From d17c1c613457d6d48c02d472e3d2a9531b502a2f Mon Sep 17 00:00:00 2001 From: predatorx7 Date: Mon, 26 Oct 2026 14:58:03 +0530 Subject: [PATCH 1/4] build: modernize toolchain, CI and the example app Tooling-only changes so the repository builds and tests on current Flutter stable (3.47) with a current JDK. No behavior changes. - javascript_android: convert android/build.gradle to Kotlin DSL on AGP 9.1.0 / Kotlin 2.4.0 / Java 17 / compileSdk 36, matching the Flutter 3.47 plugin template. minSdk is now declared as 26, which androidx.javascriptengine 1.0.0 already required. Drop the stale standalone settings.gradle (AGP 7.3 / Kotlin 1.7). - javascript_android: bump pigeon ^26 -> ^29 and regenerate. The host API methods now use `@asyncCallback` so the generated Kotlin keeps the existing callback signatures instead of suspend functions. `make gen_schema` formats the generated Dart. - example: regenerate the platform folders with `flutter create` on Flutter 3.47 (Gradle 9.3.1, AGP 9.1.0, Kotlin 2.4.0, SwiftPM), keeping lib/, README, pubspec and the path overrides. The previous Gradle 8.12 wrapper could not run on JDK 25. Keep minSdk 26 and the macOS network client entitlement the example needs to load scripts from URLs. Remove the empty widget_test.dart which failed to load. - CI: replace the eight per-package workflows, whose `paths:` filters lacked the `packages/` prefix and so never triggered, and which referenced javascript_ios/linux/macos/web/windows packages and fluttium flows that do not exist, with a single workflow that formats, analyzes and tests every package on current stable, builds the example for Android, and runs the example integration tests on an Android emulator, an iOS simulator and macOS desktop. - dependabot: point at the real package directories and the two Gradle projects. - javascript_darwin: exclude the vendored flutter_js sources from analysis; accept the analyzer excludes Flutter writes into analysis_options.yaml. Co-Authored-By: Claude Fable 5.1 --- .github/dependabot.yaml | 48 +-- .github/workflows/ci.yaml | 172 ++++++++- .github/workflows/javascript.yaml | 182 ---------- .github/workflows/javascript_android.yaml | 26 -- .github/workflows/javascript_ios.yaml | 26 -- .github/workflows/javascript_linux.yaml | 26 -- .github/workflows/javascript_macos.yaml | 26 -- .../javascript_platform_interface.yaml | 26 -- .github/workflows/javascript_web.yaml | 26 -- .github/workflows/javascript_windows.yaml | 26 -- packages/javascript/.gitignore | 1 - packages/javascript/analysis_options.yaml | 3 + packages/javascript/example/.gitignore | 5 +- packages/javascript/example/.metadata | 18 +- .../javascript/example/analysis_options.yaml | 5 + .../example/android/app/build.gradle.kts | 26 +- .../android/app/src/main/AndroidManifest.xml | 2 +- .../MainActivity.kt | 2 +- .../example/android/build.gradle.kts | 5 +- .../example/android/gradle.properties | 5 +- .../gradle/wrapper/gradle-wrapper.properties | 2 +- .../example/android/settings.gradle.kts | 19 +- .../ios/Flutter/AppFrameworkInfo.plist | 2 - .../example/ios/Flutter/Debug.xcconfig | 1 - .../example/ios/Flutter/Release.xcconfig | 1 - packages/javascript/example/ios/Podfile | 43 --- .../ios/Runner.xcodeproj/project.pbxproj | 161 +++------ .../xcshareddata/xcschemes/Runner.xcscheme | 18 + .../contents.xcworkspacedata | 3 - .../example/ios/Runner/AppDelegate.swift | 7 +- .../javascript/example/ios/Runner/Info.plist | 33 +- .../example/ios/Runner/SceneDelegate.swift | 6 + .../macos/Flutter/Flutter-Debug.xcconfig | 1 - .../macos/Flutter/Flutter-Release.xcconfig | 1 - packages/javascript/example/macos/Podfile | 42 --- .../macos/Runner.xcodeproj/project.pbxproj | 144 ++------ .../xcshareddata/xcschemes/Runner.xcscheme | 26 +- .../contents.xcworkspacedata | 3 - .../macos/Runner/Configs/AppInfo.xcconfig | 6 +- .../javascript/example/test/widget_test.dart | 1 - packages/javascript/pubspec_overrides.yaml | 7 + packages/javascript_android/Makefile | 1 + .../javascript_android/analysis_options.yaml | 3 + .../javascript_android/android/build.gradle | 59 ---- .../android/build.gradle.kts | 80 +++++ .../android/settings.gradle | 6 - .../javascript_android/Messages.kt | 79 ++++- .../lib/src/pigeons/messages.pigeon.dart | 332 +++++++----------- .../javascript_android/pigeons/schema.dart | 12 +- packages/javascript_android/pubspec.yaml | 2 +- .../javascript_darwin/analysis_options.yaml | 7 + .../lib/javascriptcore/jscore/js_object.dart | 85 ++--- .../lib/javascriptcore/jscore/js_value.dart | 27 +- 53 files changed, 747 insertions(+), 1129 deletions(-) delete mode 100644 .github/workflows/javascript.yaml delete mode 100644 .github/workflows/javascript_android.yaml delete mode 100644 .github/workflows/javascript_ios.yaml delete mode 100644 .github/workflows/javascript_linux.yaml delete mode 100644 .github/workflows/javascript_macos.yaml delete mode 100644 .github/workflows/javascript_platform_interface.yaml delete mode 100644 .github/workflows/javascript_web.yaml delete mode 100644 .github/workflows/javascript_windows.yaml delete mode 100644 packages/javascript/.gitignore rename packages/javascript/example/android/app/src/main/kotlin/com/example/{u_example => javascript_example}/MainActivity.kt (70%) delete mode 100644 packages/javascript/example/ios/Podfile create mode 100644 packages/javascript/example/ios/Runner/SceneDelegate.swift delete mode 100644 packages/javascript/example/macos/Podfile delete mode 100644 packages/javascript/example/test/widget_test.dart create mode 100644 packages/javascript/pubspec_overrides.yaml delete mode 100644 packages/javascript_android/android/build.gradle create mode 100644 packages/javascript_android/android/build.gradle.kts delete mode 100644 packages/javascript_android/android/settings.gradle 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.yaml b/.github/workflows/ci.yaml index 112f425..5bc951d 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,10 +1,180 @@ 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 15 + + - 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. + # Under load the hosted runners occasionally never deliver the VM + # service line and `flutter test` hangs after the build, so each + # attempt runs under a watchdog and only a hang (SIGALRM, exit 142) + # is retried; real test failures exit immediately. + run: | + UDID=${{ steps.simulator.outputs.udid }} + for attempt in 1 2 3; do + perl -e 'alarm shift; exec @ARGV' 600 \ + flutter test integration_test/javascript_test.dart -d "$UDID" && exit 0 + status=$? + [ "$status" -eq 142 ] || exit "$status" + echo "::warning::attempt $attempt hung waiting for the app; retrying" + xcrun simctl terminate "$UDID" com.example.javascriptExample || true + done + exit 1 + + - name: Shut down simulator + # The runner's "Complete job" cleanup can hang for a long time + # terminating an orphaned Simulator.app, so close it ourselves. + if: always() + run: | + osascript -e 'quit app "Simulator"' || true + xcrun simctl shutdown "${{ steps.simulator.outputs.udid }}" || true + + 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) + # Same watchdog-and-retry as the simulator job: only a hang is retried. + run: | + for attempt in 1 2 3; do + perl -e 'alarm shift; exec @ARGV' 600 \ + flutter test integration_test/javascript_test.dart -d macos && exit 0 + status=$? + [ "$status" -eq 142 ] || exit "$status" + echo "::warning::attempt $attempt hung waiting for the app; retrying" + pkill -f javascript_example.app || true + done + exit 1 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/analysis_options.yaml b/packages/javascript/analysis_options.yaml index a5744c1..e46654b 100644 --- a/packages/javascript/analysis_options.yaml +++ b/packages/javascript/analysis_options.yaml @@ -1,3 +1,6 @@ +analyzer: + exclude: + - build/** include: package:flutter_lints/flutter.yaml # Additional information about this file can be found at diff --git a/packages/javascript/example/.gitignore b/packages/javascript/example/.gitignore index 79c113f..79f7eca 100644 --- a/packages/javascript/example/.gitignore +++ b/packages/javascript/example/.gitignore @@ -27,11 +27,11 @@ migrate_working_dir/ **/doc/api/ **/ios/Flutter/.last_build_id .dart_tool/ -.flutter-plugins .flutter-plugins-dependencies .pub-cache/ .pub/ /build/ +/coverage/ # Symbolication related app.*.symbols @@ -43,3 +43,6 @@ app.*.map.json /android/app/debug /android/app/profile /android/app/release + +# Widget Preview related +.widget_preview/ diff --git a/packages/javascript/example/.metadata b/packages/javascript/example/.metadata index 6a0f74d..6d69b9f 100644 --- a/packages/javascript/example/.metadata +++ b/packages/javascript/example/.metadata @@ -4,7 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "edada7c56edf4a183c1735310e123c7f923584f1" + revision: "737ba8f885dc774b53b442759999a312c9461e4e" channel: "stable" project_type: app @@ -13,17 +13,17 @@ project_type: app migration: platforms: - platform: root - create_revision: edada7c56edf4a183c1735310e123c7f923584f1 - base_revision: edada7c56edf4a183c1735310e123c7f923584f1 + create_revision: 737ba8f885dc774b53b442759999a312c9461e4e + base_revision: 737ba8f885dc774b53b442759999a312c9461e4e - platform: android - create_revision: edada7c56edf4a183c1735310e123c7f923584f1 - base_revision: edada7c56edf4a183c1735310e123c7f923584f1 + create_revision: 737ba8f885dc774b53b442759999a312c9461e4e + base_revision: 737ba8f885dc774b53b442759999a312c9461e4e - platform: ios - create_revision: edada7c56edf4a183c1735310e123c7f923584f1 - base_revision: edada7c56edf4a183c1735310e123c7f923584f1 + create_revision: 737ba8f885dc774b53b442759999a312c9461e4e + base_revision: 737ba8f885dc774b53b442759999a312c9461e4e - platform: macos - create_revision: edada7c56edf4a183c1735310e123c7f923584f1 - base_revision: edada7c56edf4a183c1735310e123c7f923584f1 + create_revision: 737ba8f885dc774b53b442759999a312c9461e4e + base_revision: 737ba8f885dc774b53b442759999a312c9461e4e # User provided section diff --git a/packages/javascript/example/analysis_options.yaml b/packages/javascript/example/analysis_options.yaml index ace0476..271b84f 100644 --- a/packages/javascript/example/analysis_options.yaml +++ b/packages/javascript/example/analysis_options.yaml @@ -10,6 +10,11 @@ analyzer: errors: avoid_print: ignore + exclude: + - build/** + - android/** + - ios/** + - macos/** include: package:flutter_lints/flutter.yaml linter: diff --git a/packages/javascript/example/android/app/build.gradle.kts b/packages/javascript/example/android/app/build.gradle.kts index e84cf03..81be225 100644 --- a/packages/javascript/example/android/app/build.gradle.kts +++ b/packages/javascript/example/android/app/build.gradle.kts @@ -1,31 +1,31 @@ plugins { id("com.android.application") - id("kotlin-android") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") } android { - namespace = "com.example.u_example" + namespace = "com.example.javascript_example" compileSdk = flutter.compileSdkVersion ndkVersion = flutter.ndkVersion compileOptions { - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 - } - - kotlinOptions { - jvmTarget = JavaVersion.VERSION_11.toString() + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId = "com.example.u_example" + applicationId = "com.example.javascript_example" // You can update the following values to match your application needs. // For more information, see: https://flutter.dev/to/review-gradle-config. - minSdk = 26 // flutter.minSdkVersion + // javascript_android (androidx.javascriptengine) requires API 26 or newer. + minSdk = 26 targetSdk = flutter.targetSdkVersion + // Uses the version code from pubspec.yaml. When using split APKs, 1000 * ABI_VERSION + // is added automatically by Flutter. (https://developer.android.com/studio/build/configure-apk-splits#configure-APK-versions) + // You can force using the value of versionCode by specifying the `-P force-version-code-ignoring-abi=true` + // flag during build. versionCode = flutter.versionCode versionName = flutter.versionName } @@ -39,6 +39,12 @@ android { } } +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + flutter { source = "../.." } diff --git a/packages/javascript/example/android/app/src/main/AndroidManifest.xml b/packages/javascript/example/android/app/src/main/AndroidManifest.xml index 1b09833..df2b2cc 100644 --- a/packages/javascript/example/android/app/src/main/AndroidManifest.xml +++ b/packages/javascript/example/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,6 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 13.0 diff --git a/packages/javascript/example/ios/Flutter/Debug.xcconfig b/packages/javascript/example/ios/Flutter/Debug.xcconfig index ec97fc6..592ceee 100644 --- a/packages/javascript/example/ios/Flutter/Debug.xcconfig +++ b/packages/javascript/example/ios/Flutter/Debug.xcconfig @@ -1,2 +1 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/packages/javascript/example/ios/Flutter/Release.xcconfig b/packages/javascript/example/ios/Flutter/Release.xcconfig index c4855bf..592ceee 100644 --- a/packages/javascript/example/ios/Flutter/Release.xcconfig +++ b/packages/javascript/example/ios/Flutter/Release.xcconfig @@ -1,2 +1 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/packages/javascript/example/ios/Podfile b/packages/javascript/example/ios/Podfile deleted file mode 100644 index 620e46e..0000000 --- a/packages/javascript/example/ios/Podfile +++ /dev/null @@ -1,43 +0,0 @@ -# Uncomment this line to define a global platform for your project -# platform :ios, '13.0' - -# CocoaPods analytics sends network stats synchronously affecting flutter build latency. -ENV['COCOAPODS_DISABLE_STATS'] = 'true' - -project 'Runner', { - 'Debug' => :debug, - 'Profile' => :release, - 'Release' => :release, -} - -def flutter_root - generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) - unless File.exist?(generated_xcode_build_settings_path) - raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" - end - - File.foreach(generated_xcode_build_settings_path) do |line| - matches = line.match(/FLUTTER_ROOT\=(.*)/) - return matches[1].strip if matches - end - raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" -end - -require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) - -flutter_ios_podfile_setup - -target 'Runner' do - use_frameworks! - - flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) - target 'RunnerTests' do - inherit! :search_paths - end -end - -post_install do |installer| - installer.pods_project.targets.each do |target| - flutter_additional_ios_build_settings(target) - end -end diff --git a/packages/javascript/example/ios/Runner.xcodeproj/project.pbxproj b/packages/javascript/example/ios/Runner.xcodeproj/project.pbxproj index 3292c73..eb3f602 100644 --- a/packages/javascript/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/javascript/example/ios/Runner.xcodeproj/project.pbxproj @@ -11,11 +11,11 @@ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; - C18B3414394AF2B7D715038F /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CE53F133A9F3BB6133AF9097 /* Pods_Runner.framework */; }; - F4C7252B52516FA16F1A222C /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 35AEE5AE5D4DD0190C00785B /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -42,18 +42,15 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 0CCFC7F62B43C040398F1023 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 310D22F9A35A3CF9422D69AE /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 35AEE5AE5D4DD0190C00785B /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 47634C540BFF1FDF01E5B0C0 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; - 71F017915446B3AE64C7186C /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; @@ -62,25 +59,14 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 9B56F23C0F6328AB5ED79AA8 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; - C7AD750105693F3956607AF2 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - CE53F133A9F3BB6133AF9097 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ - 562F4CC1C5DFE72356BA0E49 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - F4C7252B52516FA16F1A222C /* Pods_RunnerTests.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - C18B3414394AF2B7D715038F /* Pods_Runner.framework in Frameworks */, + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -98,6 +84,7 @@ 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -113,8 +100,6 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, - F5C7AB9347B53C7AD92D7BE3 /* Pods */, - D79F224981C525B33C0BC556 /* Frameworks */, ); sourceTree = ""; }; @@ -137,34 +122,12 @@ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, ); path = Runner; sourceTree = ""; }; - D79F224981C525B33C0BC556 /* Frameworks */ = { - isa = PBXGroup; - children = ( - CE53F133A9F3BB6133AF9097 /* Pods_Runner.framework */, - 35AEE5AE5D4DD0190C00785B /* Pods_RunnerTests.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; - F5C7AB9347B53C7AD92D7BE3 /* Pods */ = { - isa = PBXGroup; - children = ( - 9B56F23C0F6328AB5ED79AA8 /* Pods-Runner.debug.xcconfig */, - C7AD750105693F3956607AF2 /* Pods-Runner.release.xcconfig */, - 71F017915446B3AE64C7186C /* Pods-Runner.profile.xcconfig */, - 0CCFC7F62B43C040398F1023 /* Pods-RunnerTests.debug.xcconfig */, - 310D22F9A35A3CF9422D69AE /* Pods-RunnerTests.release.xcconfig */, - 47634C540BFF1FDF01E5B0C0 /* Pods-RunnerTests.profile.xcconfig */, - ); - name = Pods; - path = Pods; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -172,10 +135,8 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( - 07B0E66F96123F84A12BE111 /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, - 562F4CC1C5DFE72356BA0E49 /* Frameworks */, ); buildRules = ( ); @@ -191,20 +152,21 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - 34771D4095194AD893A4FF68 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - 6D85BD65D456EC00FD8C7CF9 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); dependencies = ( ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; @@ -238,6 +200,9 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; @@ -270,50 +235,6 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 07B0E66F96123F84A12BE111 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; - 34771D4095194AD893A4FF68 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -330,23 +251,6 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; - 6D85BD65D456EC00FD8C7CF9 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -379,6 +283,7 @@ files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -455,9 +360,10 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTED_PLATFORMS = iphoneos; TARGETED_DEVICE_FAMILY = "1,2"; VALIDATE_PRODUCT = YES; @@ -478,7 +384,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.Example; + PRODUCT_BUNDLE_IDENTIFIER = com.example.javascriptExample; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -488,14 +394,13 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 0CCFC7F62B43C040398F1023 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.Example.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.example.javascriptExample.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -506,14 +411,13 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 310D22F9A35A3CF9422D69AE /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.Example.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.example.javascriptExample.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -522,14 +426,13 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 47634C540BFF1FDF01E5B0C0 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.Example.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.example.javascriptExample.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; @@ -585,10 +488,11 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; @@ -636,9 +540,10 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SUPPORTED_PLATFORMS = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; @@ -661,7 +566,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.Example; + PRODUCT_BUNDLE_IDENTIFIER = com.example.javascriptExample; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -684,7 +589,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.Example; + PRODUCT_BUNDLE_IDENTIFIER = com.example.javascriptExample; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -726,6 +631,20 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/packages/javascript/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/javascript/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index e3773d4..c3fedb2 100644 --- a/packages/javascript/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/javascript/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + - - diff --git a/packages/javascript/example/ios/Runner/AppDelegate.swift b/packages/javascript/example/ios/Runner/AppDelegate.swift index 6266644..c30b367 100644 --- a/packages/javascript/example/ios/Runner/AppDelegate.swift +++ b/packages/javascript/example/ios/Runner/AppDelegate.swift @@ -2,12 +2,15 @@ import Flutter import UIKit @main -@objc class AppDelegate: FlutterAppDelegate { +@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } } diff --git a/packages/javascript/example/ios/Runner/Info.plist b/packages/javascript/example/ios/Runner/Info.plist index 9bd177d..7332cf7 100644 --- a/packages/javascript/example/ios/Runner/Info.plist +++ b/packages/javascript/example/ios/Runner/Info.plist @@ -2,10 +2,12 @@ + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Example + Javascript Example CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -13,7 +15,7 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - _example + javascript_example CFBundlePackageType APPL CFBundleShortVersionString @@ -24,6 +26,29 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + + UIApplicationSupportsIndirectInputEvents + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -41,9 +66,5 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - diff --git a/packages/javascript/example/ios/Runner/SceneDelegate.swift b/packages/javascript/example/ios/Runner/SceneDelegate.swift new file mode 100644 index 0000000..b9ce8ea --- /dev/null +++ b/packages/javascript/example/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +class SceneDelegate: FlutterSceneDelegate { + +} diff --git a/packages/javascript/example/macos/Flutter/Flutter-Debug.xcconfig b/packages/javascript/example/macos/Flutter/Flutter-Debug.xcconfig index 4b81f9b..c2efd0b 100644 --- a/packages/javascript/example/macos/Flutter/Flutter-Debug.xcconfig +++ b/packages/javascript/example/macos/Flutter/Flutter-Debug.xcconfig @@ -1,2 +1 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/packages/javascript/example/macos/Flutter/Flutter-Release.xcconfig b/packages/javascript/example/macos/Flutter/Flutter-Release.xcconfig index 5caa9d1..c2efd0b 100644 --- a/packages/javascript/example/macos/Flutter/Flutter-Release.xcconfig +++ b/packages/javascript/example/macos/Flutter/Flutter-Release.xcconfig @@ -1,2 +1 @@ -#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/packages/javascript/example/macos/Podfile b/packages/javascript/example/macos/Podfile deleted file mode 100644 index ff5ddb3..0000000 --- a/packages/javascript/example/macos/Podfile +++ /dev/null @@ -1,42 +0,0 @@ -platform :osx, '10.15' - -# CocoaPods analytics sends network stats synchronously affecting flutter build latency. -ENV['COCOAPODS_DISABLE_STATS'] = 'true' - -project 'Runner', { - 'Debug' => :debug, - 'Profile' => :release, - 'Release' => :release, -} - -def flutter_root - generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) - unless File.exist?(generated_xcode_build_settings_path) - raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" - end - - File.foreach(generated_xcode_build_settings_path) do |line| - matches = line.match(/FLUTTER_ROOT\=(.*)/) - return matches[1].strip if matches - end - raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" -end - -require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) - -flutter_macos_podfile_setup - -target 'Runner' do - use_frameworks! - - flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) - target 'RunnerTests' do - inherit! :search_paths - end -end - -post_install do |installer| - installer.pods_project.targets.each do |target| - flutter_additional_macos_build_settings(target) - end -end diff --git a/packages/javascript/example/macos/Runner.xcodeproj/project.pbxproj b/packages/javascript/example/macos/Runner.xcodeproj/project.pbxproj index 40015d7..50afcb9 100644 --- a/packages/javascript/example/macos/Runner.xcodeproj/project.pbxproj +++ b/packages/javascript/example/macos/Runner.xcodeproj/project.pbxproj @@ -27,8 +27,7 @@ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; - BFBAC3B16D53ABFF5C6010E4 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A6547DC87827AD27ACE252A1 /* Pods_Runner.framework */; }; - DF9FBC1EF83BCFBD15188341 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 755B3F1CAC14D191EC1E0EA5 /* Pods_RunnerTests.framework */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -62,12 +61,11 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 14FD868C341BBC91A8708987 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* _example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = _example.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* javascript_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "javascript_example.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -79,15 +77,9 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; - 6180346A1182816A5005AED2 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - 666B6E5BAEFF77984EEF17DA /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; - 755B3F1CAC14D191EC1E0EA5 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; - A6547DC87827AD27ACE252A1 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - D8FC23FD36FE2E7C26B23A57 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; - EFFE11C9CB78D133520718F3 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; - F25B4AE472230037926A3740 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -95,7 +87,6 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - DF9FBC1EF83BCFBD15188341 /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -103,7 +94,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - BFBAC3B16D53ABFF5C6010E4 /* Pods_Runner.framework in Frameworks */, + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -137,14 +128,13 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, - B3E19D78F2422C6236282864 /* Pods */, ); sourceTree = ""; }; 33CC10EE2044A3C60003C045 /* Products */ = { isa = PBXGroup; children = ( - 33CC10ED2044A3C60003C045 /* _example.app */, + 33CC10ED2044A3C60003C045 /* javascript_example.app */, 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, ); name = Products; @@ -164,6 +154,7 @@ 33CEB47122A05771004F2AC0 /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, @@ -185,25 +176,9 @@ path = Runner; sourceTree = ""; }; - B3E19D78F2422C6236282864 /* Pods */ = { - isa = PBXGroup; - children = ( - 14FD868C341BBC91A8708987 /* Pods-Runner.debug.xcconfig */, - 6180346A1182816A5005AED2 /* Pods-Runner.release.xcconfig */, - EFFE11C9CB78D133520718F3 /* Pods-Runner.profile.xcconfig */, - D8FC23FD36FE2E7C26B23A57 /* Pods-RunnerTests.debug.xcconfig */, - 666B6E5BAEFF77984EEF17DA /* Pods-RunnerTests.release.xcconfig */, - F25B4AE472230037926A3740 /* Pods-RunnerTests.profile.xcconfig */, - ); - name = Pods; - path = Pods; - sourceTree = ""; - }; D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( - A6547DC87827AD27ACE252A1 /* Pods_Runner.framework */, - 755B3F1CAC14D191EC1E0EA5 /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -215,7 +190,6 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( - FB52D41D073454F57E72BEBD /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -234,13 +208,11 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - E749E1299F7CFA72A3083211 /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, - 5365244B2192E9C98378034D /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -248,8 +220,11 @@ 33CC11202044C79F0003C045 /* PBXTargetDependency */, ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* _example.app */; + productReference = 33CC10ED2044A3C60003C045 /* javascript_example.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -292,6 +267,9 @@ Base, ); mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -361,67 +339,6 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; - 5365244B2192E9C98378034D /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; - E749E1299F7CFA72A3083211 /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; - FB52D41D073454F57E72BEBD /* [CP] Check Pods Manifest.lock */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; - outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; - showEnvVarsInLog = 0; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -473,46 +390,43 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = D8FC23FD36FE2E7C26B23A57 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.Example.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.example.javascriptExample.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/_example"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/javascript_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/javascript_example"; }; name = Debug; }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 666B6E5BAEFF77984EEF17DA /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.Example.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.example.javascriptExample.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/_example"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/javascript_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/javascript_example"; }; name = Release; }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = F25B4AE472230037926A3740 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.Example.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.example.javascriptExample.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/_example"; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/javascript_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/javascript_example"; }; name = Profile; }; @@ -557,7 +471,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -639,7 +553,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -689,7 +603,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.15; + MACOSX_DEPLOYMENT_TARGET = 12.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -796,6 +710,20 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 33CC10E52044A3C60003C045 /* Project object */; } diff --git a/packages/javascript/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/javascript/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 6fae876..bcfd698 100644 --- a/packages/javascript/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/javascript/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + @@ -31,7 +49,7 @@ @@ -66,7 +84,7 @@ @@ -83,7 +101,7 @@ diff --git a/packages/javascript/example/macos/Runner.xcworkspace/contents.xcworkspacedata b/packages/javascript/example/macos/Runner.xcworkspace/contents.xcworkspacedata index 21a3cc1..1d526a1 100644 --- a/packages/javascript/example/macos/Runner.xcworkspace/contents.xcworkspacedata +++ b/packages/javascript/example/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -4,7 +4,4 @@ - - diff --git a/packages/javascript/example/macos/Runner/Configs/AppInfo.xcconfig b/packages/javascript/example/macos/Runner/Configs/AppInfo.xcconfig index 1ee3748..2047e4f 100644 --- a/packages/javascript/example/macos/Runner/Configs/AppInfo.xcconfig +++ b/packages/javascript/example/macos/Runner/Configs/AppInfo.xcconfig @@ -5,10 +5,10 @@ // 'flutter create' template. // The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = _example +PRODUCT_NAME = javascript_example // The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = com.example.Example +PRODUCT_BUNDLE_IDENTIFIER = com.example.javascriptExample // The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2025 com.example. All rights reserved. +PRODUCT_COPYRIGHT = Copyright © 2026 com.example. All rights reserved. diff --git a/packages/javascript/example/test/widget_test.dart b/packages/javascript/example/test/widget_test.dart deleted file mode 100644 index 8b13789..0000000 --- a/packages/javascript/example/test/widget_test.dart +++ /dev/null @@ -1 +0,0 @@ - diff --git a/packages/javascript/pubspec_overrides.yaml b/packages/javascript/pubspec_overrides.yaml new file mode 100644 index 0000000..03a7db3 --- /dev/null +++ b/packages/javascript/pubspec_overrides.yaml @@ -0,0 +1,7 @@ +dependency_overrides: + javascript_platform_interface: + path: ../javascript_platform_interface + javascript_android: + path: ../javascript_android + javascript_darwin: + path: ../javascript_darwin diff --git a/packages/javascript_android/Makefile b/packages/javascript_android/Makefile index 37a5b20..53a3c7a 100644 --- a/packages/javascript_android/Makefile +++ b/packages/javascript_android/Makefile @@ -1,2 +1,3 @@ gen_schema: dart run pigeon --input pigeons/schema.dart + dart format lib/src/pigeons diff --git a/packages/javascript_android/analysis_options.yaml b/packages/javascript_android/analysis_options.yaml index 31674fe..dc93f05 100644 --- a/packages/javascript_android/analysis_options.yaml +++ b/packages/javascript_android/analysis_options.yaml @@ -2,3 +2,6 @@ include: package:flutter_lints/flutter.yaml analyzer: errors: constant_identifier_names: ignore + exclude: + - build/** + - android/** diff --git a/packages/javascript_android/android/build.gradle b/packages/javascript_android/android/build.gradle deleted file mode 100644 index 25e5947..0000000 --- a/packages/javascript_android/android/build.gradle +++ /dev/null @@ -1,59 +0,0 @@ -plugins { - id "com.android.library" - id "kotlin-android" -} - -group 'com.magnificsoftware.javascript_android' -version '1.0-SNAPSHOT' - -allprojects { - repositories { - google() - mavenCentral() - } -} - -android { - if (project.android.hasProperty("namespace")) { - namespace 'com.magnificsoftware.javascript_android' - } - - compileSdk 34 - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - kotlinOptions { - jvmTarget = '1.8' - } - - sourceSets { - main.java.srcDirs += 'src/main/kotlin' - test.java.srcDirs += 'src/test/kotlin' - } - - defaultConfig { - minSdkVersion 21 - } - - dependencies { - testImplementation 'org.jetbrains.kotlin:kotlin-test' - testImplementation 'org.mockito:mockito-core:5.18.0' - - implementation "androidx.javascriptengine:javascriptengine:1.0.0" - } - - testOptions { - unitTests.all { - useJUnitPlatform() - - testLogging { - events "passed", "skipped", "failed", "standardOut", "standardError" - outputs.upToDateWhen {false} - showStandardStreams = true - } - } - } -} \ No newline at end of file diff --git a/packages/javascript_android/android/build.gradle.kts b/packages/javascript_android/android/build.gradle.kts new file mode 100644 index 0000000..d7e2840 --- /dev/null +++ b/packages/javascript_android/android/build.gradle.kts @@ -0,0 +1,80 @@ +group = "com.magnificsoftware.javascript_android" +version = "1.0-SNAPSHOT" + +buildscript { + val kotlinVersion = "2.4.0" + repositories { + google() + mavenCentral() + } + + dependencies { + classpath("com.android.tools.build:gradle:9.1.0") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion") + } +} + +allprojects { + repositories { + google() + mavenCentral() + } +} + +plugins { + id("com.android.library") +} + +android { + namespace = "com.magnificsoftware.javascript_android" + + compileSdk = 36 + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + sourceSets { + getByName("main") { + java.srcDirs("src/main/kotlin") + } + getByName("test") { + java.srcDirs("src/test/kotlin") + } + } + + defaultConfig { + // androidx.javascriptengine requires API 26 or newer. + minSdk = 26 + } + + testOptions { + unitTests { + isIncludeAndroidResources = true + all { + it.useJUnitPlatform() + + it.outputs.upToDateWhen { false } + + it.testLogging { + events("passed", "skipped", "failed", "standardOut", "standardError") + showStandardStreams = true + } + } + } + } +} + +kotlin { + compilerOptions { + jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 + } +} + +dependencies { + implementation("androidx.javascriptengine:javascriptengine:1.0.0") + + testImplementation("org.jetbrains.kotlin:kotlin-test") + testImplementation("org.mockito:mockito-core:5.18.0") +} diff --git a/packages/javascript_android/android/settings.gradle b/packages/javascript_android/android/settings.gradle deleted file mode 100644 index 2f1b65a..0000000 --- a/packages/javascript_android/android/settings.gradle +++ /dev/null @@ -1,6 +0,0 @@ -rootProject.name = 'javascript_android' - -plugins { - id "com.android.library" version "7.3.0" apply false - id "org.jetbrains.kotlin.android" version "1.7.10" apply false -} \ No newline at end of file diff --git a/packages/javascript_android/android/src/main/kotlin/com/magnificsoftware/javascript_android/Messages.kt b/packages/javascript_android/android/src/main/kotlin/com/magnificsoftware/javascript_android/Messages.kt index c624b19..02acd94 100644 --- a/packages/javascript_android/android/src/main/kotlin/com/magnificsoftware/javascript_android/Messages.kt +++ b/packages/javascript_android/android/src/main/kotlin/com/magnificsoftware/javascript_android/Messages.kt @@ -1,5 +1,5 @@ // Copyright 2025, Mushaheed Syed. Use of this source code is governed by a license that can be found in the LICENSE file. -// Autogenerated from Pigeon (v26.0.0), do not edit directly. +// Autogenerated from Pigeon (v29.0.11), do not edit directly. // See also: https://pub.dev/packages/pigeon @file:Suppress("UNCHECKED_CAST", "ArrayInDataClass") @@ -50,7 +50,7 @@ class FlutterError ( val code: String, override val message: String? = null, val details: Any? = null -) : Throwable() +) : RuntimeException() /** * Maintains instances used to communicate with the corresponding objects in Dart. * @@ -68,16 +68,44 @@ class FlutterError ( */ @Suppress("UNCHECKED_CAST", "MemberVisibilityCanBePrivate") class MessagesPigeonInstanceManager(private val finalizationListener: PigeonFinalizationListener) { - /** Interface for listening when a weak reference of an instance is removed from the manager. */ + /** Interface for listening when a weak reference of an instance is removed from the manager. */ interface PigeonFinalizationListener { fun onFinalize(identifier: Long) } - private val identifiers = java.util.WeakHashMap() - private val weakInstances = HashMap>() + // Extends WeakReference and overrides the `equals` and `hashCode` methods using identity rather + // than equality. + // + // Two `IdentityWeakReference`s are equal if either + // 1: `get()` returns the identical nonnull value for both references. + // 2: `get()` returns null for both references and the references are identical. + class IdentityWeakReference : java.lang.ref.WeakReference { + private val savedHashCode: Int + + constructor(instance: T) : this(instance, null) + + constructor(instance: T, queue: java.lang.ref.ReferenceQueue?) : super(instance, queue) { + savedHashCode = System.identityHashCode(instance) + } + + override fun equals(other: Any?): Boolean { + val instance = get() + if (instance != null) { + return other is IdentityWeakReference<*> && other.get() === instance + } + return other === this + } + + override fun hashCode(): Int { + return savedHashCode + } + } + + private val identifiers = java.util.WeakHashMap, Long>() + private val weakInstances = HashMap>() private val strongInstances = HashMap() private val referenceQueue = java.lang.ref.ReferenceQueue() - private val weakReferencesToIdentifiers = HashMap, Long>() + private val weakReferencesToIdentifiers = HashMap, Long>() private val handler = android.os.Handler(android.os.Looper.getMainLooper()) private val releaseAllFinalizedInstancesRunnable = Runnable { this.releaseAllFinalizedInstances() @@ -143,9 +171,12 @@ class MessagesPigeonInstanceManager(private val finalizationListener: PigeonFina */ fun getIdentifierForStrongReference(instance: Any?): Long? { logWarningIfFinalizationListenerHasStopped() - val identifier = identifiers[instance] + if (instance == null) { + return null + } + val identifier = identifiers[IdentityWeakReference(instance)] if (identifier != null) { - strongInstances[identifier] = instance!! + strongInstances[identifier] = instance } return identifier } @@ -180,16 +211,16 @@ class MessagesPigeonInstanceManager(private val finalizationListener: PigeonFina } /** Retrieves the instance associated with identifier, if present, otherwise `null`. */ - fun getInstance(identifier: Long): T? { + fun getInstance(identifier: Long): T? { logWarningIfFinalizationListenerHasStopped() - val instance = weakInstances[identifier] as java.lang.ref.WeakReference? + val instance = weakInstances[identifier] as IdentityWeakReference? return instance?.get() } /** Returns whether this manager contains the given `instance`. */ fun containsInstance(instance: Any?): Boolean { logWarningIfFinalizationListenerHasStopped() - return identifiers.containsKey(instance) + return instance != null && identifiers.containsKey(IdentityWeakReference(instance)) } /** @@ -230,8 +261,8 @@ class MessagesPigeonInstanceManager(private val finalizationListener: PigeonFina if (hasFinalizationListenerStopped()) { return } - var reference: java.lang.ref.WeakReference? - while ((referenceQueue.poll() as java.lang.ref.WeakReference?).also { reference = it } != null) { + var reference: IdentityWeakReference? + while ((referenceQueue.poll() as IdentityWeakReference?).also { reference = it } != null) { val identifier = weakReferencesToIdentifiers.remove(reference) if (identifier != null) { weakInstances.remove(identifier) @@ -247,8 +278,8 @@ class MessagesPigeonInstanceManager(private val finalizationListener: PigeonFina require(!weakInstances.containsKey(identifier)) { "Identifier has already been added: $identifier" } - val weakReference = java.lang.ref.WeakReference(instance, referenceQueue) - identifiers[instance] = identifier + val weakReference = IdentityWeakReference(instance, referenceQueue) + identifiers[weakReference] = identifier weakInstances[identifier] = weakReference weakReferencesToIdentifiers[weakReference] = identifier strongInstances[identifier] = instance @@ -405,8 +436,19 @@ private class MessagesPigeonProxyApiBaseCodec(val registrar: MessagesPigeonProxy return } + fun logNewInstanceFailure(apiName: String, value: Any, exception: Throwable?) { + Log.w( + "PigeonProxyApiBaseCodec", + "Failed to create new Dart proxy instance of $apiName: $value. $exception" + ) + } + if (value is JavaScriptAndroidConsoleMessageHandler) { - registrar.getPigeonApiJavaScriptAndroidConsoleMessageHandler().pigeon_newInstance(value) { } + registrar.getPigeonApiJavaScriptAndroidConsoleMessageHandler().pigeon_newInstance(value) { + if (it.isFailure) { + logNewInstanceFailure("JavaScriptAndroidConsoleMessageHandler", value, it.exceptionOrNull()) + } + } } when { @@ -482,6 +524,11 @@ abstract class PigeonApiJavaScriptAndroidConsoleMessageHandler(open val pigeonRe Result.failure( FlutterError("ignore-calls-error", "Calls to Dart are being ignored.", ""))) return + } else if (!pigeonRegistrar.instanceManager.containsInstance(pigeon_instanceArg)) { + callback( + Result.failure( + FlutterError("missing-instance-error", "Callback to `JavaScriptAndroidConsoleMessageHandler.onMessage` failed because native instance was not in the instance manager.", ""))) + return } val binaryMessenger = pigeonRegistrar.binaryMessenger val codec = pigeonRegistrar.codec diff --git a/packages/javascript_android/lib/src/pigeons/messages.pigeon.dart b/packages/javascript_android/lib/src/pigeons/messages.pigeon.dart index 02530c8..ea6008d 100644 --- a/packages/javascript_android/lib/src/pigeons/messages.pigeon.dart +++ b/packages/javascript_android/lib/src/pigeons/messages.pigeon.dart @@ -1,22 +1,40 @@ // Copyright 2025, Mushaheed Syed. Use of this source code is governed by a license that can be found in the LICENSE file. -// Autogenerated from Pigeon (v26.0.0), do not edit directly. +// Autogenerated from Pigeon (v29.0.11), do not edit directly. // See also: https://pub.dev/packages/pigeon -// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers +// ignore_for_file: unused_import, unused_shown_name +// ignore_for_file: type=lint import 'dart:async'; import 'dart:io' show Platform; -import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; +import 'dart:typed_data' show Float64List, Int32List, Int64List; -import 'package:flutter/foundation.dart' - show ReadBuffer, WriteBuffer, immutable, protected, visibleForTesting; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart' show WidgetsFlutterBinding; - -PlatformException _createConnectionError(String channelName) { - return PlatformException( - code: 'channel-error', - message: 'Unable to establish connection on channel: "$channelName".', - ); +import 'package:meta/meta.dart' show immutable, protected, visibleForTesting; + +Object? _extractReplyValueOrThrow( + List? replyList, + String channelName, { + required bool isNullValid, +}) { + if (replyList == null) { + throw PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); + } else if (replyList.length > 1) { + throw PlatformException( + code: replyList[0]! as String, + message: replyList[1] as String?, + details: replyList[2], + ); + } else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } + return replyList.firstOrNull; } List wrapResponse( @@ -30,8 +48,8 @@ List wrapResponse( return [error.code, error.message, error.details]; } -/// Provides overrides for the constructors and static members of each proxy -/// API. +/// Provides overrides for the constructors and static members of each +/// Dart proxy class. /// /// This is only intended to be used with unit tests to prevent errors from /// making message calls in a unit test. @@ -52,8 +70,8 @@ class PigeonOverrides { } } -/// An immutable object that serves as the base class for all ProxyApis and -/// can provide functional copies of itself. +/// An immutable object that serves as the base class for all Dart proxy classes +/// and can provide functional copies of itself. /// /// All implementers are expected to be [immutable] as defined by the annotation /// and override [pigeon_copy] returning an instance of itself. @@ -118,7 +136,7 @@ class PigeonInstanceManager { // 0 <= n < 2^16. static const int _maxDartCreatedIdentifier = 65536; - /// The default [PigeonInstanceManager] used by ProxyApis. + /// The default [PigeonInstanceManager] used by Dart proxy classes. /// /// On creation, this manager makes a call to clear the native /// InstanceManager. This is to prevent identifier conflicts after a host @@ -318,9 +336,7 @@ class _PigeonInternalInstanceManagerApi { PigeonInstanceManager? instanceManager, }) { { - final BasicMessageChannel< - Object?> pigeonVar_channel = BasicMessageChannel< - Object?>( + final pigeonVar_channel = BasicMessageChannel( 'dev.flutter.pigeon.javascript_android.PigeonInternalInstanceManager.removeStrongReference', pigeonChannelCodec, binaryMessenger: binaryMessenger); @@ -328,15 +344,11 @@ class _PigeonInternalInstanceManagerApi { pigeonVar_channel.setMessageHandler(null); } else { pigeonVar_channel.setMessageHandler((Object? message) async { - assert(message != null, - 'Argument for dev.flutter.pigeon.javascript_android.PigeonInternalInstanceManager.removeStrongReference was null.'); - final List args = (message as List?)!; - final int? arg_identifier = (args[0] as int?); - assert(arg_identifier != null, - 'Argument for dev.flutter.pigeon.javascript_android.PigeonInternalInstanceManager.removeStrongReference was null, expected non-null int.'); + final List args = message! as List; + final int arg_identifier = args[0]! as int; try { (instanceManager ?? PigeonInstanceManager.instance) - .remove(arg_identifier!); + .remove(arg_identifier); return wrapResponse(empty: true); } on PlatformException catch (e) { return wrapResponse(error: e); @@ -350,57 +362,43 @@ class _PigeonInternalInstanceManagerApi { } Future removeStrongReference(int identifier) async { - const String pigeonVar_channelName = + const pigeonVar_channelName = 'dev.flutter.pigeon.javascript_android.PigeonInternalInstanceManager.removeStrongReference'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( + final pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([identifier]); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; - if (pigeonVar_replyList == null) { - throw _createConnectionError(pigeonVar_channelName); - } else if (pigeonVar_replyList.length > 1) { - throw PlatformException( - code: pigeonVar_replyList[0]! as String, - message: pigeonVar_replyList[1] as String?, - details: pigeonVar_replyList[2], - ); - } else { - return; - } + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); } /// Clear the native `PigeonInstanceManager`. /// /// This is typically called after a hot restart. Future clear() async { - const String pigeonVar_channelName = + const pigeonVar_channelName = 'dev.flutter.pigeon.javascript_android.PigeonInternalInstanceManager.clear'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( + final pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; - if (pigeonVar_replyList == null) { - throw _createConnectionError(pigeonVar_channelName); - } else if (pigeonVar_replyList.length > 1) { - throw PlatformException( - code: pigeonVar_replyList[0]! as String, - message: pigeonVar_replyList[1] as String?, - details: pigeonVar_replyList[2], - ); - } else { - return; - } + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); } } @@ -482,10 +480,9 @@ class JavaScriptAndroidConsoleMessageHandler final _PigeonInternalProxyApiBaseCodec pigeonChannelCodec = _pigeonVar_codecJavaScriptAndroidConsoleMessageHandler; final BinaryMessenger? pigeonVar_binaryMessenger = pigeon_binaryMessenger; - const String pigeonVar_channelName = + const pigeonVar_channelName = 'dev.flutter.pigeon.javascript_android.JavaScriptAndroidConsoleMessageHandler.pigeon_defaultConstructor'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( + final pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, @@ -493,19 +490,13 @@ class JavaScriptAndroidConsoleMessageHandler final Future pigeonVar_sendFuture = pigeonVar_channel.send([pigeonVar_instanceIdentifier]); () async { - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; - if (pigeonVar_replyList == null) { - throw _createConnectionError(pigeonVar_channelName); - } else if (pigeonVar_replyList.length > 1) { - throw PlatformException( - code: pigeonVar_replyList[0]! as String, - message: pigeonVar_replyList[1] as String?, - details: pigeonVar_replyList[2], - ); - } else { - return; - } + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); }(); } @@ -562,9 +553,7 @@ class JavaScriptAndroidConsoleMessageHandler pigeon_instanceManager ?? PigeonInstanceManager.instance); final BinaryMessenger? binaryMessenger = pigeon_binaryMessenger; { - final BasicMessageChannel< - Object?> pigeonVar_channel = BasicMessageChannel< - Object?>( + final pigeonVar_channel = BasicMessageChannel( 'dev.flutter.pigeon.javascript_android.JavaScriptAndroidConsoleMessageHandler.onMessage', pigeonChannelCodec, binaryMessenger: binaryMessenger); @@ -572,19 +561,13 @@ class JavaScriptAndroidConsoleMessageHandler pigeonVar_channel.setMessageHandler(null); } else { pigeonVar_channel.setMessageHandler((Object? message) async { - assert(message != null, - 'Argument for dev.flutter.pigeon.javascript_android.JavaScriptAndroidConsoleMessageHandler.onMessage was null.'); - final List args = (message as List?)!; - final JavaScriptAndroidConsoleMessageHandler? arg_pigeon_instance = - (args[0] as JavaScriptAndroidConsoleMessageHandler?); - assert(arg_pigeon_instance != null, - 'Argument for dev.flutter.pigeon.javascript_android.JavaScriptAndroidConsoleMessageHandler.onMessage was null, expected non-null JavaScriptAndroidConsoleMessageHandler.'); - final String? arg_message = (args[1] as String?); - assert(arg_message != null, - 'Argument for dev.flutter.pigeon.javascript_android.JavaScriptAndroidConsoleMessageHandler.onMessage was null, expected non-null String.'); + final List args = message! as List; + final JavaScriptAndroidConsoleMessageHandler arg_pigeon_instance = + args[0]! as JavaScriptAndroidConsoleMessageHandler; + final String arg_message = args[1]! as String; try { - (onMessage ?? arg_pigeon_instance!.onMessage) - .call(arg_pigeon_instance!, arg_message!); + (onMessage ?? arg_pigeon_instance.onMessage) + .call(arg_pigeon_instance, arg_message); return wrapResponse(empty: true); } on PlatformException catch (e) { return wrapResponse(error: e); @@ -608,159 +591,126 @@ class JavaScriptAndroidConsoleMessageHandler } class JavaScriptAndroidPlatformApi { - /// Constructor for [JavaScriptAndroidPlatformApi]. The [binaryMessenger] named argument is - /// available for dependency injection. If it is left null, the default + /// Constructor for [JavaScriptAndroidPlatformApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default /// BinaryMessenger will be used which routes to the host platform. - JavaScriptAndroidPlatformApi( - {BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) - : pigeonVar_binaryMessenger = binaryMessenger, + JavaScriptAndroidPlatformApi({ + BinaryMessenger? binaryMessenger, + String messageChannelSuffix = '', + }) : pigeonVar_binaryMessenger = binaryMessenger, pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; - final BinaryMessenger? pigeonVar_binaryMessenger; + final BinaryMessenger? pigeonVar_binaryMessenger; static const MessageCodec pigeonChannelCodec = _PigeonCodec(); final String pigeonVar_messageChannelSuffix; Future startJavaScriptEnvironment(String javascriptInstanceId) async { - final String pigeonVar_channelName = + final pigeonVar_channelName = 'dev.flutter.pigeon.javascript_android.JavaScriptAndroidPlatformApi.startJavaScriptEnvironment$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( + final pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([javascriptInstanceId]); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; - if (pigeonVar_replyList == null) { - throw _createConnectionError(pigeonVar_channelName); - } else if (pigeonVar_replyList.length > 1) { - throw PlatformException( - code: pigeonVar_replyList[0]! as String, - message: pigeonVar_replyList[1] as String?, - details: pigeonVar_replyList[2], - ); - } else { - return; - } + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); } Future dispose(String javascriptInstanceId) async { - final String pigeonVar_channelName = + final pigeonVar_channelName = 'dev.flutter.pigeon.javascript_android.JavaScriptAndroidPlatformApi.dispose$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( + final pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([javascriptInstanceId]); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; - if (pigeonVar_replyList == null) { - throw _createConnectionError(pigeonVar_channelName); - } else if (pigeonVar_replyList.length > 1) { - throw PlatformException( - code: pigeonVar_replyList[0]! as String, - message: pigeonVar_replyList[1] as String?, - details: pigeonVar_replyList[2], - ); - } else { - return; - } + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); } Future runJavaScriptReturningResult( String javascriptInstanceId, String javaScript) async { - final String pigeonVar_channelName = + final pigeonVar_channelName = 'dev.flutter.pigeon.javascript_android.JavaScriptAndroidPlatformApi.runJavaScriptReturningResult$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( + final pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([javascriptInstanceId, javaScript]); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; - if (pigeonVar_replyList == null) { - throw _createConnectionError(pigeonVar_channelName); - } else if (pigeonVar_replyList.length > 1) { - throw PlatformException( - code: pigeonVar_replyList[0]! as String, - message: pigeonVar_replyList[1] as String?, - details: pigeonVar_replyList[2], - ); - } else { - return (pigeonVar_replyList[0] as String?); - } + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); + return pigeonVar_replyValue as String?; } Future runJavaScriptFromFileReturningResult( String javascriptInstanceId, String javaScriptFilePath) async { - final String pigeonVar_channelName = + final pigeonVar_channelName = 'dev.flutter.pigeon.javascript_android.JavaScriptAndroidPlatformApi.runJavaScriptFromFileReturningResult$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( + final pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel .send([javascriptInstanceId, javaScriptFilePath]); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; - if (pigeonVar_replyList == null) { - throw _createConnectionError(pigeonVar_channelName); - } else if (pigeonVar_replyList.length > 1) { - throw PlatformException( - code: pigeonVar_replyList[0]! as String, - message: pigeonVar_replyList[1] as String?, - details: pigeonVar_replyList[2], - ); - } else { - return (pigeonVar_replyList[0] as String?); - } + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); + return pigeonVar_replyValue as String?; } Future setIsInspectable( String javascriptInstanceId, bool isInspectable) async { - final String pigeonVar_channelName = + final pigeonVar_channelName = 'dev.flutter.pigeon.javascript_android.JavaScriptAndroidPlatformApi.setIsInspectable$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( + final pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, ); final Future pigeonVar_sendFuture = pigeonVar_channel.send([javascriptInstanceId, isInspectable]); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; - if (pigeonVar_replyList == null) { - throw _createConnectionError(pigeonVar_channelName); - } else if (pigeonVar_replyList.length > 1) { - throw PlatformException( - code: pigeonVar_replyList[0]! as String, - message: pigeonVar_replyList[1] as String?, - details: pigeonVar_replyList[2], - ); - } else { - return; - } + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); } Future setJavaScriptConsoleMessageHandler(String javascriptInstanceId, int mJavaScriptAndroidConsoleMessageHandlerInstanceIdentifier) async { - final String pigeonVar_channelName = + final pigeonVar_channelName = 'dev.flutter.pigeon.javascript_android.JavaScriptAndroidPlatformApi.setJavaScriptConsoleMessageHandler$pigeonVar_messageChannelSuffix'; - final BasicMessageChannel pigeonVar_channel = - BasicMessageChannel( + final pigeonVar_channel = BasicMessageChannel( pigeonVar_channelName, pigeonChannelCodec, binaryMessenger: pigeonVar_binaryMessenger, @@ -770,23 +720,13 @@ class JavaScriptAndroidPlatformApi { javascriptInstanceId, mJavaScriptAndroidConsoleMessageHandlerInstanceIdentifier ]); - final List? pigeonVar_replyList = - await pigeonVar_sendFuture as List?; - if (pigeonVar_replyList == null) { - throw _createConnectionError(pigeonVar_channelName); - } else if (pigeonVar_replyList.length > 1) { - throw PlatformException( - code: pigeonVar_replyList[0]! as String, - message: pigeonVar_replyList[1] as String?, - details: pigeonVar_replyList[2], - ); - } else if (pigeonVar_replyList[0] == null) { - throw PlatformException( - code: 'null-error', - message: 'Host platform returned null value for non-null return value.', - ); - } else { - return (pigeonVar_replyList[0] as bool?)!; - } + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as bool; } } diff --git a/packages/javascript_android/pigeons/schema.dart b/packages/javascript_android/pigeons/schema.dart index 5a1099a..36aa502 100644 --- a/packages/javascript_android/pigeons/schema.dart +++ b/packages/javascript_android/pigeons/schema.dart @@ -24,23 +24,23 @@ abstract class JavaScriptAndroidConsoleMessageHandler { @HostApi() abstract class JavaScriptAndroidPlatformApi { - @async + @asyncCallback void startJavaScriptEnvironment(String javascriptInstanceId); - @async + @asyncCallback void dispose(String javascriptInstanceId); - @async + @asyncCallback String? runJavaScriptReturningResult( String javascriptInstanceId, String javaScript, ); - @async + @asyncCallback String? runJavaScriptFromFileReturningResult( String javascriptInstanceId, String javaScriptFilePath, ); - @async + @asyncCallback void setIsInspectable(String javascriptInstanceId, bool isInspectable); - @async + @asyncCallback bool setJavaScriptConsoleMessageHandler( String javascriptInstanceId, int mJavaScriptAndroidConsoleMessageHandlerInstanceIdentifier, diff --git a/packages/javascript_android/pubspec.yaml b/packages/javascript_android/pubspec.yaml index e19a38a..7bb62c2 100644 --- a/packages/javascript_android/pubspec.yaml +++ b/packages/javascript_android/pubspec.yaml @@ -29,7 +29,7 @@ dev_dependencies: sdk: flutter plugin_platform_interface: ^2.0.0 flutter_lints: ^6.0.0 - pigeon: ^26.0.0 + pigeon: ^29.0.0 topics: - javascript diff --git a/packages/javascript_darwin/analysis_options.yaml b/packages/javascript_darwin/analysis_options.yaml index f9b3034..9f9312f 100644 --- a/packages/javascript_darwin/analysis_options.yaml +++ b/packages/javascript_darwin/analysis_options.yaml @@ -1 +1,8 @@ include: package:flutter_lints/flutter.yaml + +analyzer: + exclude: + # Vendored copy of package:flutter_js managed by `vendor.yaml`; its lints + # are not ours to fix. + - lib/src/third_party/** + - build/** diff --git a/packages/javascript_darwin/lib/src/third_party/flutter_js/lib/javascriptcore/jscore/js_object.dart b/packages/javascript_darwin/lib/src/third_party/flutter_js/lib/javascriptcore/jscore/js_object.dart index 2c293fb..141041f 100644 --- a/packages/javascript_darwin/lib/src/third_party/flutter_js/lib/javascriptcore/jscore/js_object.dart +++ b/packages/javascript_darwin/lib/src/third_party/flutter_js/lib/javascriptcore/jscore/js_object.dart @@ -544,11 +544,8 @@ class JSObject { this.context, JSValuePointer arguments, { JSValuePointer? exception, - }) : this.pointer = JSObjectRef.jSObjectMakeArray( - context.pointer, - arguments.count, - arguments.pointer, - exception?.pointer ?? nullptr); + }) : this.pointer = JSObjectRef.jSObjectMakeArray(context.pointer, + arguments.count, arguments.pointer, exception?.pointer ?? nullptr); /// Creates a JavaScript Date object, as if by invoking the built-in Date constructor. /// [arguments] A JSValue array of arguments to pass to the Date Constructor. Pass NULL if argumentCount is 0. @@ -557,11 +554,8 @@ class JSObject { this.context, JSValuePointer arguments, { JSValuePointer? exception, - }) : this.pointer = JSObjectRef.jSObjectMakeDate( - context.pointer, - arguments.count, - arguments.pointer, - exception?.pointer ?? nullptr); + }) : this.pointer = JSObjectRef.jSObjectMakeDate(context.pointer, + arguments.count, arguments.pointer, exception?.pointer ?? nullptr); /// Creates a JavaScript Error object, as if by invoking the built-in Error constructor. /// [arguments] (JSValueRef[]) A JSValue array of arguments to pass to the Error Constructor. Pass NULL if argumentCount is 0. @@ -570,11 +564,8 @@ class JSObject { this.context, JSValuePointer arguments, { JSValuePointer? exception, - }) : this.pointer = JSObjectRef.jSObjectMakeError( - context.pointer, - arguments.count, - arguments.pointer, - exception?.pointer ?? nullptr); + }) : this.pointer = JSObjectRef.jSObjectMakeError(context.pointer, + arguments.count, arguments.pointer, exception?.pointer ?? nullptr); /// Creates a JavaScript RegExp object, as if by invoking the built-in RegExp constructor. /// [arguments] (JSValueRef[]) A JSValue array of arguments to pass to the RegExp Constructor. Pass NULL if argumentCount is 0. @@ -583,11 +574,8 @@ class JSObject { this.context, JSValuePointer arguments, { JSValuePointer? exception, - }) : this.pointer = JSObjectRef.jSObjectMakeRegExp( - context.pointer, - arguments.count, - arguments.pointer, - exception?.pointer ?? nullptr); + }) : this.pointer = JSObjectRef.jSObjectMakeRegExp(context.pointer, + arguments.count, arguments.pointer, exception?.pointer ?? nullptr); /// Creates a JavaScript promise object by invoking the provided executor. /// [resolve] (JSObjectRef*) A pointer to a JSObjectRef in which to store the resolve function for the new promise. Pass NULL if you do not care to store the resolve callback. @@ -598,11 +586,8 @@ class JSObject { JSObjectPointer resolve, JSObjectPointer reject, { JSValuePointer? exception, - }) : this.pointer = JSObjectRef.jSObjectMakeDeferredPromise( - context.pointer, - resolve.pointer, - reject.pointer, - exception?.pointer ?? nullptr); + }) : this.pointer = JSObjectRef.jSObjectMakeDeferredPromise(context.pointer, + resolve.pointer, reject.pointer, exception?.pointer ?? nullptr); /// Creates a function with a given script as its body. /// Use this method when you want to execute a script repeatedly, to avoid the cost of re-parsing the script before each execution. @@ -757,8 +742,10 @@ class JSObject { }) { final name = JSString.fromString(propertyName); try { - return JSValue(context, JSObjectRef.jSObjectGetProperty( - context.pointer, pointer, name.pointer, exception?.pointer ?? nullptr)); + return JSValue( + context, + JSObjectRef.jSObjectGetProperty(context.pointer, pointer, + name.pointer, exception?.pointer ?? nullptr)); } finally { name.release(); } @@ -878,12 +865,8 @@ class JSObject { JSValue value, { JSValuePointer? exception, }) { - JSObjectRef.jSObjectSetPropertyAtIndex( - context.pointer, - pointer, - propertyIndex, - value.pointer, - exception?.pointer ?? nullptr); + JSObjectRef.jSObjectSetPropertyAtIndex(context.pointer, pointer, + propertyIndex, value.pointer, exception?.pointer ?? nullptr); } /// Gets an object's private data. @@ -939,12 +922,8 @@ class JSObject { }) { return JSObject( context, - JSObjectRef.jSObjectCallAsConstructor( - context.pointer, - pointer, - arguments.count, - arguments.pointer, - exception?.pointer ?? nullptr)); + JSObjectRef.jSObjectCallAsConstructor(context.pointer, pointer, + arguments.count, arguments.pointer, exception?.pointer ?? nullptr)); } /// Gets the names of an object's enumerable properties. @@ -969,10 +948,10 @@ class JSObject { JSValuePointer? exception, }) { return Bytes( - JSTypedArray.jSObjectGetTypedArrayBytesPtr(context.pointer, pointer, - exception?.pointer ?? nullptr), - JSTypedArray.jSObjectGetTypedArrayLength(context.pointer, pointer, - exception?.pointer ?? nullptr)); + JSTypedArray.jSObjectGetTypedArrayBytesPtr( + context.pointer, pointer, exception?.pointer ?? nullptr), + JSTypedArray.jSObjectGetTypedArrayLength( + context.pointer, pointer, exception?.pointer ?? nullptr)); } /// Returns the byte length of a JavaScript Typed Array object. @@ -980,8 +959,8 @@ class JSObject { int typedArrayByteLength({ JSValuePointer? exception, }) { - return JSTypedArray.jSObjectGetTypedArrayByteLength(context.pointer, - pointer, exception?.pointer ?? nullptr); + return JSTypedArray.jSObjectGetTypedArrayByteLength( + context.pointer, pointer, exception?.pointer ?? nullptr); } /// Returns the byte offset of a JavaScript Typed Array object. @@ -989,8 +968,8 @@ class JSObject { int typedArrayByteOffset({ JSValuePointer? exception, }) { - return JSTypedArray.jSObjectGetTypedArrayByteOffset(context.pointer, - pointer, exception?.pointer ?? nullptr); + return JSTypedArray.jSObjectGetTypedArrayByteOffset( + context.pointer, pointer, exception?.pointer ?? nullptr); } /// Returns the JavaScript Array Buffer object that is used as the backing of a JavaScript Typed Array object. @@ -1000,8 +979,8 @@ class JSObject { }) { return JSObject( context, - JSTypedArray.jSObjectGetTypedArrayBuffer(context.pointer, pointer, - exception?.pointer ?? nullptr)); + JSTypedArray.jSObjectGetTypedArrayBuffer( + context.pointer, pointer, exception?.pointer ?? nullptr)); } /// Returns a pointer to the data buffer that serves as the backing store for a JavaScript Typed Array object. @@ -1011,10 +990,10 @@ class JSObject { JSValuePointer? exception, }) { return Bytes( - JSTypedArray.jSObjectGetArrayBufferBytesPtr(context.pointer, pointer, - exception?.pointer ?? nullptr), - JSTypedArray.jSObjectGetArrayBufferByteLength(context.pointer, pointer, - exception?.pointer ?? nullptr)); + JSTypedArray.jSObjectGetArrayBufferBytesPtr( + context.pointer, pointer, exception?.pointer ?? nullptr), + JSTypedArray.jSObjectGetArrayBufferByteLength( + context.pointer, pointer, exception?.pointer ?? nullptr)); } /// JSObject to JSValue diff --git a/packages/javascript_darwin/lib/src/third_party/flutter_js/lib/javascriptcore/jscore/js_value.dart b/packages/javascript_darwin/lib/src/third_party/flutter_js/lib/javascriptcore/jscore/js_value.dart index 30e05a1..690029c 100644 --- a/packages/javascript_darwin/lib/src/third_party/flutter_js/lib/javascriptcore/jscore/js_value.dart +++ b/packages/javascript_darwin/lib/src/third_party/flutter_js/lib/javascriptcore/jscore/js_value.dart @@ -273,8 +273,8 @@ class JSValue { JSTypedArrayType getTypedArrayType({ JSValuePointer? exception, }) { - int typeCode = JSValueRef.jSValueGetTypedArrayType(context.pointer, pointer, - exception?.pointer ?? nullptr); + int typeCode = JSValueRef.jSValueGetTypedArrayType( + context.pointer, pointer, exception?.pointer ?? nullptr); return cEnumToJSTypedArrayType(typeCode); } @@ -293,11 +293,8 @@ class JSValue { JSObject constructor, { JSValuePointer? exception, }) { - return JSValueRef.jSValueIsInstanceOfConstructor( - context.pointer, - pointer, - constructor.pointer, - exception?.pointer ?? nullptr) == + return JSValueRef.jSValueIsInstanceOfConstructor(context.pointer, pointer, + constructor.pointer, exception?.pointer ?? nullptr) == 1; } @@ -308,8 +305,8 @@ class JSValue { int indent = 4, JSValuePointer? exception, }) { - return JSString(JSValueRef.jSValueCreateJSONString(context.pointer, pointer, - indent, exception?.pointer ?? nullptr)); + return JSString(JSValueRef.jSValueCreateJSONString( + context.pointer, pointer, indent, exception?.pointer ?? nullptr)); } /// Converts a JavaScript value to boolean and returns the resulting boolean. @@ -322,8 +319,8 @@ class JSValue { double toNumber({ JSValuePointer? exception, }) { - return JSValueRef.jSValueToNumber(context.pointer, pointer, - exception?.pointer ?? nullptr); + return JSValueRef.jSValueToNumber( + context.pointer, pointer, exception?.pointer ?? nullptr); } /// Converts a JavaScript value to number and returns the resulting string. @@ -339,8 +336,8 @@ class JSValue { JSString toStringCopy({ JSValuePointer? exception, }) { - return JSString(JSValueRef.jSValueToStringCopy(context.pointer, pointer, - exception?.pointer ?? nullptr)); + return JSString(JSValueRef.jSValueToStringCopy( + context.pointer, pointer, exception?.pointer ?? nullptr)); } /// Converts a JavaScript value to object and returns the resulting object. @@ -350,8 +347,8 @@ class JSValue { }) { return JSObject( context, - JSValueRef.jSValueToObject(context.pointer, pointer, - exception?.pointer ?? nullptr)); + JSValueRef.jSValueToObject( + context.pointer, pointer, exception?.pointer ?? nullptr)); } /// Protects a JavaScript value from garbage collection. From be589b273a2c45fcda569da7a77ef78008bb3cf8 Mon Sep 17 00:00:00 2001 From: predatorx7 Date: Mon, 26 Oct 2026 15:10:35 +0530 Subject: [PATCH 2/4] fix(darwin): string-only result contract, promise and number fixes Define the one result contract for `runJavaScriptReturningResult` and `runJavaScriptFromFileReturningResult`, document it in `javascript_platform_interface` (2.1.0), implement it on Darwin (2.1.0), and pin it with a device integration test that must pass on Android, iOS and macOS. Contract: the script's completion value, or the value a returned Promise resolves to, is returned to Dart as a String verbatim. No JSON decoding, no type detection. A non-string value resolves to the empty string on every platform. A rejected promise or a thrown error completes with a JavaScriptExecutionException carrying the JS message. Callers who want structured data end their script with JSON.stringify and jsonDecode it in Dart. The plugin deliberately does not parse the script to recover a typed last expression. Darwin fixes, each pinned by a real-JavaScriptCore test: - NaN, Infinity, 1e21 and 2 ** 63 threw FormatException out of the typed converter; they now resolve to ''. - Promise results were double-encoded (Promise.resolve("hi") gave "\"hi\""); the resolved JSValue is now inspected directly. - undefined, functions, symbols, bigint and circular objects silently became null; they now resolve to ''. - Promise rejection reasons were lost as "{}"; the Error message and stack, or String(reason), are now carried by the exception. - A string result starting with "ERROR:" was reported as a failure and non-object throws were not; the vendored runtime now tracks whether an exception was actually raised. - Channel messages reach onMessageReceived as the exact string passed to sendMessage, and sendMessage to an unregistered channel throws, which is what Android already did. Co-Authored-By: Claude Fable 5.1 --- .../integration_test/javascript_test.dart | 465 ++++++++++++++++++ packages/javascript/example/pubspec.yaml | 2 + packages/javascript_darwin/CHANGELOG.md | 12 +- .../lib/javascript_darwin.dart | 182 +++++-- .../lib/javascriptcore/jscore_runtime.dart | 13 +- packages/javascript_darwin/pubspec.yaml | 2 +- .../test/string_result_contract_test.dart | 428 ++++++++++++++++ .../CHANGELOG.md | 10 + .../lib/javascript_platform_interface.dart | 51 +- .../lib/src/api/message.dart | 10 + .../pubspec.yaml | 2 +- 11 files changed, 1127 insertions(+), 50 deletions(-) create mode 100644 packages/javascript/example/integration_test/javascript_test.dart create mode 100644 packages/javascript_darwin/test/string_result_contract_test.dart diff --git a/packages/javascript/example/integration_test/javascript_test.dart b/packages/javascript/example/integration_test/javascript_test.dart new file mode 100644 index 0000000..5c8b860 --- /dev/null +++ b/packages/javascript/example/integration_test/javascript_test.dart @@ -0,0 +1,465 @@ +// Device tests pinning the public behavior of `javascript_flutter`. +// +// Run with `flutter test integration_test` from `packages/javascript/example` +// against an Android emulator, an iOS simulator or the macOS desktop target. +// Every expectation here must hold on every platform; that is the contract. +// +// Contract: the value a script evaluates to, or the value its Promise resolves +// to, is returned to Dart as a `String` exactly as-is. Nothing is decoded or +// coerced. A script that evaluates to anything other than a string resolves +// to an empty string. Scripts that throw, and promises that reject, throw a +// `JavaScriptExecutionException` carrying the JavaScript error message. +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:javascript_flutter/javascript_flutter.dart'; +import 'package:path_provider/path_provider.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + late JavaScript js; + + setUp(() async { + js = await JavaScript.createNew(); + }); + + tearDown(() async { + await js.dispose(); + }); + + Future run(String source) => js.runJavaScriptReturningResult(source); + + group('string results are returned verbatim', () { + final cases = { + '"hello"': 'hello', + '""': '', + '"42"': '42', + '"true"': 'true', + '"null"': 'null', + '"undefined"': 'undefined', + "'\"quoted\"'": '"quoted"', + '" padded "': ' padded ', + '"line1\\nline2"': 'line1\nline2', + '"héllo wörld 🌍"': 'héllo wörld 🌍', + '"a".repeat(100000)': 'a' * 100000, + 'JSON.stringify({a: 1, b: [1, 2, 3], c: null})': + '{"a":1,"b":[1,2,3],"c":null}', + 'String(40 + 2)': '42', + '(40 + 2).toString()': '42', + '`template \${1 + 1}`': 'template 2', + 'var s = "stateful"; s': 'stateful', + 'const f = () => "from function"; f()': 'from function', + '(() => "iife")()': 'iife', + '"last"; "expression"': 'expression', + }; + + cases.forEach((source, expected) { + test(source, () async { + final result = await run(source); + expect(result, isA()); + expect(result, expected); + }); + }); + + test('multi-line script whose completion value is a string', () async { + final result = await run(''' + function add(a, b) { + return a + b; + } + const total = add(40, 2); + JSON.stringify({ total }); + '''); + expect(result, '{"total":42}'); + }); + + test('a JSON string can be decoded by the caller', () async { + final result = await run( + 'JSON.stringify({a: 1, b: "two", c: [true, null]})', + ); + final decoded = jsonDecode(result! as String); + expect(decoded, { + 'a': 1, + 'b': 'two', + 'c': [true, null], + }); + }); + }); + + group('non-string results resolve to an empty string', () { + final cases = [ + '40 + 2', + '0', + '-0', + '1.5', + '1e21', + '2 ** 63', + 'NaN', + 'Infinity', + '-Infinity', + 'true', + 'false', + 'null', + 'undefined', + 'void 0', + '10n', + 'Symbol("s")', + '() => 1', + 'function f() {}; f', + '({a: 1})', + '[1, 2, 3]', + 'new Date(0)', + 'new Map([[1, 2]])', + 'var circular = {}; circular.self = circular; circular', + // A thenable is not a Promise, so it is not awaited (the Android engine only awaits real Promises). + '({then(resolve) { resolve("x"); }})', + 'var x = 1;', + 'if (true) { 1 }', + '', + '// only a comment', + ]; + + for (final source in cases) { + test(source.isEmpty ? '' : source, () async { + final result = await run(source); + expect(result, isA()); + expect(result, ''); + }); + } + }); + + group('promises', () { + test('a promise resolving to a string yields that string', () async { + expect(await run('Promise.resolve("hi")'), 'hi'); + }); + + test( + 'a promise resolving to an empty string yields an empty string', + () async { + expect(await run('Promise.resolve("")'), ''); + }, + ); + + test('a promise resolving to JSON text yields it verbatim', () async { + expect(await run('Promise.resolve(JSON.stringify({a: 1}))'), '{"a":1}'); + }); + + test('a promise that resolves later is awaited', () async { + expect( + await run( + 'new Promise(resolve => setTimeout(() => resolve("late"), 50))', + ), + 'late', + ); + }); + + test('an async function result is awaited', () async { + expect( + await run('(async () => { await null; return "async"; })()'), + 'async', + ); + }); + + test('an async function chain is awaited', () async { + final result = await run(''' + const sum = async (a, b) => a + b; + sum(1, 2).then(v => String(v)); + '''); + expect(result, '3'); + }); + + test( + 'a promise that adopts a thenable resolves to the thenable value', + () async { + expect( + await run('Promise.resolve({then(resolve) { resolve("adopted"); }})'), + 'adopted', + ); + }, + ); + + test( + 'a promise resolving to a non-string yields an empty string', + () async { + expect(await run('Promise.resolve(42)'), ''); + expect(await run('Promise.resolve({a: 1})'), ''); + expect(await run('Promise.resolve(null)'), ''); + expect(await run('Promise.resolve(undefined)'), ''); + expect(await run('Promise.resolve(false)'), ''); + }, + ); + + test('a rejected promise throws with the rejection reason', () async { + await expectLater( + run('Promise.reject(new Error("boom"))'), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('boom'), + ), + ), + ); + }); + + test('an async function that throws rejects with the reason', () async { + await expectLater( + run('(async () => { throw new Error("async boom"); })()'), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('async boom'), + ), + ), + ); + }); + + test('a promise rejected with a non-Error value throws', () async { + await expectLater( + run('Promise.reject("plain reason")'), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('plain reason'), + ), + ), + ); + }); + }); + + group('errors', () { + test('a thrown Error surfaces as JavaScriptExecutionException', () async { + await expectLater( + run('throw new Error("bad thing")'), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('bad thing'), + ), + ), + ); + }); + + test('a ReferenceError surfaces as JavaScriptExecutionException', () async { + await expectLater( + run('definitelyNotDefined + 1'), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('definitelyNotDefined'), + ), + ), + ); + }); + + test('a syntax error surfaces as JavaScriptExecutionException', () async { + await expectLater( + run('this is not javascript'), + throwsA(isA()), + ); + }); + + test('the environment keeps working after an error', () async { + await expectLater(run('throw new Error("x")'), throwsA(anything)); + expect(await run('"still alive"'), 'still alive'); + }); + }); + + group('environment', () { + test('state persists between evaluations', () async { + await run('var counter = 1;'); + await run('counter += 1;'); + expect(await run('String(counter)'), '2'); + }); + + test('top-level let and const persist between evaluations', () async { + await run('let letValue = "let"; const constValue = "const";'); + expect(await run('letValue + " " + constValue'), 'let const'); + }); + + test('instances are isolated from each other', () async { + final other = await JavaScript.createNew(); + addTearDown(other.dispose); + await run('var onlyHere = "mine";'); + expect(await other.run('typeof onlyHere'), 'undefined'); + }); + + test('runJavaScriptFromFileReturningResult evaluates a file', () async { + final dir = await getTemporaryDirectory(); + final file = File( + '${dir.path}/javascript_flutter_test_${DateTime.now().microsecondsSinceEpoch}.js', + ); + await file.create(recursive: true); + await file.writeAsString( + 'const fromFile = { ok: true };\nJSON.stringify(fromFile);\n', + ); + addTearDown(() => file.delete()); + + final result = await js.runJavaScriptFromFileReturningResult(file.path); + expect(result, '{"ok":true}'); + }); + + test('setTimeout works', () async { + final result = await run(''' + new Promise(resolve => { + const order = []; + setTimeout(() => { order.push("b"); resolve(order.join(",")); }, 150); + setTimeout(() => order.push("a"), 10); + }) + '''); + expect(result, 'a,b'); + }); + + test('running after dispose throws', () async { + final disposable = await JavaScript.createNew(); + await disposable.dispose(); + await expectLater( + disposable.runJavaScriptReturningResult('"x"'), + throwsA(isA()), + ); + }); + }); + + group('channels', () { + test('a JSON message round-trips with a JSON reply', () async { + Object? received; + await js.addJavaScriptChannel( + JavaScriptChannelParams( + name: 'Sum', + onMessageReceived: (message) { + received = jsonDecode(message.message!); + final data = received! as Map; + return JavaScriptReply( + message: jsonEncode({'sum': data['a'] + data['b']}), + ); + }, + ), + ); + + final result = await run(''' + sendMessage('Sum', JSON.stringify({a: 40, b: 2})) + .then(reply => JSON.parse(reply)) + .then(data => String(data.sum)); + '''); + + expect(received, {'a': 40, 'b': 2}); + expect(result, '42'); + }); + + test('a plain string message arrives verbatim', () async { + String? received; + await js.addJavaScriptChannel( + JavaScriptChannelParams( + name: 'Echo', + onMessageReceived: (message) { + received = message.message; + return JavaScriptReply(message: 'echo: ${message.message}'); + }, + ), + ); + + final result = await run("sendMessage('Echo', 'hello world')"); + + expect(received, 'hello world'); + expect(result, 'echo: hello world'); + }); + + test('a non-string message throws in JavaScript', () async { + await js.addJavaScriptChannel( + JavaScriptChannelParams( + name: 'Strict', + onMessageReceived: (_) => JavaScriptReply(message: 'unreachable'), + ), + ); + await expectLater( + run("sendMessage('Strict', {a: 1})"), + throwsA(isA()), + ); + await expectLater( + run("sendMessage('Strict', 42)"), + throwsA(isA()), + ); + }); + + test('a null reply resolves the promise with null', () async { + await js.addJavaScriptChannel( + JavaScriptChannelParams( + name: 'Nothing', + onMessageReceived: (_) => const JavaScriptReply(message: null), + ), + ); + expect( + await run( + "sendMessage('Nothing', 'x').then(v => v === null ? 'was null' : 'was ' + typeof v)", + ), + 'was null', + ); + }); + + test('the reply string is delivered verbatim', () async { + await js.addJavaScriptChannel( + JavaScriptChannelParams( + name: 'Reply', + onMessageReceived: (_) => JavaScriptReply(message: ' "spaced" \n'), + ), + ); + expect(await run("sendMessage('Reply', 'x')"), ' "spaced" \n'); + }); + + test('an async channel handler is awaited', () async { + await js.addJavaScriptChannel( + JavaScriptChannelParams( + name: 'Slow', + onMessageReceived: (message) async { + await Future.delayed(const Duration(milliseconds: 50)); + return JavaScriptReply(message: 'slow ${message.message}'); + }, + ), + ); + expect(await run("sendMessage('Slow', 'reply')"), 'slow reply'); + }); + + test('many concurrent messages are all answered', () async { + await js.addJavaScriptChannel( + JavaScriptChannelParams( + name: 'Double', + onMessageReceived: (message) async { + return JavaScriptReply( + message: '${int.parse(message.message!) * 2}', + ); + }, + ), + ); + final result = await run(''' + Promise.all(Array.from({length: 50}, (_, i) => sendMessage('Double', String(i)))) + .then(replies => replies.join(',')); + '''); + expect(result, List.generate(50, (i) => i * 2).join(',')); + }); + + test('a channel can be removed', () async { + await js.addJavaScriptChannel( + JavaScriptChannelParams( + name: 'Temp', + onMessageReceived: (_) => JavaScriptReply(message: 'temp'), + ), + ); + expect(await run("sendMessage('Temp', 'x')"), 'temp'); + await js.removeJavaScriptChannel('Temp'); + await expectLater( + run("sendMessage('Temp', 'x')"), + throwsA(isA()), + ); + }); + }); +} + +extension on JavaScript { + Future run(String source) => runJavaScriptReturningResult(source); +} diff --git a/packages/javascript/example/pubspec.yaml b/packages/javascript/example/pubspec.yaml index 85d25d8..50259a2 100644 --- a/packages/javascript/example/pubspec.yaml +++ b/packages/javascript/example/pubspec.yaml @@ -13,6 +13,7 @@ dependencies: sdk: flutter google_fonts: ^6.3.0 http: ^1.5.0 + path_provider: ^2.1.5 logging: ^1.3.0 javascript_flutter: # When depending on this package from a real application you should use: @@ -30,6 +31,7 @@ dev_dependencies: integration_test: sdk: flutter flutter_lints: ^6.0.0 + plugin_platform_interface: ^2.1.0 flutter: uses-material-design: true diff --git a/packages/javascript_darwin/CHANGELOG.md b/packages/javascript_darwin/CHANGELOG.md index d43c323..d629c6d 100644 --- a/packages/javascript_darwin/CHANGELOG.md +++ b/packages/javascript_darwin/CHANGELOG.md @@ -1,5 +1,13 @@ -# Unreleased - +## 2.1.0 + +- `runJavaScriptReturningResult` and `runJavaScriptFromFileReturningResult` now follow the string-only result contract shared with Android: a string result (or a string a Promise resolves to) is returned verbatim, and any non-string value (number, boolean, null, undefined, object, ...) resolves to an empty string. +- Fix `FormatException` thrown for `NaN`, `Infinity`, `-Infinity`, `1e21` and `2 ** 63` results. +- Fix Promise results being double-encoded (`Promise.resolve("hi")` returned `"\"hi\""`). Only native Promises are awaited (like Android); other thenables resolve to an empty string. +- Fix `undefined`, functions, symbols, bigint and circular objects silently resolving to `null`; they now resolve to an empty string. +- Promise rejection reasons are now surfaced in `JavaScriptDarwinExecutionException` (previously lost as `{}`). +- Scripts whose result string starts with `ERROR:` are no longer mistaken for errors; non-object values can be thrown. +- Channel messages are delivered to `onMessageReceived` exactly as the string passed to `sendMessage`, and `sendMessage` to an unregistered channel throws, like on Android. Non-string `sendMessage` messages throw. +- Disposing an environment while a Promise is pending now throws `JavaScriptEnvironmentClosedException`. - Remove per-runtime callback registrations and cancel timers on disposal. - Guard asynchronous replies and Promise polling after context disposal. - Release temporary JavaScriptCore strings and pointer slots in runtime paths. diff --git a/packages/javascript_darwin/lib/javascript_darwin.dart b/packages/javascript_darwin/lib/javascript_darwin.dart index f55db59..873f9b2 100644 --- a/packages/javascript_darwin/lib/javascript_darwin.dart +++ b/packages/javascript_darwin/lib/javascript_darwin.dart @@ -1,7 +1,9 @@ import 'dart:convert'; +import 'dart:ffi'; import 'dart:io'; import 'package:javascript_darwin/src/third_party/flutter_js/lib/extensions/handle_promises.dart'; +import 'package:javascript_darwin/src/third_party/flutter_js/lib/javascriptcore/jscore_bindings.dart'; import 'package:javascript_darwin/src/third_party/flutter_js/lib/js_eval_result.dart'; import 'package:javascript_platform_interface/javascript_platform_interface.dart'; @@ -21,12 +23,12 @@ class JavaScriptDarwinMessage extends JavaScriptMessage { : super(message: fromRawMessage(rawMessage)); static String? fromRawMessage(Object? rawMessage) { + // The JS side wrapper delivers every message as the exact string that was + // passed to `sendMessage`. + if (rawMessage is String) return rawMessage; try { return json.encode(rawMessage); } catch (_) { - if (rawMessage is String) { - return rawMessage; - } _logger.warning('Failed to encode raw message: $rawMessage'); return null; } @@ -46,11 +48,78 @@ extension on JavascriptCoreRuntime { } } +/// JavaScript helpers installed in every environment. +/// +/// - `channels`/`sendMessage`: channel messages are delivered to Dart as the +/// exact string passed by the script (the vendored bridge JSON-decodes its +/// payload, so the string is JSON-encoded here first), and sending to a +/// channel that is not registered throws, like it does on Android. +/// - `settle`/`status`/`take`: awaiting native Promises while keeping the +/// raw settled value alive on the JS side until Dart takes it. +const _environmentShim = r""" +var __jsf = (function () { + var channels = Object.create(null); + var nativeSendMessage = globalThis.sendMessage; + globalThis.sendMessage = function (name, message) { + name = String(name); + if (name === 'ConsoleLog' || name === 'SetTimeout') { + return nativeSendMessage.apply(this, arguments); + } + if (!channels[name]) { + throw new Error('No JavaScript channel named "' + name + '" is registered'); + } + if (typeof message !== 'string') { + throw new Error('Message must be a string'); + } + return nativeSendMessage(name, JSON.stringify(message)); + }; + var counter = 0; + var pending = {}; + function describe(reason) { + try { + if (reason !== null && typeof reason === 'object' && + typeof reason.message === 'string') { + var stack = typeof reason.stack === 'string' ? reason.stack : ''; + return reason.message + (stack ? '\n' + stack : ''); + } + return String(reason); + } catch (e) { + return 'Unknown JavaScript rejection'; + } + } + return { + channels: channels, + settle: function (value) { + // Only native Promises are awaited (like Android). Other objects, + // thenables included, are not strings and so resolve to ''. + if (!(value instanceof Promise)) return -1; + var id = counter++; + var record = { state: 0, value: undefined }; + pending[id] = record; + Promise.resolve(value).then( + function (v) { record.state = 1; record.value = v; }, + function (e) { record.state = 2; record.value = e; } + ); + return id; + }, + status: function (id) { return pending[id].state; }, + take: function (id) { + var record = pending[id]; + delete pending[id]; + return record.state === 1 ? record.value : describe(record.value); + } + }; +})(); +"""; + class _JavaScriptDarwinState { final JavascriptCoreRuntime runtime; final Map enabledChannels; - const _JavaScriptDarwinState({ + /// `__jsf.settle`, protected from garbage collection until disposal. + late final Pointer settleFunction; + + _JavaScriptDarwinState({ required this.runtime, required this.enabledChannels, }); @@ -87,6 +156,11 @@ class JavaScriptDarwin extends JavaScriptPlatform { ); runtime.enableHandlePromises(); + runtime.evaluate(_environmentShim); + final settleFunction = + runtime.evaluate('__jsf.settle').rawResult as Pointer; + jSValueProtect(runtime.context.pointer, settleFunction); + _activeState[javaScriptInstanceId]!.settleFunction = settleFunction; } _JavaScriptDarwinState _requireJsRuntimeState(String javaScriptInstanceId) { @@ -136,6 +210,9 @@ class JavaScriptDarwin extends JavaScriptPlatform { final enabledChannels = state.enabledChannels; enabledChannels[javaScriptChannelParams.name] = javaScriptChannelParams; + runtime.evaluate( + '__jsf.channels[${json.encode(javaScriptChannelParams.name)}] = true;', + ); return runtime.onMessage( javaScriptChannelParams.name, @@ -151,10 +228,17 @@ class JavaScriptDarwin extends JavaScriptPlatform { final state = _requireJsRuntimeState(javaScriptInstanceId); final runtime = state.runtime; runtime.removeBridge(javaScriptChannelName); + runtime.evaluate( + 'delete __jsf.channels[${json.encode(javaScriptChannelName)}];', + ); final enabledChannels = state.enabledChannels; enabledChannels.remove(javaScriptChannelName); } + /// Evaluates [javaScript] and resolves to its completion value (or what a + /// returned Promise/thenable resolves to) if, and only if, that value is a + /// JavaScript string. The string is returned verbatim. Any other value + /// resolves to an empty string. This matches the Android implementation. @override Future runJavaScriptReturningResult( String javaScriptInstanceId, @@ -163,50 +247,65 @@ class JavaScriptDarwin extends JavaScriptPlatform { final state = _requireJsRuntimeState(javaScriptInstanceId); final runtime = state.runtime; - try { - final result = await runtime.evaluateAsync(javaScript); - if (result.isError) { - throw JavaScriptDarwinExecutionException.fromResult( - runtime, result, StackTrace.current); - } + final result = runtime.evaluate(javaScript); + if (result.isError) { + throw JavaScriptDarwinExecutionException.fromResult( + runtime, + result, + StackTrace.current, + ); + } - if (!result.isPromise) { - try { - return runtime.convertValue(result); - } on TypeError { - return result.stringResult; - } - } + final ref = result.rawResult as Pointer; + if (_isString(runtime, ref)) { + return result.stringResult; + } + if (jSValueIsObject(runtime.context.pointer, ref) != 1) { + return ''; + } - final promiseResult = await runtime.handlePromise(result); - if (promiseResult.isError) { - throw JavaScriptDarwinExecutionException.fromResult( - runtime, - promiseResult, - StackTrace.current, - ); - } + // Only native Promises are awaited. Hand the value to the cached JS side + // settle helper, which keeps the settled value alive in a registry until + // taken. The result is protected while it is only referenced from Dart. + final context = runtime.context.pointer; + jSValueProtect(context, ref); + final int id; + try { + id = int.parse( + runtime.callFunction(state.settleFunction, ref).stringResult, + ); + } finally { + if (!runtime.isDisposed) jSValueUnprotect(context, ref); + } + if (id < 0) return ''; - try { - return runtime.convertValue(promiseResult); - } on TypeError { - return promiseResult.stringResult; - } - } catch (e, s) { - if (e is JavaScriptDarwinExecutionException) { - rethrow; - } - if (e is JsEvalResult) { - throw JavaScriptDarwinExecutionException.fromResult( - runtime, - e, - s, + while (true) { + if (runtime.isDisposed) { + throw const JavaScriptEnvironmentClosedException( + 'The JavaScript environment was disposed while awaiting a Promise', ); } - rethrow; + final status = runtime.evaluate('__jsf.status($id)').stringResult; + if (status != '0') { + final taken = runtime.evaluate('__jsf.take($id)'); + if (status == '2') { + final reason = taken.stringResult; + throw JavaScriptDarwinExecutionException( + reason, + JsEvalResult(reason, taken.rawResult, isError: true), + runtime, + StackTrace.current, + ); + } + return _isString(runtime, taken.rawResult) ? taken.stringResult : ''; + } + await Future.delayed(const Duration(milliseconds: 20)); } } + bool _isString(JavascriptCoreRuntime runtime, Object? value) => + jSValueIsString(runtime.context.pointer, value as Pointer) == 1; + @override Future runJavaScriptFromFileReturningResult( String javaScriptInstanceId, @@ -230,6 +329,9 @@ class JavaScriptDarwin extends JavaScriptPlatform { Future dispose(String javaScriptInstanceId) async { final state = _requireJsRuntimeState(javaScriptInstanceId); final runtime = state.runtime; + if (!runtime.isDisposed) { + jSValueUnprotect(runtime.context.pointer, state.settleFunction); + } runtime.dispose(); _activeState.remove(javaScriptInstanceId); final enabledChannels = state.enabledChannels; diff --git a/packages/javascript_darwin/lib/src/third_party/flutter_js/lib/javascriptcore/jscore_runtime.dart b/packages/javascript_darwin/lib/src/third_party/flutter_js/lib/javascriptcore/jscore_runtime.dart index b025d4e..7c1985f 100644 --- a/packages/javascript_darwin/lib/src/third_party/flutter_js/lib/javascriptcore/jscore_runtime.dart +++ b/packages/javascript_darwin/lib/src/third_party/flutter_js/lib/javascriptcore/jscore_runtime.dart @@ -80,11 +80,18 @@ class JavascriptCoreRuntime extends JavascriptRuntime { String result; JSValue exceptionValue = exception.getValue(context); + // PATCH(javascript_darwin): track whether an exception was thrown instead of + // inferring it from a result string that starts with 'ERROR:', so scripts + // may evaluate to any string, and non-object values can be thrown. + final exceptionRef = exception.pointer.value; + final hasException = exceptionRef != nullptr; malloc.free(exception.pointer); bool isPromise = false; if (exceptionValue.isObject) { result = 'ERROR: ${exceptionValue.toObject().getProperty("message").string} \n at ${exceptionValue.toObject().getProperty("stack").string}'; + } else if (hasException) { + result = 'ERROR: ${_getJsValue(exceptionRef)}'; } else { result = _getJsValue(jsValueRef); JSValue resultValue = JSValue(context, jsValueRef); @@ -96,8 +103,10 @@ class JavascriptCoreRuntime extends JavascriptRuntime { return JsEvalResult( result, - exceptionValue.isObject ? exceptionValue.toObject().pointer : jsValueRef, - isError: result.startsWith('ERROR:'), + exceptionValue.isObject + ? exceptionValue.toObject().pointer + : (hasException ? exceptionRef : jsValueRef), + isError: hasException, isPromise: isPromise, ); } diff --git a/packages/javascript_darwin/pubspec.yaml b/packages/javascript_darwin/pubspec.yaml index c75113c..0f1608a 100644 --- a/packages/javascript_darwin/pubspec.yaml +++ b/packages/javascript_darwin/pubspec.yaml @@ -2,7 +2,7 @@ name: javascript_darwin description: An iOS & MacOS implementation of the javascript_flutter plugin repository: https://github.com/predatorx7/javascript_flutter/tree/main/packages/javascript_darwin issue_tracker: https://github.com/predatorx7/javascript_flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+javascript_darwin%22 -version: 2.0.0 +version: 2.1.0 environment: sdk: ^3.6.0 diff --git a/packages/javascript_darwin/test/string_result_contract_test.dart b/packages/javascript_darwin/test/string_result_contract_test.dart new file mode 100644 index 0000000..951291f --- /dev/null +++ b/packages/javascript_darwin/test/string_result_contract_test.dart @@ -0,0 +1,428 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:javascript_darwin/exception.dart'; +import 'package:javascript_darwin/javascript_darwin.dart'; +import 'package:javascript_platform_interface/javascript_platform_interface.dart'; + +// Runs real JavaScriptCore on the macOS host. Mirrors the shared device test in +// packages/javascript/example/integration_test/javascript_test.dart. +void main() { + final skip = !Platform.isMacOS; + late JavaScriptDarwin js; + var counter = 0; + late String id; + + setUp(() async { + js = JavaScriptDarwin(); + id = 'env-${counter++}'; + await js.startNewJavaScriptEnvironment(id); + }); + + tearDown(() async { + try { + await js.dispose(id); + } on JavaScriptInstanceNotFoundException { + // Already disposed by the test. + } + }); + + Future run(String source) => + js.runJavaScriptReturningResult(id, source); + + Matcher throwsJsError(String text) => throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains(text), + ), + ); + + group('string results are returned verbatim', () { + final cases = { + '"hello"': 'hello', + '""': '', + '"42"': '42', + '"true"': 'true', + '"null"': 'null', + '"undefined"': 'undefined', + "'\"quoted\"'": '"quoted"', + '" padded "': ' padded ', + '"line1\\nline2"': 'line1\nline2', + '"héllo wörld 🌍"': 'héllo wörld 🌍', + '"a".repeat(100000)': 'a' * 100000, + 'JSON.stringify({a: 1, b: [1, 2, 3], c: null})': + '{"a":1,"b":[1,2,3],"c":null}', + 'String(40 + 2)': '42', + '(40 + 2).toString()': '42', + '`template \${1 + 1}`': 'template 2', + 'var s = "stateful"; s': 'stateful', + 'const f = () => "from function"; f()': 'from function', + '(() => "iife")()': 'iife', + '"last"; "expression"': 'expression', + '"ERROR: not an error"': 'ERROR: not an error', + '"\\u0000nul"': '\u0000nul', + }; + cases.forEach((source, expected) { + test(source.length > 60 ? source.substring(0, 60) : source, () async { + final result = await run(source); + expect(result, isA()); + expect(result, expected); + }); + }); + }, skip: skip); + + group('non-string results resolve to an empty string', () { + final cases = [ + '40 + 2', + '0', + '-0', + '1.5', + '1e21', + '2 ** 63', + 'NaN', + 'Infinity', + '-Infinity', + 'true', + 'false', + 'null', + 'undefined', + 'void 0', + '10n', + 'Symbol("s")', + '() => 1', + 'function f() {}; f', + '({a: 1})', + '[1, 2, 3]', + 'new Date(0)', + 'new Map([[1, 2]])', + 'var circular = {}; circular.self = circular; circular', + 'var x = 1;', + 'if (true) { 1 }', + '', + '// only a comment', + '({ toString() { throw new Error("no"); } })', + ]; + for (final source in cases) { + test(source.isEmpty ? '' : source, () async { + final result = await run(source); + expect(result, isA()); + expect(result, ''); + }); + } + + // Previously: FormatException out of convertValue. + test('NaN, Infinity, 1e21 and 2 ** 63 no longer throw FormatException', + () async { + for (final s in ['NaN', 'Infinity', '-Infinity', '1e21', '2 ** 63']) { + expect(await run(s), '', reason: s); + } + }); + + // Previously: silently Dart null. + test('undefined, functions, symbols, bigint and cycles are not null', + () async { + for (final s in [ + 'undefined', + '(function() {})', + 'Symbol("x")', + '1n', + 'var c = {}; c.self = c; c', + ]) { + expect(await run(s), isNotNull, reason: s); + expect(await run(s), '', reason: s); + } + }); + }, skip: skip); + + group('promises', () { + test('resolving to a string yields that string, not JSON', () async { + // Previously double encoded as '"hi"'. + expect(await run('Promise.resolve("hi")'), 'hi'); + expect(await run('Promise.resolve("")'), ''); + expect(await run('Promise.resolve(JSON.stringify({a: 1}))'), '{"a":1}'); + expect(await run('Promise.resolve(" \\"x\\" \\n")'), ' "x" \n'); + }); + + test('resolving to a non-string yields an empty string', () async { + // Previously '42' etc. + for (final v in ['42', '{a: 1}', 'null', 'undefined', 'false', '[]']) { + expect(await run('Promise.resolve($v)'), '', reason: v); + } + }); + + test('bare thenables are not awaited, adopted ones are', () async { + expect(await run('({ then(resolve) { resolve("x"); } })'), ''); + expect( + await run('Promise.resolve({ then(resolve) { resolve("adopted"); } })'), + 'adopted', + ); + await expectLater( + run( + 'Promise.resolve({ then(_, reject) { reject(new Error("thenable boom")); } })', + ), + throwsJsError('thenable boom'), + ); + }); + + test('later, async and chained promises are awaited', () async { + expect( + await run( + 'new Promise(resolve => setTimeout(() => resolve("late"), 50))'), + 'late', + ); + expect( + await run('(async () => { await null; return "async"; })()'), + 'async', + ); + expect( + await run(''' + const sum = async (a, b) => a + b; + sum(1, 2).then(v => String(v)); + '''), + '3', + ); + }); + + test('rejection reasons are surfaced', () async { + await expectLater( + run('Promise.reject(new Error("boom"))'), throwsJsError('boom')); + await expectLater( + run('(async () => { throw new Error("async boom"); })()'), + throwsJsError('async boom'), + ); + await expectLater( + run('Promise.reject("plain reason")'), throwsJsError('plain reason')); + await expectLater(run('Promise.reject(42)'), throwsJsError('42')); + await expectLater(run('Promise.reject({message: "obj msg"})'), + throwsJsError('obj msg')); + await expectLater( + run('Promise.reject(undefined)'), throwsJsError('undefined')); + await expectLater( + run('Promise.reject(Symbol("sym"))'), throwsJsError('sym')); + await expectLater( + run('Promise.reject(Object.create(null))'), + throwsA(isA()), + ); + }); + + test('environment keeps working after a rejection', () async { + await expectLater(run('Promise.reject("x")'), throwsA(anything)); + expect(await run('Promise.resolve("ok")'), 'ok'); + }); + }, skip: skip); + + group('errors', () { + test('thrown values surface as JavaScriptDarwinExecutionException', + () async { + await expectLater( + run('throw new Error("bad thing")'), throwsJsError('bad thing')); + await expectLater(run('definitelyNotDefined + 1'), + throwsJsError('definitelyNotDefined')); + await expectLater( + run('throw "plain string"'), throwsJsError('plain string')); + await expectLater(run('this is not javascript'), + throwsA(isA())); + }); + + test('implements JavaScriptExecutionException', () async { + await expectLater(run('throw new Error("x")'), + throwsA(isA())); + }); + + test('the environment keeps working after an error', () async { + await expectLater(run('throw new Error("x")'), throwsA(anything)); + expect(await run('"still alive"'), 'still alive'); + }); + }, skip: skip); + + group('environment', () { + test('state persists, including let and const', () async { + await run('var counter = 1;'); + await run('counter += 1;'); + expect(await run('String(counter)'), '2'); + await run('let letValue = "let"; const constValue = "const";'); + expect(await run('letValue + " " + constValue'), 'let const'); + }); + + test('instances are isolated', () async { + final other = 'other-$id'; + await js.startNewJavaScriptEnvironment(other); + addTearDown(() => js.dispose(other)); + await run('var onlyHere = "mine";'); + expect(await js.runJavaScriptReturningResult(other, 'typeof onlyHere'), + 'undefined'); + }); + + test('runJavaScriptFromFileReturningResult evaluates a file', () async { + final dir = Directory.systemTemp.createTempSync('jsdarwin'); + addTearDown(() => dir.deleteSync(recursive: true)); + final file = File('${dir.path}/a.js') + ..writeAsStringSync('const f = {ok: true};\nJSON.stringify(f);\n'); + expect(await js.runJavaScriptFromFileReturningResult(id, file.path), + '{"ok":true}'); + }); + + test('setTimeout works', () async { + expect( + await run(''' + new Promise(resolve => { + const order = []; + setTimeout(() => { order.push("b"); resolve(order.join(",")); }, 30); + setTimeout(() => order.push("a"), 10); + }) + '''), + 'a,b', + ); + }); + + test('running after dispose throws', () async { + await js.dispose(id); + await expectLater( + run('"x"'), throwsA(isA())); + }); + + test('disposing while a promise is pending completes with an error', + () async { + final pending = run('new Promise(() => {})'); + final assertion = expectLater( + pending, throwsA(isA())); + await js.dispose(id); + await assertion; + }); + }, skip: skip); + + group('channels', () { + test('a JSON message round-trips with a JSON reply', () async { + String? received; + await js.addJavaScriptChannel( + id, + JavaScriptChannelParams( + name: 'Sum', + onMessageReceived: (message) { + received = message.message; + return JavaScriptReply(message: '{"sum":42}'); + }, + ), + ); + final result = await run(''' + sendMessage('Sum', JSON.stringify({a: 40, b: 2})) + .then(reply => String(JSON.parse(reply).sum)); + '''); + expect(received, '{"a":40,"b":2}'); + expect(result, '42'); + }); + + test('plain, empty and awkward string messages arrive verbatim', () async { + final received = []; + await js.addJavaScriptChannel( + id, + JavaScriptChannelParams( + name: 'Echo', + onMessageReceived: (message) { + received.add(message.message); + return JavaScriptReply(message: 'echo: ${message.message}'); + }, + ), + ); + expect( + await run("sendMessage('Echo', 'hello world')"), 'echo: hello world'); + expect(await run("sendMessage('Echo', '')"), 'echo: '); + expect(await run("sendMessage('Echo', ' \"q\" \\n\\u00e9 🌍 ')"), + 'echo: "q" \né 🌍 '); + expect(await run("sendMessage('Echo', '42')"), 'echo: 42'); + expect(received, ['hello world', '', ' "q" \né 🌍 ', '42']); + }); + + test('the reply string is delivered verbatim', () async { + await js.addJavaScriptChannel( + id, + JavaScriptChannelParams( + name: 'Reply', + onMessageReceived: (_) => JavaScriptReply(message: ' "spaced" \n'), + ), + ); + expect(await run("sendMessage('Reply', 'x')"), ' "spaced" \n'); + }); + + test('async handlers and many concurrent messages', () async { + await js.addJavaScriptChannel( + id, + JavaScriptChannelParams( + name: 'Slow', + onMessageReceived: (message) async { + await Future.delayed(const Duration(milliseconds: 20)); + return JavaScriptReply(message: 'slow ${message.message}'); + }, + ), + ); + expect(await run("sendMessage('Slow', 'reply')"), 'slow reply'); + await js.addJavaScriptChannel( + id, + JavaScriptChannelParams( + name: 'Double', + onMessageReceived: (message) async => + JavaScriptReply(message: '${int.parse(message.message!) * 2}'), + ), + ); + final result = await run(''' + Promise.all(Array.from({length: 50}, (_, i) => sendMessage('Double', String(i)))) + .then(replies => replies.join(',')); + '''); + expect(result, List.generate(50, (i) => i * 2).join(',')); + }); + + test('unregistered and removed channels throw', () async { + await expectLater(run("sendMessage('Nope', 'x')"), throwsJsError('Nope')); + await js.addJavaScriptChannel( + id, + JavaScriptChannelParams( + name: 'Temp', + onMessageReceived: (_) => JavaScriptReply(message: 'temp'), + ), + ); + expect(await run("sendMessage('Temp', 'x')"), 'temp'); + await js.removeJavaScriptChannel(id, 'Temp'); + await expectLater(run("sendMessage('Temp', 'x')"), throwsJsError('Temp')); + }); + + test('non-string messages throw synchronously in JS', () async { + await js.addJavaScriptChannel( + id, + JavaScriptChannelParams( + name: 'Strict', + onMessageReceived: (_) => JavaScriptReply(message: 'never'), + ), + ); + await expectLater( + run("sendMessage('Strict', {a: 1})"), + throwsJsError('Message must be a string'), + ); + await expectLater( + run("sendMessage('Strict', 42)"), + throwsJsError('Message must be a string'), + ); + expect(await run("sendMessage('Strict', 'ok')"), 'never'); + }); + + test('a null reply resolves the JS promise with null', () async { + await js.addJavaScriptChannel( + id, + JavaScriptChannelParams( + name: 'Nothing', + onMessageReceived: (_) => JavaScriptReply(message: null), + ), + ); + expect( + await run( + "sendMessage('Nothing','x').then(v => v === null ? 'was null' : 'was ' + typeof v)", + ), + 'was null', + ); + }); + + test('console.log keeps working', () async { + expect(await run('console.log("hi"); "after"'), 'after'); + }); + }, skip: skip); +} diff --git a/packages/javascript_platform_interface/CHANGELOG.md b/packages/javascript_platform_interface/CHANGELOG.md index 2056337..2066367 100644 --- a/packages/javascript_platform_interface/CHANGELOG.md +++ b/packages/javascript_platform_interface/CHANGELOG.md @@ -1,3 +1,13 @@ +# 2.1.0 + +- Document the result contract of `runJavaScriptReturningResult` and `runJavaScriptFromFileReturningResult` (documentation only, no code change): + - The completion value of the script, or the value a returned `Promise` resolves to, is returned as a `String` exactly as-is, with no JSON decoding or type detection. + - A value that is not a JavaScript string resolves to the empty string `''` on every platform. Use `JSON.stringify(...)` in JavaScript and `jsonDecode` in Dart for structured data. + - A rejected promise, a thrown error, or a parse failure completes with a `JavaScriptExecutionException`. + - The declared type stays `Future` but always resolves to a `String`; it may be narrowed to `Future` in a future major version. +- Document that a `null` `JavaScriptReply.message` resolves the JavaScript promise with `null`. +- Document that channel messages (`JavaScriptMessage.message`, `JavaScriptReply.message`) are strings delivered verbatim, and that calling `sendMessage` with an unregistered channel throws in JavaScript. + # 2.0.0 - Add [JavaScriptUnavailablePlatform] as the default JavaScriptPlatform diff --git a/packages/javascript_platform_interface/lib/javascript_platform_interface.dart b/packages/javascript_platform_interface/lib/javascript_platform_interface.dart index 3d94b1c..17b74b6 100644 --- a/packages/javascript_platform_interface/lib/javascript_platform_interface.dart +++ b/packages/javascript_platform_interface/lib/javascript_platform_interface.dart @@ -54,21 +54,64 @@ abstract class JavaScriptPlatform extends PlatformInterface { /// Evaluates the given JavaScript code [javaScript] in the context of the javascript environment /// with id [javaScriptInstanceId], and returns the result. /// - /// The Future completes with an error if a JavaScript error occurred, or if the - /// type the given expression evaluates to is unsupported. + /// {@template javascript_platform_result_contract} + /// ## Result contract + /// + /// 1. The value the script evaluates to (its completion value, i.e. the last + /// expression statement; there is no top level `return`), or the value a + /// returned `Promise` resolves to, is returned as a [String] exactly + /// as-is. There is no JSON decoding, number or boolean parsing, or type + /// detection. `""` is `""`. + /// 2. If that value is not a JavaScript string (a 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`; WebViews without it + /// resolve a Promise result to `''`). A rejected promise completes the [Future] with a + /// [JavaScriptExecutionException] carrying the rejection reason. A script + /// that throws or fails to parse completes it with a + /// [JavaScriptExecutionException] carrying the JavaScript error message. + /// 4. The declared type is `Future` for API compatibility, but the + /// [Future] always resolves to a [String]. It may be narrowed to + /// `Future` in a future major version. + /// + /// To get structured data back, end the script with `JSON.stringify(...)` + /// and decode it in Dart: + /// + /// ```dart + /// final result = await platform.runJavaScriptReturningResult( + /// id, + /// 'JSON.stringify({a: 1, b: [2, 3]})', + /// ); + /// final data = jsonDecode(result as String); // {a: 1, b: [2, 3]} + /// ``` + /// {@endtemplate} Future runJavaScriptReturningResult(String javaScriptInstanceId, String javaScript); /// Loads the content of of file from [javaScriptFilePath] and evaluates it as javascript code in the context of the /// javascript environment with id [javaScriptInstanceId], and returns the result. /// - /// The Future completes with an error if a JavaScript error occurred, or if the - /// type the given expression evaluates to is unsupported. + /// The result follows the same contract as [runJavaScriptReturningResult]: + /// + /// {@macro javascript_platform_result_contract} Future runJavaScriptFromFileReturningResult( String javaScriptInstanceId, String javaScriptFilePath, ); /// Adds a new JavaScript channel to the set of enabled channels. + /// + /// JavaScript code calls `sendMessage(channelName, message)` where [message] + /// is a string. The Dart handler + /// ([JavaScriptChannelParams.onMessageReceived]) receives it verbatim in + /// [JavaScriptMessage.message], and [JavaScriptReply.message] is delivered + /// to JavaScript verbatim as the string the returned promise resolves to. + /// Nothing is encoded or decoded on the way, so use `JSON.stringify` and + /// `jsonDecode`/`jsonEncode` for structured data. + /// + /// Calling `sendMessage` with a channel name that is not registered throws + /// in JavaScript. Future addJavaScriptChannel( String javaScriptInstanceId, JavaScriptChannelParams javaScriptChannelParams, diff --git a/packages/javascript_platform_interface/lib/src/api/message.dart b/packages/javascript_platform_interface/lib/src/api/message.dart index ddcf099..846528a 100644 --- a/packages/javascript_platform_interface/lib/src/api/message.dart +++ b/packages/javascript_platform_interface/lib/src/api/message.dart @@ -8,6 +8,10 @@ class JavaScriptMessage { const JavaScriptMessage({required this.message}); /// The contents of the message that was sent by the JavaScript code. + /// + /// This is the string passed to `sendMessage(channelName, message)` in + /// JavaScript, delivered verbatim. Nothing is decoded; use `jsonDecode` if + /// the script sent `JSON.stringify(...)`. final String? message; } @@ -18,5 +22,11 @@ class JavaScriptReply { const JavaScriptReply({required this.message}); /// The contents of the message that is sent back to the JavaScript code. + /// + /// It is delivered verbatim to JavaScript as the string the promise returned + /// by `sendMessage(...)` resolves to. Nothing is encoded; use `jsonEncode` + /// to reply with structured data and `JSON.parse` it in JavaScript. + /// + /// A `null` message resolves the JavaScript promise with `null`. final String? message; } diff --git a/packages/javascript_platform_interface/pubspec.yaml b/packages/javascript_platform_interface/pubspec.yaml index dee03b4..98d53f7 100644 --- a/packages/javascript_platform_interface/pubspec.yaml +++ b/packages/javascript_platform_interface/pubspec.yaml @@ -2,7 +2,7 @@ name: javascript_platform_interface description: A common platform interface for the javascript_flutter plugin. repository: https://github.com/predatorx7/javascript_flutter/tree/main/packages/javascript_platform_interface issue_tracker: https://github.com/predatorx7/javascript_flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+javascript_platform_interface%22 -version: 2.0.0 +version: 2.1.0 environment: sdk: ^3.6.0 From c2dd91484f2ad663e1549490ecc6cdac82f7cc19 Mon Sep 17 00:00:00 2001 From: predatorx7 Date: Mon, 26 Oct 2026 15:13:22 +0530 Subject: [PATCH 3/4] fix(android): return results verbatim, message ports via javascriptengine 1.1.1 Fixes the Android half of #11 and migrates channel messaging to the proper message-port API. Results: `runJavaScriptReturningResult` and `runJavaScriptFromFileReturningResult` now return the engine's string untouched. The old `parseValue` heuristic, which guessed types from the string content (a JS string "42" became int 42, '{"a":1}' became a Map, while an actual JS number 42 arrived as "" because androidx.javascriptengine coerces every non-string result to ""), is removed. This is the documented behavior change of this release: callers jsonDecode themselves. A warning is logged at isolate start if the WebView lacks JS_FEATURE_PROMISE_RETURN, in which case promises cannot be awaited by the engine and resolve to "". Messaging: bump androidx.javascriptengine 1.0.0 -> 1.1.1 and, when JS_FEATURE_MESSAGE_PORTS is supported, open one MessagePort per isolate with createMessageChannel; JS obtains it with android.getNamedPort and posts {channelName, id, message} as a string, replies travel back as {channelName, id, ok, message | error}. Messages posted before the port promise resolves are queued. Selection order is message port, then the console-messaging hack (JS_FEATURE_CONSOLE_MESSAGING), then timer polling, gated by isFeatureSupported, so older WebViews keep working. Pigeon gains a JavaScriptAndroidMessagePortHandler proxy API and two host methods; the public Dart API is unchanged apart from @visibleForTesting hooks to force a transport. removeJavaScriptChannel now also deletes the channel on the JS side so sendMessage on a removed channel throws, as it does on Darwin. Tests: 35 Dart unit tests against a fake pigeon host (verbatim results, transport selection, port/console/polling routing, SetTimeout, exception mapping) and a device test that forces each of the three transports in turn. Verified on an API 37 emulator (WebView 153), where logcat confirms "Message port transport ...: enabled" by default. Co-Authored-By: Claude Fable 5.1 --- .../android_transports_test.dart | 177 +++++ packages/javascript/example/pubspec.yaml | 2 + packages/javascript_android/CHANGELOG.md | 31 + .../android/build.gradle.kts | 2 +- .../javascript_android/JavaScriptAndroid.kt | 102 +++ .../JavaScriptAndroidMessagePortHandler.kt | 22 + ...ScriptAndroidMessagePortHandlerProxyApi.kt | 11 + .../javascript_android/Messages.kt | 142 ++++ .../javascript_android/ProxyApiRegistrar.kt | 4 + .../lib/javascript_android.dart | 123 +++- .../lib/src/host/scripts.dart | 61 +- .../lib/src/host/state.dart | 140 +++- .../lib/src/host/transport.dart | 14 + .../javascript_android/lib/src/parse.dart | 15 - .../lib/src/pigeons/messages.pigeon.dart | 197 ++++++ .../javascript_android/pigeons/schema.dart | 22 + packages/javascript_android/pubspec.yaml | 4 +- .../test/javascript_android_test.dart | 642 ++++++++++++++++++ 18 files changed, 1645 insertions(+), 66 deletions(-) create mode 100644 packages/javascript/example/integration_test/android_transports_test.dart create mode 100644 packages/javascript_android/android/src/main/kotlin/com/magnificsoftware/javascript_android/JavaScriptAndroidMessagePortHandler.kt create mode 100644 packages/javascript_android/android/src/main/kotlin/com/magnificsoftware/javascript_android/JavaScriptAndroidMessagePortHandlerProxyApi.kt create mode 100644 packages/javascript_android/lib/src/host/transport.dart delete mode 100644 packages/javascript_android/lib/src/parse.dart diff --git a/packages/javascript/example/integration_test/android_transports_test.dart b/packages/javascript/example/integration_test/android_transports_test.dart new file mode 100644 index 0000000..764ba65 --- /dev/null +++ b/packages/javascript/example/integration_test/android_transports_test.dart @@ -0,0 +1,177 @@ +// Exercises every Android messaging transport (message ports, console +// messaging and polling) by forcing each one in turn on a device that supports +// all of them. Skipped on other platforms. +// +// Run with `flutter test integration_test/android_transports_test.dart -d `. +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart' hide test; +import 'package:flutter_test/flutter_test.dart' as flutter_test show test; +import 'package:integration_test/integration_test.dart'; +import 'package:javascript_android/javascript_android.dart'; +import 'package:javascript_flutter/javascript_flutter.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + if (!Platform.isAndroid) { + flutter_test.test('Android transports', () {}, skip: 'Android only'); + return; + } + + // Transports the device can actually select, discovered with nothing + // disabled. Selection order is messagePort, consoleMessaging, polling, so the + // default pick tells us what is supported (e.g. a CI emulator WebView may lack + // JS_FEATURE_MESSAGE_PORTS). + late final JavaScriptAndroidMessagingTransport defaultTransport; + + setUpAll(() async { + JavaScriptAndroid.debugDisabledTransports = {}; + final probe = await JavaScript.createNew(); + defaultTransport = JavaScriptAndroid.debugTransportFor(probe.instanceId)!; + await probe.dispose(); + }); + + for (final transport in JavaScriptAndroidMessagingTransport.values) { + group('transport ${transport.name}', () { + late JavaScript js; + + // Registers a test that is reported as skipped when this device cannot + // select the transport under test. + void test(String name, Future Function() body) { + flutter_test.test(name, () async { + if (transport.index < defaultTransport.index) { + markTestSkipped( + '${transport.name} is not supported by this device ' + '(default transport: ${defaultTransport.name})', + ); + return; + } + await body(); + }); + } + + setUp(() async { + if (transport.index < defaultTransport.index) return; + // Disable every transport that comes before the one under test, so the + // selection order lands on it. + JavaScriptAndroid.debugDisabledTransports = { + for (final t in JavaScriptAndroidMessagingTransport.values) + if (t.index < transport.index) t, + }; + js = await JavaScript.createNew(); + }); + + tearDown(() async { + JavaScriptAndroid.debugDisabledTransports = {}; + if (transport.index < defaultTransport.index) return; + await js.dispose(); + }); + + Future run(String source) => + js.runJavaScriptReturningResult(source); + + test('is the selected transport', () async { + expect(JavaScriptAndroid.debugTransportFor(js.instanceId), transport); + }); + + test('a JSON message round-trips with a JSON reply', () async { + Object? received; + await js.addJavaScriptChannel( + JavaScriptChannelParams( + name: 'Sum', + onMessageReceived: (message) { + received = jsonDecode(message.message!); + final data = received! as Map; + return JavaScriptReply( + message: jsonEncode({'sum': data['a'] + data['b']}), + ); + }, + ), + ); + final result = await run(''' + sendMessage('Sum', JSON.stringify({a: 40, b: 2})) + .then(reply => JSON.parse(reply)) + .then(data => String(data.sum)); + '''); + expect(received, {'a': 40, 'b': 2}); + expect(result, '42'); + }); + + test('a plain string message and reply are verbatim', () async { + String? received; + await js.addJavaScriptChannel( + JavaScriptChannelParams( + name: 'Echo', + onMessageReceived: (message) { + received = message.message; + return JavaScriptReply( + message: ' "echo: ${message.message}" \n', + ); + }, + ), + ); + final result = await run("sendMessage('Echo', ' héllo \"wörld\" 🌍 ')"); + expect(received, ' héllo "wörld" 🌍 '); + expect(result, ' "echo: ${received!}" \n'); + }); + + test('many concurrent messages are all answered', () async { + await js.addJavaScriptChannel( + JavaScriptChannelParams( + name: 'Double', + onMessageReceived: (message) async { + return JavaScriptReply( + message: '${int.parse(message.message!) * 2}', + ); + }, + ), + ); + final result = await run(''' + Promise.all(Array.from({length: 50}, (_, i) => sendMessage('Double', String(i)))) + .then(replies => replies.join(',')); + '''); + expect(result, List.generate(50, (i) => i * 2).join(',')); + }); + + test('a handler error rejects the JavaScript promise', () async { + await js.addJavaScriptChannel( + JavaScriptChannelParams( + name: 'Bad', + onMessageReceived: (_) => throw StateError('handler boom'), + ), + ); + await expectLater( + run("sendMessage('Bad', 'x')"), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('handler boom'), + ), + ), + ); + }); + + test('setTimeout works', () async { + final result = await run(''' + new Promise(resolve => { + const order = []; + setTimeout(() => { order.push("b"); resolve(order.join(",")); }, 150); + setTimeout(() => order.push("a"), 10); + }) + '''); + expect(result, 'a,b'); + }); + + test('plain string results are returned verbatim', () async { + expect(await run('"42"'), '42'); + expect(await run('JSON.stringify({a: 1})'), '{"a":1}'); + expect(await run('"null"'), 'null'); + expect(await run('42'), ''); + expect(await run('Promise.resolve("later")'), 'later'); + }); + }); + } +} diff --git a/packages/javascript/example/pubspec.yaml b/packages/javascript/example/pubspec.yaml index 50259a2..0bb00c9 100644 --- a/packages/javascript/example/pubspec.yaml +++ b/packages/javascript/example/pubspec.yaml @@ -30,6 +30,8 @@ dev_dependencies: sdk: flutter integration_test: sdk: flutter + # Only for the transport testing hooks used by integration_test/android_transports_test.dart. + javascript_android: ^2.2.0 flutter_lints: ^6.0.0 plugin_platform_interface: ^2.1.0 diff --git a/packages/javascript_android/CHANGELOG.md b/packages/javascript_android/CHANGELOG.md index f430ffe..a4b4c25 100644 --- a/packages/javascript_android/CHANGELOG.md +++ b/packages/javascript_android/CHANGELOG.md @@ -1,3 +1,34 @@ +## 2.2.0 + +### BREAKING BEHAVIOR CHANGE: results are no longer auto-JSON-decoded + +`runJavaScriptReturningResult` and `runJavaScriptFromFileReturningResult` now return what the JavaScript engine gives back as a Dart `String`, exactly as-is. Nothing is parsed or coerced anymore. + +- A script that evaluates to the JS string `"42"` now returns the Dart `String` `'42'`, not the `int` `42`. +- `'{"a":1}'` now returns the JSON text, not a `Map`. Likewise `'true'`, `'null'` and `'[1,2]'` stay strings. +- Callers that relied on the old decoding must call `jsonDecode` themselves. + +### Fixes + +- Fix [#11](https://github.com/predatorx7/javascript_flutter/issues/11): the old heuristic decoding produced inconsistent types and differed from the other platforms. Results that are not JS strings (numbers, booleans, `null`, objects, ...) are documented as returning an empty string on both Android and Darwin. Use `JSON.stringify(...)` in the script to return structured data. A Promise is awaited and its resolved string is returned when the WebView supports `JS_FEATURE_PROMISE_RETURN`; otherwise it evaluates to an empty string. +- Removing a channel now also removes it from the JavaScript environment: `sendMessage` on a removed channel throws, and sends already in flight are rejected with an error instead of never resolving. +- A `null` reply message resolves the JavaScript promise with `null` (it used to be the string `"null"`). +- Message ids are unique across channels, so a late reply for a removed channel can no longer resolve a message of a re-added channel. +- A failed environment start now disposes the native isolate and message port instead of leaking them. + +### Features + +- Channel messaging (`sendMessage` in JavaScript, replies from Dart, and the internal `setTimeout` support) now uses `androidx.javascriptengine` message ports (`JS_FEATURE_MESSAGE_PORTS`, `androidx.javascriptengine:javascriptengine:1.1.1`). Message bodies are delivered verbatim in one hop, with no console truncation and no follow-up fetch. +- Transport selection order is message ports, then the console messaging hack (`JS_FEATURE_CONSOLE_MESSAGING`), then timer polling. The chosen transport is logged to logcat under the `JavaScriptAndroid` tag. +- A warning is logged when the WebView lacks `JS_FEATURE_PROMISE_RETURN`: Promises then evaluate to an empty string. +- Requires Flutter 3.44 or newer and Java 17 for the Android build. +- `minSdk` remains 26 (required by `androidx.javascriptengine`). + +### Testing hooks + +- `JavaScriptAndroid.debugDisabledTransports` (`@visibleForTesting`) skips transports during selection so fallbacks can be exercised on a capable device. +- `JavaScriptAndroid.debugTransportFor(instanceId)` (`@visibleForTesting`) returns the `JavaScriptAndroidMessagingTransport` selected for an instance. + # 2.1.0 - For improving performance, add a hack to use console messaging as a more efficient way to receive independent javascript channel messages from JavaScript environment diff --git a/packages/javascript_android/android/build.gradle.kts b/packages/javascript_android/android/build.gradle.kts index d7e2840..fdc252a 100644 --- a/packages/javascript_android/android/build.gradle.kts +++ b/packages/javascript_android/android/build.gradle.kts @@ -73,7 +73,7 @@ kotlin { } dependencies { - implementation("androidx.javascriptengine:javascriptengine:1.0.0") + implementation("androidx.javascriptengine:javascriptengine:1.1.1") testImplementation("org.jetbrains.kotlin:kotlin-test") testImplementation("org.mockito:mockito-core:5.18.0") diff --git a/packages/javascript_android/android/src/main/kotlin/com/magnificsoftware/javascript_android/JavaScriptAndroid.kt b/packages/javascript_android/android/src/main/kotlin/com/magnificsoftware/javascript_android/JavaScriptAndroid.kt index 552cfa2..a275750 100644 --- a/packages/javascript_android/android/src/main/kotlin/com/magnificsoftware/javascript_android/JavaScriptAndroid.kt +++ b/packages/javascript_android/android/src/main/kotlin/com/magnificsoftware/javascript_android/JavaScriptAndroid.kt @@ -9,6 +9,8 @@ import androidx.javascriptengine.JavaScriptConsoleCallback import androidx.javascriptengine.JavaScriptIsolate import androidx.javascriptengine.JavaScriptSandbox import androidx.javascriptengine.MemoryLimitExceededException +import androidx.javascriptengine.Message +import androidx.javascriptengine.MessagePort import androidx.javascriptengine.SandboxDeadException import androidx.javascriptengine.TerminationInfo import com.google.common.util.concurrent.FutureCallback @@ -21,6 +23,7 @@ class JavaScriptAndroid(private var applicationContext: android.content.Context, JavaScriptAndroidPlatformApi { companion object { private const val TAG = "JavaScriptAndroid" + private const val MESSAGE_PORT_NAME = "javascript_android_host" private var sandbox: ListenableFuture? = null } @@ -33,6 +36,18 @@ class JavaScriptAndroid(private var applicationContext: android.content.Context, private val activeJsIsolates = mutableMapOf() + // Message ports opened per isolate, see [setJavaScriptMessagePortHandler]. + private val activeMessagePorts = mutableMapOf() + + private fun closeMessagePort(javascriptInstanceId: String) { + val port = activeMessagePorts.remove(javascriptInstanceId) ?: return + try { + port.close() + } catch (e: Throwable) { + Log.w(TAG, "Failed to close message port of isolate $javascriptInstanceId", e) + } + } + private fun getMainExecutor(): Executor { val executor = ContextCompat.getMainExecutor(applicationContext) return executor @@ -54,6 +69,9 @@ class JavaScriptAndroid(private var applicationContext: android.content.Context, "JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT" to sandbox.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_EVALUATE_WITHOUT_TRANSACTION_LIMIT), "JS_FEATURE_ISOLATE_MAX_HEAP_SIZE" to sandbox.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_ISOLATE_MAX_HEAP_SIZE) ).toString()) + if (!sandbox.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_PROMISE_RETURN)) { + Log.w(TAG, "JS_FEATURE_PROMISE_RETURN is not supported by this WebView: scripts that evaluate to a Promise will return an empty string instead of the resolved value") + } val jsIsolate = sandbox.createIsolate(startupParams) activeJsIsolates.put(javascriptInstanceId, jsIsolate) callback(Result.success(Unit)) @@ -68,6 +86,7 @@ class JavaScriptAndroid(private var applicationContext: android.content.Context, else -> IsolateTerminatedException(this.toString()) } cleanUpIfSandboxDead(ex) + closeMessagePort(javascriptInstanceId) activeJsIsolates.remove(javascriptInstanceId) } } @@ -102,6 +121,7 @@ class JavaScriptAndroid(private var applicationContext: android.content.Context, ) { try { val js = requireJsIsolateById(javascriptInstanceId) + closeMessagePort(javascriptInstanceId) js.close() activeJsIsolates.remove(javascriptInstanceId) callback(Result.success(Unit)) @@ -200,6 +220,7 @@ class JavaScriptAndroid(private var applicationContext: android.content.Context, if (sandbox.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_CONSOLE_MESSAGING)) { js.setConsoleCallback(getMainExecutor(), handler) result = true + Log.i(TAG, "Console messaging transport for $javascriptInstanceId: enabled") } } catch (e: Throwable) { onError(e) @@ -223,6 +244,86 @@ class JavaScriptAndroid(private var applicationContext: android.content.Context, } } + override fun setJavaScriptMessagePortHandler( + javascriptInstanceId: String, + messagePortHandlerInstanceIdentifier: Long, + callback: (Result) -> Unit + ) { + try { + val handler = proxyApiRegistrar.instanceManager.getInstance(messagePortHandlerInstanceIdentifier) + if (handler == null) { + return callback(Result.failure(Throwable("No JavaScriptAndroidMessagePortHandler found registered by identifier $messagePortHandlerInstanceIdentifier"))) + } + + val sandboxFuture = getJSSandbox() + val js = requireJsIsolateById(javascriptInstanceId) + + var didSubmitResponse = false + + fun onError(t: Throwable) { + cleanUpIfSandboxDead(t) + if (didSubmitResponse) { + Log.e(TAG, "An error was received when didSubmitResponse is true", t) + return + } + didSubmitResponse = true + callback(Result.failure(t)) + } + + Futures.addCallback( + sandboxFuture, object : FutureCallback { + override fun onSuccess(sandbox: JavaScriptSandbox) { + var result = false + try { + if (sandbox.isFeatureSupported(JavaScriptSandbox.JS_FEATURE_MESSAGE_PORTS)) { + if (activeMessagePorts.containsKey(javascriptInstanceId)) { + result = true + } else { + try { + val port = js.createMessageChannel(MESSAGE_PORT_NAME, getMainExecutor(), handler) + activeMessagePorts[javascriptInstanceId] = port + result = true + } catch (e: Exception) { + Log.w(TAG, "Failed to create message channel, falling back", e) + } + } + } + } catch (e: Throwable) { + onError(e) + return + } + if (didSubmitResponse) { + Log.i(TAG, "A response was received when didSubmitResponse is true: $result") + return + } + didSubmitResponse = true + Log.i(TAG, "Message port transport for $javascriptInstanceId: ${if (result) "enabled" else "unavailable"}") + callback(Result.success(result)) + } + + override fun onFailure(t: Throwable) { + onError(t) + } + }, getMainExecutor() + ) + } catch (e: Exception) { + callback(Result.failure(e)) + } + } + + override fun postMessageToJavaScript( + javascriptInstanceId: String, message: String, callback: (Result) -> Unit + ) { + try { + val port = activeMessagePorts[javascriptInstanceId] + ?: throw IllegalStateException("No active message port for isolate with id $javascriptInstanceId") + port.postMessage(Message.createStringMessage(message)) + callback(Result.success(Unit)) + } catch (e: Exception) { + callback(Result.failure(e)) + } + } + override fun setIsInspectable( javascriptInstanceId: String, isInspectable: Boolean, callback: (Result) -> Unit ) { @@ -253,6 +354,7 @@ class JavaScriptAndroid(private var applicationContext: android.content.Context, sandbox, object : FutureCallback { override fun onSuccess(sandbox: JavaScriptSandbox) { Log.i("JavaScriptAndroid", "Closing sandbox") + activeMessagePorts.keys.toList().forEach { closeMessagePort(it) } activeJsIsolates.clear() try { sandbox.close() diff --git a/packages/javascript_android/android/src/main/kotlin/com/magnificsoftware/javascript_android/JavaScriptAndroidMessagePortHandler.kt b/packages/javascript_android/android/src/main/kotlin/com/magnificsoftware/javascript_android/JavaScriptAndroidMessagePortHandler.kt new file mode 100644 index 0000000..b91728d --- /dev/null +++ b/packages/javascript_android/android/src/main/kotlin/com/magnificsoftware/javascript_android/JavaScriptAndroidMessagePortHandler.kt @@ -0,0 +1,22 @@ +package com.magnificsoftware.javascript_android + +import androidx.javascriptengine.Message +import androidx.javascriptengine.MessagePortClient + +/** Receives string messages that JavaScript posts over the isolate's message port and forwards them to Dart. */ +class JavaScriptAndroidMessagePortHandler + constructor(val api: JavaScriptAndroidMessagePortHandlerProxyApi) : MessagePortClient { + + override fun onMessage(message: Message) { + if (message.type != Message.TYPE_STRING) return + onMessage(message.string) + } + + fun onMessage(message: String) { + api.pigeonRegistrar + .runOnMainThread( + { + api.onMessage(this@JavaScriptAndroidMessagePortHandler, message, { reply -> null }) + }) + } +} diff --git a/packages/javascript_android/android/src/main/kotlin/com/magnificsoftware/javascript_android/JavaScriptAndroidMessagePortHandlerProxyApi.kt b/packages/javascript_android/android/src/main/kotlin/com/magnificsoftware/javascript_android/JavaScriptAndroidMessagePortHandlerProxyApi.kt new file mode 100644 index 0000000..dc9cdfa --- /dev/null +++ b/packages/javascript_android/android/src/main/kotlin/com/magnificsoftware/javascript_android/JavaScriptAndroidMessagePortHandlerProxyApi.kt @@ -0,0 +1,11 @@ +package com.magnificsoftware.javascript_android + +class JavaScriptAndroidMessagePortHandlerProxyApi(pigeonRegistrar: ProxyApiRegistrar) : + PigeonApiJavaScriptAndroidMessagePortHandler(pigeonRegistrar) { + override fun pigeon_defaultConstructor(): JavaScriptAndroidMessagePortHandler { + return JavaScriptAndroidMessagePortHandler(this) + } + + override val pigeonRegistrar: ProxyApiRegistrar + get() = super.pigeonRegistrar as ProxyApiRegistrar +} diff --git a/packages/javascript_android/android/src/main/kotlin/com/magnificsoftware/javascript_android/Messages.kt b/packages/javascript_android/android/src/main/kotlin/com/magnificsoftware/javascript_android/Messages.kt index 02acd94..09e1803 100644 --- a/packages/javascript_android/android/src/main/kotlin/com/magnificsoftware/javascript_android/Messages.kt +++ b/packages/javascript_android/android/src/main/kotlin/com/magnificsoftware/javascript_android/Messages.kt @@ -403,13 +403,21 @@ abstract class MessagesPigeonProxyApiRegistrar(val binaryMessenger: BinaryMessen */ abstract fun getPigeonApiJavaScriptAndroidConsoleMessageHandler(): PigeonApiJavaScriptAndroidConsoleMessageHandler + /** + * An implementation of [PigeonApiJavaScriptAndroidMessagePortHandler] used to add a new Dart instance of + * `JavaScriptAndroidMessagePortHandler` to the Dart `InstanceManager`. + */ + abstract fun getPigeonApiJavaScriptAndroidMessagePortHandler(): PigeonApiJavaScriptAndroidMessagePortHandler + fun setUp() { MessagesPigeonInstanceManagerApi.setUpMessageHandlers(binaryMessenger, instanceManager) PigeonApiJavaScriptAndroidConsoleMessageHandler.setUpMessageHandlers(binaryMessenger, getPigeonApiJavaScriptAndroidConsoleMessageHandler()) + PigeonApiJavaScriptAndroidMessagePortHandler.setUpMessageHandlers(binaryMessenger, getPigeonApiJavaScriptAndroidMessagePortHandler()) } fun tearDown() { MessagesPigeonInstanceManagerApi.setUpMessageHandlers(binaryMessenger, null) PigeonApiJavaScriptAndroidConsoleMessageHandler.setUpMessageHandlers(binaryMessenger, null) + PigeonApiJavaScriptAndroidMessagePortHandler.setUpMessageHandlers(binaryMessenger, null) } } private class MessagesPigeonProxyApiBaseCodec(val registrar: MessagesPigeonProxyApiRegistrar) : MessagesPigeonCodec() { @@ -450,6 +458,13 @@ private class MessagesPigeonProxyApiBaseCodec(val registrar: MessagesPigeonProxy } } } + else if (value is JavaScriptAndroidMessagePortHandler) { + registrar.getPigeonApiJavaScriptAndroidMessagePortHandler().pigeon_newInstance(value) { + if (it.isFailure) { + logNewInstanceFailure("JavaScriptAndroidMessagePortHandler", value, it.exceptionOrNull()) + } + } + } when { registrar.instanceManager.containsInstance(value) -> { @@ -547,6 +562,84 @@ abstract class PigeonApiJavaScriptAndroidConsoleMessageHandler(open val pigeonRe } } +} +@Suppress("UNCHECKED_CAST") +abstract class PigeonApiJavaScriptAndroidMessagePortHandler(open val pigeonRegistrar: MessagesPigeonProxyApiRegistrar) { + abstract fun pigeon_defaultConstructor(): JavaScriptAndroidMessagePortHandler + + companion object { + @Suppress("LocalVariableName") + fun setUpMessageHandlers(binaryMessenger: BinaryMessenger, api: PigeonApiJavaScriptAndroidMessagePortHandler?) { + val codec = api?.pigeonRegistrar?.codec ?: MessagesPigeonCodec() + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.javascript_android.JavaScriptAndroidMessagePortHandler.pigeon_defaultConstructor", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val pigeon_identifierArg = args[0] as Long + val wrapped: List = try { + api.pigeonRegistrar.instanceManager.addDartCreatedInstance(api.pigeon_defaultConstructor(), pigeon_identifierArg) + listOf(null) + } catch (exception: Throwable) { + MessagesPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } + + @Suppress("LocalVariableName", "FunctionName") + /** Creates a Dart instance of JavaScriptAndroidMessagePortHandler and attaches it to [pigeon_instanceArg]. */ + fun pigeon_newInstance(pigeon_instanceArg: JavaScriptAndroidMessagePortHandler, callback: (Result) -> Unit) +{ + if (pigeonRegistrar.ignoreCallsToDart) { + callback( + Result.failure( + FlutterError("ignore-calls-error", "Calls to Dart are being ignored.", ""))) + } else if (pigeonRegistrar.instanceManager.containsInstance(pigeon_instanceArg)) { + callback(Result.success(Unit)) + } else { + callback( + Result.failure( + FlutterError("new-instance-error", "Attempting to create a new Dart instance of JavaScriptAndroidMessagePortHandler, but the class has a nonnull callback method.", ""))) + } + } + + /** Handles string messages posted by JavaScript over the message port. */ + fun onMessage(pigeon_instanceArg: JavaScriptAndroidMessagePortHandler, messageArg: String, callback: (Result) -> Unit) +{ + if (pigeonRegistrar.ignoreCallsToDart) { + callback( + Result.failure( + FlutterError("ignore-calls-error", "Calls to Dart are being ignored.", ""))) + return + } else if (!pigeonRegistrar.instanceManager.containsInstance(pigeon_instanceArg)) { + callback( + Result.failure( + FlutterError("missing-instance-error", "Callback to `JavaScriptAndroidMessagePortHandler.onMessage` failed because native instance was not in the instance manager.", ""))) + return + } + val binaryMessenger = pigeonRegistrar.binaryMessenger + val codec = pigeonRegistrar.codec + val channelName = "dev.flutter.pigeon.javascript_android.JavaScriptAndroidMessagePortHandler.onMessage" + val channel = BasicMessageChannel(binaryMessenger, channelName, codec) + channel.send(listOf(pigeon_instanceArg, messageArg)) { + if (it is List<*>) { + if (it.size > 1) { + callback(Result.failure(FlutterError(it[0] as String, it[1] as String, it[2] as String?))) + } else { + callback(Result.success(Unit)) + } + } else { + callback(Result.failure(MessagesPigeonUtils.createConnectionError(channelName))) + } + } + } + } /** Generated interface from Pigeon that represents a handler of messages from Flutter. */ interface JavaScriptAndroidPlatformApi { @@ -556,6 +649,14 @@ interface JavaScriptAndroidPlatformApi { fun runJavaScriptFromFileReturningResult(javascriptInstanceId: String, javaScriptFilePath: String, callback: (Result) -> Unit) fun setIsInspectable(javascriptInstanceId: String, isInspectable: Boolean, callback: (Result) -> Unit) fun setJavaScriptConsoleMessageHandler(javascriptInstanceId: String, mJavaScriptAndroidConsoleMessageHandlerInstanceIdentifier: Long, callback: (Result) -> Unit) + /** + * Opens a message port named `javascript_android_host` on the isolate and + * forwards the string messages JavaScript posts to [handler]. Returns false + * when message ports are unsupported by the device's WebView. + */ + fun setJavaScriptMessagePortHandler(javascriptInstanceId: String, messagePortHandlerInstanceIdentifier: Long, callback: (Result) -> Unit) + /** Posts a string message to JavaScript over the message port. */ + fun postMessageToJavaScript(javascriptInstanceId: String, message: String, callback: (Result) -> Unit) companion object { /** The codec used by JavaScriptAndroidPlatformApi. */ @@ -687,6 +788,47 @@ interface JavaScriptAndroidPlatformApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.javascript_android.JavaScriptAndroidPlatformApi.setJavaScriptMessagePortHandler$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val javascriptInstanceIdArg = args[0] as String + val messagePortHandlerInstanceIdentifierArg = args[1] as Long + api.setJavaScriptMessagePortHandler(javascriptInstanceIdArg, messagePortHandlerInstanceIdentifierArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(MessagesPigeonUtils.wrapError(error)) + } else { + val data = result.getOrNull() + reply.reply(MessagesPigeonUtils.wrapResult(data)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.javascript_android.JavaScriptAndroidPlatformApi.postMessageToJavaScript$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val javascriptInstanceIdArg = args[0] as String + val messageArg = args[1] as String + api.postMessageToJavaScript(javascriptInstanceIdArg, messageArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(MessagesPigeonUtils.wrapError(error)) + } else { + reply.reply(MessagesPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } } } } diff --git a/packages/javascript_android/android/src/main/kotlin/com/magnificsoftware/javascript_android/ProxyApiRegistrar.kt b/packages/javascript_android/android/src/main/kotlin/com/magnificsoftware/javascript_android/ProxyApiRegistrar.kt index a358c46..3b5776c 100644 --- a/packages/javascript_android/android/src/main/kotlin/com/magnificsoftware/javascript_android/ProxyApiRegistrar.kt +++ b/packages/javascript_android/android/src/main/kotlin/com/magnificsoftware/javascript_android/ProxyApiRegistrar.kt @@ -11,6 +11,10 @@ class ProxyApiRegistrar(private var context: Context, binaryMessenger: BinaryMes return JavaScriptAndroidConsoleMessageHandlerProxyApi(this) } + override fun getPigeonApiJavaScriptAndroidMessagePortHandler(): PigeonApiJavaScriptAndroidMessagePortHandler { + return JavaScriptAndroidMessagePortHandlerProxyApi(this) + } + fun setContext(context: Context) { this.context = context } diff --git a/packages/javascript_android/lib/javascript_android.dart b/packages/javascript_android/lib/javascript_android.dart index 1a36378..ce22a42 100644 --- a/packages/javascript_android/lib/javascript_android.dart +++ b/packages/javascript_android/lib/javascript_android.dart @@ -6,10 +6,12 @@ import 'package:javascript_platform_interface/javascript_platform_interface.dart import 'package:logging/logging.dart'; import 'exception.dart'; -import 'src/parse.dart'; import 'src/host/state.dart'; +import 'src/host/transport.dart'; import 'src/pigeons/messages.pigeon.dart'; +export 'src/host/transport.dart' show JavaScriptAndroidMessagingTransport; + final _logger = Logger('JavaScriptAndroid'); /// The Android implementation of [JavaScriptPlatform]. @@ -18,6 +20,25 @@ class JavaScriptAndroid extends JavaScriptPlatform { @visibleForTesting final pigeonPlatformApi = JavaScriptAndroidPlatformApi(); + /// Transports that must not be selected when a new environment starts. + /// + /// Lets tests exercise the fallbacks on a device that supports every + /// transport. + @visibleForTesting + static Set debugDisabledTransports = {}; + + static final _selectedTransports = + {}; + + /// The transport selected for the environment [javaScriptInstanceId], or + /// null if there is no such environment. + @visibleForTesting + static JavaScriptAndroidMessagingTransport? debugTransportFor( + String javaScriptInstanceId, + ) { + return _selectedTransports[javaScriptInstanceId]; + } + /// Registers this class as the default instance of [JavaScriptPlatform] static void registerWith() { JavaScriptPlatform.instance = JavaScriptAndroid(); @@ -75,6 +96,19 @@ class JavaScriptAndroid extends JavaScriptPlatform { final _consoleMessageHandler = {}; + final _messagePortHandler = {}; + + void _onPortMessage(String javaScriptInstanceId, String message) { + final state = _activeEngineHostState[javaScriptInstanceId]; + if (state == null) { + _logger.warning( + 'Dropping a message for unknown JavaScriptAndroid instance $javaScriptInstanceId', + ); + return; + } + state.onPortMessage(message); + } + @override Future startNewJavaScriptEnvironment( String javaScriptInstanceId, { @@ -85,8 +119,60 @@ class JavaScriptAndroid extends JavaScriptPlatform { (api) => api.startJavaScriptEnvironment(javaScriptInstanceId), ); - final didSetConsoleMessageHandler = await _usePlatform( - (api) { + try { + await _startTransportAndState( + javaScriptInstanceId, + messageListenerInterval, + implementJsSetTimeout, + ); + } catch (_) { + _activeEngineHostState.remove(javaScriptInstanceId)?.dispose(); + _selectedTransports.remove(javaScriptInstanceId); + _consoleMessageHandler.remove(javaScriptInstanceId); + _messagePortHandler.remove(javaScriptInstanceId); + try { + // Do not leak the native isolate and message port. + await _usePlatform((api) => api.dispose(javaScriptInstanceId)); + } catch (e, s) { + _logger.warning('Failed to dispose $javaScriptInstanceId', e, s); + } + rethrow; + } + } + + Future _startTransportAndState( + String javaScriptInstanceId, + Duration messageListenerInterval, + bool implementJsSetTimeout, + ) async { + // Transport selection order: message port, console messaging, polling. + var transport = JavaScriptAndroidMessagingTransport.polling; + + if (!debugDisabledTransports + .contains(JavaScriptAndroidMessagingTransport.messagePort)) { + final didSetMessagePortHandler = await _usePlatform((api) { + final handler = JavaScriptAndroidMessagePortHandler( + onMessage: (_, String message) { + _onPortMessage(javaScriptInstanceId, message); + }, + ); + _messagePortHandler[javaScriptInstanceId] = handler; + return api.setJavaScriptMessagePortHandler( + javaScriptInstanceId, + handler.pigeon_instanceManager.getIdentifier(handler)!, + ); + }); + if (didSetMessagePortHandler) { + transport = JavaScriptAndroidMessagingTransport.messagePort; + } else { + _messagePortHandler.remove(javaScriptInstanceId); + } + } + + if (transport == JavaScriptAndroidMessagingTransport.polling && + !debugDisabledTransports + .contains(JavaScriptAndroidMessagingTransport.consoleMessaging)) { + final didSetConsoleMessageHandler = await _usePlatform((api) { final handler = JavaScriptAndroidConsoleMessageHandler( onMessage: (_, String message) async { _onConsoleMessage(javaScriptInstanceId, message); @@ -99,10 +185,16 @@ class JavaScriptAndroid extends JavaScriptPlatform { javaScriptInstanceId, handler.pigeon_instanceManager.getIdentifier(handler)!, ); - }, - ); + }); + if (didSetConsoleMessageHandler) { + transport = JavaScriptAndroidMessagingTransport.consoleMessaging; + } else { + _consoleMessageHandler.remove(javaScriptInstanceId); + } + } - _logger.config('useConsoleMessagingHack: $didSetConsoleMessageHandler'); + _logger.config('messaging transport: ${transport.name}'); + _selectedTransports[javaScriptInstanceId] = transport; final engineHostState = await EngineHostState.create( engineId: javaScriptInstanceId, @@ -111,7 +203,12 @@ class JavaScriptAndroid extends JavaScriptPlatform { runJavaScript: (javaScript) { return runJavaScriptReturningResult(javaScriptInstanceId, javaScript); }, - useConsoleMessagingHack: didSetConsoleMessageHandler, + transport: transport, + postMessageToJavaScript: (message) { + return _usePlatform( + (api) => api.postMessageToJavaScript(javaScriptInstanceId, message), + ); + }, ); _activeEngineHostState[javaScriptInstanceId] = engineHostState; } @@ -131,7 +228,7 @@ class JavaScriptAndroid extends JavaScriptPlatform { String javaScriptChannelName, ) async { final engineState = _requireEngineState(javaScriptInstanceId); - engineState.removeChannel(javaScriptChannelName); + await engineState.removeChannel(javaScriptChannelName); } @override @@ -140,6 +237,8 @@ class JavaScriptAndroid extends JavaScriptPlatform { engineState.dispose(); _activeEngineHostState.remove(javaScriptInstanceId); _consoleMessageHandler.remove(javaScriptInstanceId); + _messagePortHandler.remove(javaScriptInstanceId); + _selectedTransports.remove(javaScriptInstanceId); return _usePlatform((api) => api.dispose(javaScriptInstanceId)); } @@ -155,7 +254,9 @@ class JavaScriptAndroid extends JavaScriptPlatform { ); }); - return parseValue(result); + // The engine returns the script's string result verbatim, and an empty + // string for anything that is not a string. Nothing is decoded here. + return result ?? ''; } @override @@ -170,7 +271,9 @@ class JavaScriptAndroid extends JavaScriptPlatform { ); }); - return parseValue(result); + // The engine returns the script's string result verbatim, and an empty + // string for anything that is not a string. Nothing is decoded here. + return result ?? ''; } @override diff --git a/packages/javascript_android/lib/src/host/scripts.dart b/packages/javascript_android/lib/src/host/scripts.dart index 77712e3..a930550 100644 --- a/packages/javascript_android/lib/src/host/scripts.dart +++ b/packages/javascript_android/lib/src/host/scripts.dart @@ -1,12 +1,11 @@ part of 'state.dart'; // ignore: non_constant_identifier_names -String _MESSAGING_SCRIPT(bool useConsoleMessagingHack) => ''' +String _MESSAGING_SCRIPT(JavaScriptAndroidMessagingTransport transport) => ''' class HostMessenger { constructor(name) { this.channelName = name; this.pendingMessages = []; - this.id = 0; } getPendingMessages() { @@ -17,6 +16,12 @@ class HostMessenger { this.pendingMessages = this.pendingMessages.filter(m => m.id !== id); } + rejectAll(error) { + const pending = this.pendingMessages; + this.pendingMessages = []; + pending.forEach(m => m.reject(error)); + } + resolveById(id, response) { let message = this.pendingMessages.find(m => m.id === id); if (message) { @@ -42,14 +47,17 @@ class HostMessenger { throw new Error(`Message must be a string, got \${typeof message}`); } let promise = new Promise((resolve, reject) => { - const nextId = this.id++; + // One counter shared by all messengers so ids are never reused when a channel is re-added. + const nextId = globalThis.JavaScriptAndroid.nextMessageId++; this.pendingMessages.push({ id: nextId, message, resolve, reject }); - if (globalThis.JavaScriptAndroid.useConsoleMessagingHack) { + if (globalThis.JavaScriptAndroid.usePort) { + globalThis.JavaScriptAndroid.sendToHost(JSON.stringify({ channelName: this.channelName, id: nextId, message })); + } else if (globalThis.JavaScriptAndroid.useConsoleMessagingHack) { this.postMessageForConsoleMessagingHack(nextId, message); } }); @@ -70,8 +78,36 @@ globalThis.sendMessage = function(channelName, message) { globalThis.JavaScriptAndroid = { HostMessengerRegisteredChannels: {}, + nextMessageId: 0, /// Using console.warn because it may be the least used console method. - useConsoleMessagingHack: $useConsoleMessagingHack ? console.warn : false, + useConsoleMessagingHack: ${transport == JavaScriptAndroidMessagingTransport.consoleMessaging} ? console.warn : false, + usePort: ${transport == JavaScriptAndroidMessagingTransport.messagePort}, + port: null, + portQueue: [], + /// Posts a string to the host, queueing it until the message port is ready. + sendToHost: function(raw) { + const self = globalThis.JavaScriptAndroid; + if (self.port) { + self.port.postMessage(raw); + } else { + self.portQueue.push(raw); + } + }, + /// Handles `{channelName, id, ok, message | error}` replies from the host. + onHostMessage: function(raw) { + try { + const reply = JSON.parse(raw); + const channel = globalThis.JavaScriptAndroid.HostMessengerRegisteredChannels[reply.channelName]; + if (!channel) return; + if (reply.ok) { + channel.resolveById(reply.id, reply.message); + } else { + channel.rejectById(reply.id, new Error(reply.error)); + } + } catch (e) { + console.error('Failed to handle a message from the host', e); + } + }, getPendingMessages: function(channelName) { try { const registeredChannels = globalThis.JavaScriptAndroid.HostMessengerRegisteredChannels; @@ -92,6 +128,21 @@ if (globalThis.JavaScriptAndroid.useConsoleMessagingHack) { /// Forward warning level messages to console.info console.warn = function (...args) { console.info(`WARNING: `, ...args);} } + +if (globalThis.JavaScriptAndroid.usePort) { + android.getNamedPort("javascript_android_host").then(function (port) { + const self = globalThis.JavaScriptAndroid; + port.onmessage = function (e) { self.onHostMessage(e.data); }; + self.port = port; + // Flush messages posted before the port was ready. + const queued = self.portQueue; + self.portQueue = []; + queued.forEach(function (raw) { port.postMessage(raw); }); + }, function (e) { + console.error('Failed to open the host message port', e); + }); +} +void 0; '''; const String _TIMEOUT_SCRIPT = r''' diff --git a/packages/javascript_android/lib/src/host/state.dart b/packages/javascript_android/lib/src/host/state.dart index 8c9790f..34414b2 100644 --- a/packages/javascript_android/lib/src/host/state.dart +++ b/packages/javascript_android/lib/src/host/state.dart @@ -4,17 +4,21 @@ import 'package:logging/logging.dart'; import 'package:javascript_platform_interface/javascript_platform_interface.dart'; +import 'transport.dart'; + part 'scripts.dart'; typedef RunJavascriptCallback = Future Function(String javaScript); +typedef PostMessageToJavaScriptCallback = Future Function(String message); class EngineHostState { EngineHostState._({ required this.messageListenerInterval, required this.engineId, required RunJavascriptCallback runJavaScript, - required this.useConsoleMessagingHack, - }) { + required this.transport, + PostMessageToJavaScriptCallback? postMessageToJavaScript, + }) : _postMessageToJavaScript = postMessageToJavaScript { _runJavaScript = (String javascript) { if (_isDisposed) return Future.value(null); return runJavaScript(javascript); @@ -28,22 +32,31 @@ class EngineHostState { required Duration messageListenerInterval, bool implementJsSetTimeout = true, required RunJavascriptCallback runJavaScript, - // A hack that uses console message handler to receive new channel messages - // for more efficient communication because jetpack javascript engine doesn't - // have an api to set javascript interface in javascript environment - required bool useConsoleMessagingHack, + // How JavaScript delivers channel messages to Dart. See + // [JavaScriptAndroidMessagingTransport]. + required JavaScriptAndroidMessagingTransport transport, + // Posts a string to JavaScript over the message port. Required for + // [JavaScriptAndroidMessagingTransport.messagePort]. + PostMessageToJavaScriptCallback? postMessageToJavaScript, }) async { final state = EngineHostState._( messageListenerInterval: messageListenerInterval, engineId: engineId, runJavaScript: runJavaScript, - useConsoleMessagingHack: useConsoleMessagingHack, + transport: transport, + postMessageToJavaScript: postMessageToJavaScript, ); - await state._runJavaScript(_MESSAGING_SCRIPT(useConsoleMessagingHack)); - if (implementJsSetTimeout) { - await state._runJavaScript(_TIMEOUT_SCRIPT); - await state._setupJsSetTimeoutMessaging(); + try { + await state._runJavaScript(_MESSAGING_SCRIPT(transport)); + if (implementJsSetTimeout) { + await state._runJavaScript(_TIMEOUT_SCRIPT); + await state._setupJsSetTimeoutMessaging(); + } + } catch (_) { + // Stops the polling timer that addChannel may have started. + state.dispose(); + rethrow; } return state; @@ -51,7 +64,8 @@ class EngineHostState { final String engineId; final Duration messageListenerInterval; - final bool useConsoleMessagingHack; + final JavaScriptAndroidMessagingTransport transport; + final PostMessageToJavaScriptCallback? _postMessageToJavaScript; late final RunJavascriptCallback _runJavaScript; @@ -65,7 +79,7 @@ class EngineHostState { if (_timer != null) { return; } - if (useConsoleMessagingHack) return; + if (transport != JavaScriptAndroidMessagingTransport.polling) return; _startTimer(); } @@ -81,11 +95,11 @@ class EngineHostState { return '"$channelName": $_envExport.getPendingMessages("$channelName")'; }).join(','); - final messages = - await _runJavaScript('JSON.stringify({$channelPendingMessages})') - as Map; + final result = + await _runJavaScript('JSON.stringify({$channelPendingMessages})'); - return messages; + // Results are plain strings, decode the JSON ourselves. + return (json.decode(result! as String) as Map).cast(); } Future> getPendingMessagesBy( @@ -96,11 +110,11 @@ class EngineHostState { return '"$channelName": $_envExport.getPendingMessages("$channelName").filter(it => it.id == $id)'; }).join(','); - final messages = - await _runJavaScript('JSON.stringify({$channelPendingMessages})') - as Map; + final result = + await _runJavaScript('JSON.stringify({$channelPendingMessages})'); - return messages; + // Results are plain strings, decode the JSON ourselves. + return (json.decode(result! as String) as Map).cast(); } final Map> _processingMessages = {}; @@ -152,6 +166,55 @@ class EngineHostState { } } + /// Handles a raw `{channelName, id, message}` JSON string that JavaScript + /// posted over the message port. + void onPortMessage(String raw) { + if (_isDisposed) return; + String channelName; + int id; + String message; + try { + final payload = json.decode(raw) as Map; + channelName = payload['channelName'] as String; + id = (payload['id'] as num).toInt(); + message = payload['message'] as String; + } catch (e, s) { + _logger.severe( + 'Invalid message received over the message port: $raw', e, s); + return; + } + final channel = _enabledChannels[channelName]; + if (channel == null) { + _logger.severe('Channel $channelName not found'); + _replyOverPort(channelName, id, + ok: false, error: 'Channel $channelName not found'); + return; + } + _processMessage(channel, message, channelName, id); + } + + Future _replyOverPort( + String channelName, + int id, { + required bool ok, + String? message, + String? error, + }) async { + final post = _postMessageToJavaScript; + if (_isDisposed || post == null) return; + try { + await post(json.encode({ + 'channelName': channelName, + 'id': id, + 'ok': ok, + if (ok) 'message': message else 'error': error, + })); + } catch (e, s) { + _logger.severe( + 'Error replying to message $id of channel $channelName', e, s); + } + } + Future _processMessage( JavaScriptChannelParams channel, String message, @@ -167,24 +230,27 @@ class EngineHostState { JavaScriptMessage(message: message), ); final reply = response.message; - String encodedMessage = 'null'; - if (reply != null) { - encodedMessage = reply; - } + // A null reply resolves the JavaScript promise with `null`. _logger.finest( - 'Resolved message by id $id for channel $channelName: $encodedMessage', - ); - evaluationResult = _runJavaScript( - '$_envExport.HostMessengerRegisteredChannels["$channelName"].resolveById($id, ${json.encode(encodedMessage)})', + 'Resolved message by id $id for channel $channelName: $reply', ); + evaluationResult = + transport == JavaScriptAndroidMessagingTransport.messagePort + ? _replyOverPort(channelName, id, ok: true, message: reply) + : _runJavaScript( + '$_envExport.HostMessengerRegisteredChannels["$channelName"].resolveById($id, ${json.encode(reply)})', + ); } catch (e) { final encodedMessage = e.toString(); _logger.finest( 'Rejected message by id $id for channel $channelName: $encodedMessage', ); - evaluationResult = _runJavaScript( - '$_envExport.HostMessengerRegisteredChannels["$channelName"].rejectById($id, new Error(${json.encode(encodedMessage)}))', - ); + evaluationResult = transport == + JavaScriptAndroidMessagingTransport.messagePort + ? _replyOverPort(channelName, id, ok: false, error: encodedMessage) + : _runJavaScript( + '$_envExport.HostMessengerRegisteredChannels["$channelName"].rejectById($id, new Error(${json.encode(encodedMessage)}))', + ); } try { await evaluationResult; @@ -216,13 +282,21 @@ class EngineHostState { } } - void removeChannel(String channelName) { + Future removeChannel(String channelName) async { _enabledChannels.remove(channelName); final events = _processingMessages.remove(channelName); events?.clear(); if (_enabledChannels.isEmpty) { _removeTimer(); } + // Reject in-flight sends and make later `sendMessage` calls fail in + // JavaScript instead of leaving their promises pending forever. + try { + await _runJavaScript( + 'var __ch = $_envExport.HostMessengerRegisteredChannels["$channelName"]; if (__ch) { __ch.rejectAll(new Error("Channel $channelName was removed")); } delete $_envExport.HostMessengerRegisteredChannels["$channelName"]; delete globalThis["$channelName"]; void 0;'); + } catch (e, s) { + _logger.severe('Error removing channel $channelName', e, s); + } } bool _isDisposed = false; diff --git a/packages/javascript_android/lib/src/host/transport.dart b/packages/javascript_android/lib/src/host/transport.dart new file mode 100644 index 0000000..4dee08d --- /dev/null +++ b/packages/javascript_android/lib/src/host/transport.dart @@ -0,0 +1,14 @@ +/// The mechanism JavaScript uses to deliver channel messages to Dart. +/// +/// Transports are selected in declaration order: the first one the device +/// supports (and that is not disabled for testing) wins. +enum JavaScriptAndroidMessagingTransport { + /// `androidx.javascriptengine` message ports (`JS_FEATURE_MESSAGE_PORTS`). + messagePort, + + /// `console.warn` notifications (`JS_FEATURE_CONSOLE_MESSAGING`). + consoleMessaging, + + /// Periodically polling the JavaScript environment for pending messages. + polling, +} diff --git a/packages/javascript_android/lib/src/parse.dart b/packages/javascript_android/lib/src/parse.dart deleted file mode 100644 index dbace7d..0000000 --- a/packages/javascript_android/lib/src/parse.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'dart:convert'; - -Object? parseValue(String? result) { - if (result == null) return null; - if (result == 'null') return null; - if (result == 'true') return true; - if (result == 'false') return false; - final numResult = num.tryParse(result); - if (numResult != null) return numResult; - try { - return json.decode(result); - } catch (_) { - return result; - } -} diff --git a/packages/javascript_android/lib/src/pigeons/messages.pigeon.dart b/packages/javascript_android/lib/src/pigeons/messages.pigeon.dart index ea6008d..7abf7af 100644 --- a/packages/javascript_android/lib/src/pigeons/messages.pigeon.dart +++ b/packages/javascript_android/lib/src/pigeons/messages.pigeon.dart @@ -64,9 +64,17 @@ class PigeonOverrides { String message, ) onMessage})? javaScriptAndroidConsoleMessageHandler_new; + /// Overrides [JavaScriptAndroidMessagePortHandler.new]. + static JavaScriptAndroidMessagePortHandler Function( + {required void Function( + JavaScriptAndroidMessagePortHandler pigeon_instance, + String message, + ) onMessage})? javaScriptAndroidMessagePortHandler_new; + /// Sets all overridden ProxyApi class members to null. static void pigeon_reset() { javaScriptAndroidConsoleMessageHandler_new = null; + javaScriptAndroidMessagePortHandler_new = null; } } @@ -181,6 +189,8 @@ class PigeonInstanceManager { instanceManager: instanceManager); JavaScriptAndroidConsoleMessageHandler.pigeon_setUpMessageHandlers( pigeon_instanceManager: instanceManager); + JavaScriptAndroidMessagePortHandler.pigeon_setUpMessageHandlers( + pigeon_instanceManager: instanceManager); return instanceManager; } @@ -590,6 +600,148 @@ class JavaScriptAndroidConsoleMessageHandler } } +class JavaScriptAndroidMessagePortHandler + extends PigeonInternalProxyApiBaseClass { + factory JavaScriptAndroidMessagePortHandler({ + BinaryMessenger? pigeon_binaryMessenger, + PigeonInstanceManager? pigeon_instanceManager, + required void Function( + JavaScriptAndroidMessagePortHandler pigeon_instance, + String message, + ) onMessage, + }) { + if (PigeonOverrides.javaScriptAndroidMessagePortHandler_new != null) { + return PigeonOverrides.javaScriptAndroidMessagePortHandler_new!( + onMessage: onMessage); + } + return JavaScriptAndroidMessagePortHandler.pigeon_new( + pigeon_binaryMessenger: pigeon_binaryMessenger, + pigeon_instanceManager: pigeon_instanceManager, + onMessage: onMessage, + ); + } + + @protected + JavaScriptAndroidMessagePortHandler.pigeon_new({ + super.pigeon_binaryMessenger, + super.pigeon_instanceManager, + required this.onMessage, + }) { + final int pigeonVar_instanceIdentifier = + pigeon_instanceManager.addDartCreatedInstance(this); + final _PigeonInternalProxyApiBaseCodec pigeonChannelCodec = + _pigeonVar_codecJavaScriptAndroidMessagePortHandler; + final BinaryMessenger? pigeonVar_binaryMessenger = pigeon_binaryMessenger; + const pigeonVar_channelName = + 'dev.flutter.pigeon.javascript_android.JavaScriptAndroidMessagePortHandler.pigeon_defaultConstructor'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = + pigeonVar_channel.send([pigeonVar_instanceIdentifier]); + () async { + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); + }(); + } + + /// Constructs [JavaScriptAndroidMessagePortHandler] without creating the associated native object. + /// + /// This should only be used by subclasses created by this library or to + /// create copies for an [PigeonInstanceManager]. + @protected + JavaScriptAndroidMessagePortHandler.pigeon_detached({ + super.pigeon_binaryMessenger, + super.pigeon_instanceManager, + required this.onMessage, + }); + + late final _PigeonInternalProxyApiBaseCodec + _pigeonVar_codecJavaScriptAndroidMessagePortHandler = + _PigeonInternalProxyApiBaseCodec(pigeon_instanceManager); + + /// Handles string messages posted by JavaScript over the message port. + /// + /// For the associated Native object to be automatically garbage collected, + /// it is required that the implementation of this `Function` doesn't have a + /// strong reference to the encapsulating class instance. When this `Function` + /// references a non-local variable, it is strongly recommended to access it + /// with a `WeakReference`: + /// + /// ```dart + /// final WeakReference weakMyVariable = WeakReference(myVariable); + /// final JavaScriptAndroidMessagePortHandler instance = JavaScriptAndroidMessagePortHandler( + /// onMessage: (JavaScriptAndroidMessagePortHandler pigeon_instance, ...) { + /// print(weakMyVariable?.target); + /// }, + /// ); + /// ``` + /// + /// Alternatively, [PigeonInstanceManager.removeWeakReference] can be used to + /// release the associated Native object manually. + final void Function( + JavaScriptAndroidMessagePortHandler pigeon_instance, + String message, + ) onMessage; + + static void pigeon_setUpMessageHandlers({ + bool pigeon_clearHandlers = false, + BinaryMessenger? pigeon_binaryMessenger, + PigeonInstanceManager? pigeon_instanceManager, + void Function( + JavaScriptAndroidMessagePortHandler pigeon_instance, + String message, + )? onMessage, + }) { + final _PigeonInternalProxyApiBaseCodec pigeonChannelCodec = + _PigeonInternalProxyApiBaseCodec( + pigeon_instanceManager ?? PigeonInstanceManager.instance); + final BinaryMessenger? binaryMessenger = pigeon_binaryMessenger; + { + final pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.javascript_android.JavaScriptAndroidMessagePortHandler.onMessage', + pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (pigeon_clearHandlers) { + pigeonVar_channel.setMessageHandler(null); + } else { + pigeonVar_channel.setMessageHandler((Object? message) async { + final List args = message! as List; + final JavaScriptAndroidMessagePortHandler arg_pigeon_instance = + args[0]! as JavaScriptAndroidMessagePortHandler; + final String arg_message = args[1]! as String; + try { + (onMessage ?? arg_pigeon_instance.onMessage) + .call(arg_pigeon_instance, arg_message); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + } + + @override + JavaScriptAndroidMessagePortHandler pigeon_copy() { + return JavaScriptAndroidMessagePortHandler.pigeon_detached( + pigeon_binaryMessenger: pigeon_binaryMessenger, + pigeon_instanceManager: pigeon_instanceManager, + onMessage: onMessage, + ); + } +} + class JavaScriptAndroidPlatformApi { /// Constructor for [JavaScriptAndroidPlatformApi]. The [binaryMessenger] named argument is /// available for dependency injection. If it is left null, the default @@ -729,4 +881,49 @@ class JavaScriptAndroidPlatformApi { ); return pigeonVar_replyValue! as bool; } + + /// Opens a message port named `javascript_android_host` on the isolate and + /// forwards the string messages JavaScript posts to [handler]. Returns false + /// when message ports are unsupported by the device's WebView. + Future setJavaScriptMessagePortHandler(String javascriptInstanceId, + int messagePortHandlerInstanceIdentifier) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.javascript_android.JavaScriptAndroidPlatformApi.setJavaScriptMessagePortHandler$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [javascriptInstanceId, messagePortHandlerInstanceIdentifier]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ); + return pigeonVar_replyValue! as bool; + } + + /// Posts a string message to JavaScript over the message port. + Future postMessageToJavaScript( + String javascriptInstanceId, String message) async { + final pigeonVar_channelName = + 'dev.flutter.pigeon.javascript_android.JavaScriptAndroidPlatformApi.postMessageToJavaScript$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = + pigeonVar_channel.send([javascriptInstanceId, message]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); + } } diff --git a/packages/javascript_android/pigeons/schema.dart b/packages/javascript_android/pigeons/schema.dart index 36aa502..9ae077c 100644 --- a/packages/javascript_android/pigeons/schema.dart +++ b/packages/javascript_android/pigeons/schema.dart @@ -22,6 +22,15 @@ abstract class JavaScriptAndroidConsoleMessageHandler { late void Function(String message) onMessage; } +@ProxyApi() +abstract class JavaScriptAndroidMessagePortHandler { + // ignore: avoid_unused_constructor_parameters + JavaScriptAndroidMessagePortHandler(); + + /// Handles string messages posted by JavaScript over the message port. + late void Function(String message) onMessage; +} + @HostApi() abstract class JavaScriptAndroidPlatformApi { @asyncCallback @@ -45,4 +54,17 @@ abstract class JavaScriptAndroidPlatformApi { String javascriptInstanceId, int mJavaScriptAndroidConsoleMessageHandlerInstanceIdentifier, ); + + /// Opens a message port named `javascript_android_host` on the isolate and + /// forwards the string messages JavaScript posts to [handler]. Returns false + /// when message ports are unsupported by the device's WebView. + @asyncCallback + bool setJavaScriptMessagePortHandler( + String javascriptInstanceId, + int messagePortHandlerInstanceIdentifier, + ); + + /// Posts a string message to JavaScript over the message port. + @asyncCallback + void postMessageToJavaScript(String javascriptInstanceId, String message); } diff --git a/packages/javascript_android/pubspec.yaml b/packages/javascript_android/pubspec.yaml index 7bb62c2..5f863d7 100644 --- a/packages/javascript_android/pubspec.yaml +++ b/packages/javascript_android/pubspec.yaml @@ -2,11 +2,11 @@ name: javascript_android description: Android implementation of the javascript_flutter plugin repository: https://github.com/predatorx7/javascript_flutter/tree/main/packages/javascript_android issue_tracker: https://github.com/predatorx7/javascript_flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+javascript_android%22 -version: 2.1.0 +version: 2.2.0 environment: sdk: ^3.6.0 - flutter: ">=3.0.0" + flutter: ">=3.44.0" flutter: plugin: diff --git a/packages/javascript_android/test/javascript_android_test.dart b/packages/javascript_android/test/javascript_android_test.dart index 47e02cb..e174950 100644 --- a/packages/javascript_android/test/javascript_android_test.dart +++ b/packages/javascript_android/test/javascript_android_test.dart @@ -1,7 +1,111 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:javascript_android/exception.dart'; import 'package:javascript_android/javascript_android.dart'; +import 'package:javascript_android/src/pigeons/messages.pigeon.dart'; import 'package:javascript_platform_interface/javascript_platform_interface.dart'; +const _prefix = 'dev.flutter.pigeon.javascript_android'; +const _api = '$_prefix.JavaScriptAndroidPlatformApi'; + +/// Fakes the native side of the pigeon host API. +class FakeHost { + FakeHost({this.supportsPorts = true, this.supportsConsole = true}); + + bool supportsPorts; + bool supportsConsole; + + /// Every call received, as `method -> list of argument lists`. + final calls = >>{}; + + /// Result of `runJavaScriptReturningResult`; may throw [PlatformException]. + FutureOr Function(String script) onRun = (_) => ''; + + /// Identifier of the handler registered for each transport. + int? portHandlerId; + int? consoleHandlerId; + + final scripts = []; + + static final _codec = const StandardMessageCodec(); + + List> callsOf(String method) => calls[method] ?? []; + + void install() { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + for (final method in [ + 'startJavaScriptEnvironment', + 'dispose', + 'runJavaScriptReturningResult', + 'runJavaScriptFromFileReturningResult', + 'setIsInspectable', + 'setJavaScriptConsoleMessageHandler', + 'setJavaScriptMessagePortHandler', + 'postMessageToJavaScript', + ]) { + messenger.setMockMessageHandler('$_api.$method', (data) async { + final args = (_codec.decodeMessage(data) as List).cast(); + calls.putIfAbsent(method, () => []).add(args); + try { + final result = await _handle(method, args); + return _codec.encodeMessage([result]); + } on PlatformException catch (e) { + return _codec.encodeMessage([e.code, e.message, e.details]); + } + }); + } + for (final name in ['ConsoleMessage', 'MessagePort']) { + messenger.setMockMessageHandler( + '$_prefix.JavaScriptAndroid${name}Handler.pigeon_defaultConstructor', + (data) async => _codec.encodeMessage([null]), + ); + } + } + + void uninstall() { + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + for (final key in calls.keys) { + messenger.setMockMessageHandler('$_api.$key', null); + } + } + + Future _handle(String method, List args) async { + switch (method) { + case 'runJavaScriptReturningResult': + final script = args[1]! as String; + scripts.add(script); + return onRun(script); + case 'runJavaScriptFromFileReturningResult': + return onRun('file:${args[1]}'); + case 'setJavaScriptMessagePortHandler': + if (supportsPorts) portHandlerId = args[1]! as int; + return supportsPorts; + case 'setJavaScriptConsoleMessageHandler': + if (supportsConsole) consoleHandlerId = args[1]! as int; + return supportsConsole; + default: + return null; + } + } + + JavaScriptAndroidMessagePortHandler get portHandler => + PigeonInstanceManager.instance + .getInstanceWithWeakReference( + portHandlerId!)!; + + JavaScriptAndroidConsoleMessageHandler get consoleHandler => + PigeonInstanceManager.instance + .getInstanceWithWeakReference( + consoleHandlerId!)!; +} + +Future _pump() => Future.delayed(const Duration(milliseconds: 20)); + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -11,4 +115,542 @@ void main() { expect(JavaScriptPlatform.instance, isA()); }); }); + + late FakeHost host; + late JavaScriptAndroid platform; + var counter = 0; + late String id; + final startedIds = []; + + setUp(() { + host = FakeHost(); + host.install(); + platform = JavaScriptAndroid(); + id = 'test-${counter++}'; + }); + + tearDown(() async { + // Stop polling timers so they cannot leak into the next test's fake host. + for (final started in startedIds) { + try { + await platform.dispose(started); + } catch (_) {} + } + startedIds.clear(); + JavaScriptAndroid.debugDisabledTransports = {}; + host.uninstall(); + }); + + Future start([String? instanceId]) async { + final target = instanceId ?? id; + startedIds.add(target); + await platform.startNewJavaScriptEnvironment(target); + } + + group('results are returned verbatim', () { + for (final value in [ + '42', + '{"a":1}', + '', + 'true', + 'null', + 'false', + '1.5', + '[1,2]', + ' padded ', + 'plain text', + ]) { + test('"$value" stays a String', () async { + await start(); + host.onRun = (_) => value; + final result = await platform.runJavaScriptReturningResult(id, 'x'); + expect(result, isA()); + expect(result, value); + }); + } + + test('a null host result becomes an empty string', () async { + await start(); + host.onRun = (_) => null; + expect(await platform.runJavaScriptReturningResult(id, 'x'), ''); + }); + + test('file results are returned verbatim too', () async { + await start(); + host.onRun = (_) => '{"ok":true}'; + expect( + await platform.runJavaScriptFromFileReturningResult(id, '/a.js'), + '{"ok":true}', + ); + host.onRun = (_) => null; + expect( + await platform.runJavaScriptFromFileReturningResult(id, '/a.js'), + '', + ); + }); + }); + + group('transport selection', () { + test('prefers message ports', () async { + await start(); + expect( + JavaScriptAndroid.debugTransportFor(id), + JavaScriptAndroidMessagingTransport.messagePort, + ); + expect(host.callsOf('setJavaScriptConsoleMessageHandler'), isEmpty); + }); + + test('falls back to console messaging when ports are unsupported', + () async { + host.supportsPorts = false; + await start(); + expect( + JavaScriptAndroid.debugTransportFor(id), + JavaScriptAndroidMessagingTransport.consoleMessaging, + ); + }); + + test('falls back to polling when nothing is supported', () async { + host.supportsPorts = false; + host.supportsConsole = false; + await start(); + expect( + JavaScriptAndroid.debugTransportFor(id), + JavaScriptAndroidMessagingTransport.polling, + ); + }); + + test('debugDisabledTransports skips transports', () async { + JavaScriptAndroid.debugDisabledTransports = { + JavaScriptAndroidMessagingTransport.messagePort, + }; + await start(); + expect( + JavaScriptAndroid.debugTransportFor(id), + JavaScriptAndroidMessagingTransport.consoleMessaging, + ); + expect(host.callsOf('setJavaScriptMessagePortHandler'), isEmpty); + + final id2 = '$id-b'; + JavaScriptAndroid.debugDisabledTransports = { + JavaScriptAndroidMessagingTransport.messagePort, + JavaScriptAndroidMessagingTransport.consoleMessaging, + }; + await start(id2); + expect( + JavaScriptAndroid.debugTransportFor(id2), + JavaScriptAndroidMessagingTransport.polling, + ); + }); + + test('the transport is forgotten on dispose', () async { + await start(); + await platform.dispose(id); + expect(JavaScriptAndroid.debugTransportFor(id), isNull); + }); + + test('the port script is injected only for the port transport', () async { + await start(); + expect(host.scripts.first, contains('usePort: true')); + expect(host.scripts.first, contains('javascript_android_host')); + }); + }); + + group('message port routing', () { + test('routes a message to the channel and replies over the port', () async { + await start(); + final received = []; + await platform.addJavaScriptChannel( + id, + JavaScriptChannelParams( + name: 'Echo', + onMessageReceived: (message) { + received.add(message.message); + return JavaScriptReply(message: 'echo: ${message.message}'); + }, + ), + ); + + const body = '{"a": "b \\" c"}\n'; + host.portHandler.onMessage( + host.portHandler, + jsonEncode({'channelName': 'Echo', 'id': 7, 'message': body}), + ); + await _pump(); + + expect(received, [body]); + final posts = host.callsOf('postMessageToJavaScript'); + // The SetTimeout channel does not post anything on its own. + expect(posts, hasLength(1)); + expect(posts.single[0], id); + expect(jsonDecode(posts.single[1]! as String), { + 'channelName': 'Echo', + 'id': 7, + 'ok': true, + 'message': 'echo: $body', + }); + }); + + test('a null reply message is delivered as a real null', () async { + await start(); + await platform.addJavaScriptChannel( + id, + JavaScriptChannelParams( + name: 'Nothing', + onMessageReceived: (_) => JavaScriptReply(message: null), + ), + ); + host.portHandler.onMessage( + host.portHandler, + jsonEncode({'channelName': 'Nothing', 'id': 1, 'message': 'x'}), + ); + await _pump(); + final reply = jsonDecode( + host.callsOf('postMessageToJavaScript').single[1]! as String, + ); + expect(reply['ok'], true); + expect(reply.containsKey('message'), isTrue); + expect(reply['message'], isNull); + }); + + test('message ids stay unique when a channel is re-added', () { + // Ids come from one counter shared by every HostMessenger. + return start().then((_) { + final script = host.scripts.first; + expect(script, contains('nextMessageId++')); + expect(script, isNot(contains('this.id'))); + }); + }); + + test('removing a channel rejects its in-flight sends', () async { + await start(); + await platform.addJavaScriptChannel( + id, + JavaScriptChannelParams( + name: 'Gone', + onMessageReceived: (_) => JavaScriptReply(message: ''), + ), + ); + host.scripts.clear(); + await platform.removeJavaScriptChannel(id, 'Gone'); + final script = host.scripts.single; + expect(script, contains('rejectAll(')); + expect(script, contains('Channel Gone was removed')); + expect( + script.indexOf('rejectAll('), + lessThan(script.indexOf('delete ')), + ); + }); + + test('a late reply after a channel is re-added keeps its own id', () async { + await start(); + final release = Completer(); + var calls = 0; + JavaScriptChannelParams params(String name) => JavaScriptChannelParams( + name: name, + onMessageReceived: (m) async { + calls++; + if (m.message == 'old') await release.future; + return JavaScriptReply(message: 'reply ${m.message}'); + }, + ); + await platform.addJavaScriptChannel(id, params('Re')); + host.portHandler.onMessage(host.portHandler, + jsonEncode({'channelName': 'Re', 'id': 5, 'message': 'old'})); + await _pump(); + await platform.removeJavaScriptChannel(id, 'Re'); + await platform.addJavaScriptChannel(id, params('Re')); + // The new messenger gets a fresh, never-used id from the shared counter. + host.portHandler.onMessage(host.portHandler, + jsonEncode({'channelName': 'Re', 'id': 6, 'message': 'new'})); + await _pump(); + release.complete(); + await _pump(); + + expect(calls, 2); + final replies = host + .callsOf('postMessageToJavaScript') + .map((c) => jsonDecode(c[1]! as String) as Map) + .toList(); + expect(replies.map((r) => r['id']).toSet(), {5, 6}); + expect(replies.firstWhere((r) => r['id'] == 6)['message'], 'reply new'); + }); + + test('a throwing handler rejects with the error string', () async { + await start(); + await platform.addJavaScriptChannel( + id, + JavaScriptChannelParams( + name: 'Bad', + onMessageReceived: (_) => throw StateError('nope'), + ), + ); + host.portHandler.onMessage( + host.portHandler, + jsonEncode({'channelName': 'Bad', 'id': 2, 'message': 'x'}), + ); + await _pump(); + final reply = jsonDecode( + host.callsOf('postMessageToJavaScript').single[1]! as String, + ); + expect(reply['ok'], false); + expect(reply['id'], 2); + expect(reply['error'], contains('nope')); + }); + + test('an unknown channel is rejected gracefully', () async { + await start(); + host.portHandler.onMessage( + host.portHandler, + jsonEncode({'channelName': 'Missing', 'id': 3, 'message': 'x'}), + ); + await _pump(); + final reply = jsonDecode( + host.callsOf('postMessageToJavaScript').single[1]! as String, + ); + expect(reply['ok'], false); + expect(reply['id'], 3); + expect(reply['error'], contains('Missing')); + }); + + test('malformed port messages are ignored', () async { + await start(); + host.portHandler.onMessage(host.portHandler, 'not json'); + host.portHandler.onMessage(host.portHandler, '{"id": 1}'); + await _pump(); + expect(host.callsOf('postMessageToJavaScript'), isEmpty); + }); + + test('the SetTimeout channel replies and schedules the callback', () async { + await start(); + host.portHandler.onMessage( + host.portHandler, + jsonEncode({ + 'channelName': 'SetTimeout', + 'id': 0, + 'message': jsonEncode({'timeoutIndex': '5', 'timeout': 10}), + }), + ); + await Future.delayed(const Duration(milliseconds: 80)); + expect( + jsonDecode( + host.callsOf('postMessageToJavaScript').single[1]! as String), + containsPair('message', ''), + ); + expect( + host.scripts + .any((s) => s.contains('__NATIVE_HOST_JS__setTimeoutCallbacks[5]')), + isTrue, + ); + }); + }); + + group('console messaging routing', () { + test('still routes console notifications', () async { + host.supportsPorts = false; + await start(); + final received = []; + await platform.addJavaScriptChannel( + id, + JavaScriptChannelParams( + name: 'Echo', + onMessageReceived: (message) { + received.add(message.message); + return JavaScriptReply(message: 'echo: ${message.message}'); + }, + ), + ); + host.onRun = (script) { + if (script.contains('getPendingMessages("Echo")')) { + return jsonEncode({ + 'Echo': [ + {'id': 4, 'message': 'hi'}, + ], + }); + } + return ''; + }; + + final notification = jsonEncode({ + 'tag': 'CONSOLE_MESSAGING_HACK', + 'id': 4, + 'channelName': 'Echo', + }); + host.consoleHandler.onMessage( + host.consoleHandler, + jsonEncode({'level': 16, 'message': notification}), + ); + await _pump(); + + expect(received, ['hi']); + expect(host.callsOf('postMessageToJavaScript'), isEmpty); + expect( + host.scripts.any( + (s) => s.contains('["Echo"].resolveById(4, "echo: hi")'), + ), + isTrue, + ); + }); + + test('ignores console messages that are not notifications', () async { + host.supportsPorts = false; + await start(); + final before = host.scripts.length; + host.consoleHandler.onMessage( + host.consoleHandler, + jsonEncode({'level': 16, 'message': 'something else'}), + ); + await _pump(); + expect(host.scripts.skip(before).toList(), isEmpty); + }); + }); + + group('start-up failures', () { + test('a failing start disposes the isolate and forgets the instance', + () async { + host.onRun = (script) { + if (script.contains('__NATIVE_HOST_JS__setTimeoutCount')) { + throw PlatformException(code: 'Weird'); + } + return ''; + }; + host.supportsPorts = false; + host.supportsConsole = false; + startedIds.add(id); + await expectLater( + platform.startNewJavaScriptEnvironment(id), + throwsA(isA()), + ); + expect(host.callsOf('dispose').single[0], id); + expect(JavaScriptAndroid.debugTransportFor(id), isNull); + // The polling timer must be stopped: no further polling scripts run. + final count = host.scripts.length; + await Future.delayed(const Duration(milliseconds: 150)); + expect(host.scripts.length, count); + }); + }); + + group('polling', () { + test('polls pending messages and replies via script', () async { + host.supportsPorts = false; + host.supportsConsole = false; + await start(); + var served = false; + host.onRun = (script) { + if (script.contains('getPendingMessages("Echo")') && !served) { + served = true; + return jsonEncode({ + 'Echo': [ + {'id': 9, 'message': 'poll'}, + ], + }); + } + return script.contains('getPendingMessages') ? '{"Echo":[]}' : ''; + }; + await platform.addJavaScriptChannel( + id, + JavaScriptChannelParams( + name: 'Echo', + onMessageReceived: (m) => + JavaScriptReply(message: 'got ${m.message}'), + ), + ); + await Future.delayed(const Duration(milliseconds: 200)); + expect( + host.scripts.any((s) => s.contains('resolveById(9, "got poll")')), + isTrue, + ); + }); + }); + + group('exception mapping', () { + Future failWith(PlatformException e) async { + host.onRun = (_) => ''; + id = 'test-${counter++}'; + await start(); + host.onRun = (_) => throw e; + return platform.runJavaScriptReturningResult(id, 'x'); + } + + test('EvaluationFailedException keeps the message', () async { + await expectLater( + failWith(PlatformException( + code: 'EvaluationFailedException', + message: 'Error: boom', + )), + throwsA( + isA() + .having((e) => e.toString(), 'toString', contains('boom')) + .having((e) => e, 'is execution', + isA()), + ), + ); + }); + + test('DataInputException', () async { + await expectLater( + failWith(PlatformException(code: 'DataInputException', message: 'm')), + throwsA(isA()), + ); + }); + + test('EvaluationResultSizeLimitExceededException', () async { + await expectLater( + failWith(PlatformException( + code: 'EvaluationResultSizeLimitExceededException', + )), + throwsA( + isA()), + ); + }); + + test('dead environment exceptions', () async { + await expectLater( + failWith(PlatformException(code: 'SandboxDeadException')), + throwsA(isA()), + ); + await expectLater( + failWith(PlatformException(code: 'IsolateTerminatedException')), + throwsA(isA()), + ); + await expectLater( + failWith(PlatformException(code: 'MemoryLimitExceededException')), + throwsA(isA()), + ); + await expectLater( + failWith(PlatformException(code: 'SandboxUnsupportedException')), + throwsA(isA()), + ); + }); + + test('a missing isolate maps to the gone exception', () async { + await expectLater( + failWith(PlatformException( + code: 'IllegalStateException', + message: 'No active isolate with id x', + )), + throwsA(isA()), + ); + }); + + test('other platform exceptions are rethrown', () async { + await expectLater( + failWith(PlatformException(code: 'Weird', message: 'w')), + throwsA(isA()), + ); + }); + + test('an unknown instance id throws', () { + expect( + () => platform.addJavaScriptChannel( + 'nope', + JavaScriptChannelParams( + name: 'X', + onMessageReceived: (_) => JavaScriptReply(message: ''), + ), + ), + throwsA(isA()), + ); + }); + }); } From 08a3378eb67da5fb771a90d36b6383819e304637 Mon Sep 17 00:00:00 2001 From: predatorx7 Date: Mon, 26 Oct 2026 15:13:22 +0530 Subject: [PATCH 4/4] docs: document the string result contract, add API tests and example - javascript_flutter 1.3.0: rewrite the runJavaScriptReturningResult dartdoc and the README around the string-in, string-out contract, with the JSON.stringify + jsonDecode pattern as the way to get structured data back, and a loud behavior-change note with migration steps. Depend on javascript_platform_interface ^2.1.0, javascript_android ^2.2.0 and javascript_darwin ^2.1.0 so the fixed implementations are guaranteed. - javascript_flutter tests: 15 unit tests pin the public JavaScript API against a mock platform (ids, pass-through of results, forwarding, dispose semantics, latching of unavailable-platform errors). - example: README snippet and app updated to the contract; the console pretty-prints a result only when it parses as JSON, purely as a convenience in the example. Add a widget smoke test and model tests so `flutter test` finds tests. - cspell: add the words the new docs use. Co-Authored-By: Claude Fable 5.1 --- .github/cspell.json | 14 +- packages/javascript/CHANGELOG.md | 27 +++ packages/javascript/README.md | 99 ++++++-- packages/javascript/example/README.md | 4 + packages/javascript/example/lib/snippet.dart | 12 +- .../javascript/example/lib/src/model.dart | 39 ++- .../example/lib/src/ui/command_input.dart | 2 +- .../javascript/example/test/widget_test.dart | 96 ++++++++ .../javascript/lib/javascript_flutter.dart | 38 ++- packages/javascript/pubspec.yaml | 10 +- packages/javascript/test/javascript_test.dart | 224 +++++++++++++++++- 11 files changed, 514 insertions(+), 51 deletions(-) create mode 100644 packages/javascript/example/test/widget_test.dart 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/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 `