diff --git a/.agents/skills/dart-log-failure-parser/SKILL.md b/.agents/skills/dart-log-failure-parser/SKILL.md new file mode 100644 index 0000000000000..affda52b35273 --- /dev/null +++ b/.agents/skills/dart-log-failure-parser/SKILL.md @@ -0,0 +1,52 @@ +--- +name: dart-log-failure-parser +description: Parse failures from Dart and Flutter test logs. +--- + +# Dart Log Failure Parser + +## Workflow + +### 1. Analyze Raw Log Output + +Analyze the raw log output for failure details. Do not skim the output; check the entire log. **The description of findings should include specific details for the failures (e.g., unformatted files, specific test names), not just the top-level command that failed.** + +### 2. Look for Failure Patterns + +#### Pattern A: Error Blocks (e.g., Linux Analyze) +Search for blocks starting with `╡ERROR #`. +Example: +``` +╔═╡ERROR #1╞════════════════════════════════════════════════════════════════════ +║ Command: bin/cache/dart-sdk/bin/dart --enable-asserts /b/s/w/ir/x/w/flutter/dev/bots/analyze_snippet_code.dart --verbose +║ Command exited with exit code 255 but expected zero exit code. +║ Working directory: /b/s/w/ir/x/w/flutter +╚═══════════════════════════════════════════════════════════════════════════════ +``` + +#### Pattern B: Task Result JSON +Search for "Task result:" followed by a JSON object. +Example: +```json +Task result: +{ + "success": false, + "reason": "Task failed: PathNotFoundException: Cannot open file..." +} +``` + +#### Pattern C: Failing Tests List +For general Dart tests, look for a list at the end of the log starting with "Failing tests:". +Example: +``` +Failing tests: + test/general.shard/cache_test.dart: FontSubset artifacts for all platforms on arm64 hosts + test/general.shard/cache_test.dart: FontSubset artifacts on arm64 linux +``` + +#### Pattern D: Build Failures +For build failures (e.g., engine tests failing at compile time), look for the following indicators in the logs or API summaries: +- Lines starting with `FAILED:` (indicates a Ninja target failed). +- Compiler error messages (e.g., `error:`, `fatal error:`). +- Linker error messages (e.g., `undefined reference to`). +- Summary messages in the check-runs API output like `1 build failed: []`. diff --git a/.agents/skills/flutter-pr-checks-finder/SKILL.md b/.agents/skills/flutter-pr-checks-finder/SKILL.md new file mode 100644 index 0000000000000..48834c01ffd1f --- /dev/null +++ b/.agents/skills/flutter-pr-checks-finder/SKILL.md @@ -0,0 +1,92 @@ +--- +name: flutter-pr-checks-finder +description: Find failing checks on a Flutter PR and locate the corresponding LUCI log URLs. +--- + +# Flutter PR Checks Finder + +## Prerequisites + +- `gh` (GitHub CLI) must be installed and authenticated. If not in your PATH, check common locations like `/opt/homebrew/bin/gh` on macOS or `C:\Program Files\GitHub CLI\gh.exe` on Windows. +- Access to `curl` or similar tool to fetch raw logs from LUCI. + +## Workflow + +### 1. Find Failing Checks + +You can use the `gh` CLI if it's installed and authenticated, or use direct HTTP requests to the GitHub API as a fallback. + +#### Option A: Using `gh` CLI (Preferred) +Run the following command to list checks: +```bash +gh pr checks +``` + +#### Option B: Using GitHub API via HTTP +If `gh` is not available, you can use `read_url_content` or a similar method to interact with the public GitHub API: +1. **Find the PR SHA**: + Make an HTTP request to: `https://api.github.com/repos/flutter/flutter/pulls/` + Extract the `head.sha` field. +2. **List Check Runs**: + Make an HTTP request to: `https://api.github.com/repos/flutter/flutter/commits//check-runs` + Parse the JSON response. **CRITICAL**: You must handle pagination to avoid missing failures! Check the `total_count` field. If it is greater than the number of items in the `check_runs` array (typically capped at 100 or what you set with `per_page`), make additional HTTP requests by appending `?per_page=100&page=` to the URL for each subsequent page until all check runs are fetched. Identify all checks that have failed (i.e., where `conclusion` is `failure`). + +Identify all checks that have failed. + +### 2. Retrieve Failure Logs + +For each failing check: +1. **Find the Log URL**: + - Look for the target URL or link associated with the check. + - The `flutter-dashboard` link typically appears as "View more details on flutter-dashboard" at the bottom of the check view on GitHub. + - Alternatively, you can reconstruct the link to the LUCI page based on the name of the failing check and the build number if available. + Example LUCI URL structure: `https://ci.chromium.org/ui/p/flutter/builders/try///overview` +2. **Build Raw Log URL**: + - **Manual**: Reconstruct the raw log URL by appending `?format=raw` to the log URL or by following the pattern: `https://logs.chromium.org/logs/flutter/buildbucket/cr-buildbucket//+/u//stdout?format=raw` + +3. **Fetch Raw Logs**: + - Use `curl` or similar tool to fetch the content of the raw log URL. + - **CRITICAL**: You must use the raw log URL to avoid HTML formatting and truncated output. **Do NOT rely solely on the check summary in the GitHub API, as it may be truncated or lack full context.** + + +### Builder to Step Name Mapping + +> [!NOTE] +> Step names can be very specific and hard to guess. This section documents patterns to help find them. + +#### Recipes & Tools + +* **`flutter_drone` Recipe** + * **Description:** Originates from the [cocoon][] or [recipes][] repository. It is typically used for running sharded framework tests and lints (like `analyze`, `test_general`) as specified in [.ci.yaml (root)][ci-yaml-root]. + * **Pattern:** `run test.dart for shard and subshard ` + * **URL Transformation:** Spaces are replaced by underscores. + * **Default:** If `subshard` is not specified, it defaults to `None`. + * **Example:** For `Linux analyze` (shard: `analyze`, no subshard), the URL step name is `run_test.dart_for_analyze_shard_and_subshard_None`. + +* **`builder.py` & Related Recipes** + * **Description:** Found in the `flutter/engine` repository (or recipes repository) and used to execute builds and tests according to target JSON configurations in the [builders folder][builders-folder] directory. + * **Pattern:** Typically the task name specified in the JSON configuration, often prefixed with `test: `. + * **URL Transformation:** Spaces are replaced by underscores. + * **Gotcha:** If the test name already starts with `test: ` in the JSON file, the recipe might still add the prefix again (e.g., `test:_test:_Check_formatting`). + +* **`tester.py`** + * **Description:** A common helper script in the engine's CI recipes used to execute tests. + * **Pattern:** `Run tests` or `Run tests`. + * **URL Transformation:** Spaces are replaced by underscores. + +#### Locating Exact Names in Engine + +If guessing fails, find the exact test and task names in the engine configuration: +1. Look up the builder in [.ci.yaml (engine)][ci-yaml-engine] to find its `config_name` property. +2. Locate the corresponding JSON file in the [builders folder][builders-folder] directory. +3. Read the JSON file to find the `tests` array and the specific `tasks` listed within them. + +#### Fallback + +If `read_url_content` fails with 404 on guessed step names, you may need to find the step name from the LUCI overview page or other sources. + +[cocoon]: https://github.com/flutter/cocoon +[recipes]: https://github.com/flutter/recipes +[ci-yaml-root]: ../../../.ci.yaml +[ci-yaml-engine]: ../../../engine/src/flutter/.ci.yaml +[builders-folder]: ../../../engine/src/flutter/ci/builders/ diff --git a/.ci.yaml b/.ci.yaml index a43d30c9a8b93..5d11f2b5ad519 100644 --- a/.ci.yaml +++ b/.ci.yaml @@ -2968,7 +2968,6 @@ targets: task_name: native_assets_android - name: Linux_android_emu android_engine_flags_debug_test - bringup: true recipe: devicelab/devicelab_drone timeout: 60 properties: @@ -2977,7 +2976,6 @@ targets: task_name: android_engine_flags_debug_test - name: Linux_android_emu android_engine_flags_release_test - bringup: true recipe: devicelab/devicelab_drone timeout: 60 properties: @@ -3856,7 +3854,6 @@ targets: - name: Mac_benchmark complex_layout_macos_impeller__start_up presubmit: false recipe: devicelab/devicelab_drone - bringup: true timeout: 60 properties: dependencies: >- @@ -4170,7 +4167,7 @@ targets: properties: tags: > ["devicelab", "mac"] - task_name: hello_world_macos_impeller_macos_sdfs + task_name: hello_world_impeller_macos_sdfs - name: Mac integration_ui_test_test_macos recipe: devicelab/devicelab_drone diff --git a/CODEOWNERS b/CODEOWNERS index 478afc53f4527..d464d7a5f4123 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -63,3 +63,5 @@ .agents/skills/find-release @reidbaker .agents/skills/rebuilding-flutter-tool @vashworth .agents/skills/upgrade-browser @mdebbar +.agents/skills/flutter-pr-checks-finder @bkonyi +.agents/skills/dart-log-failure-parser @bkonyi diff --git a/DEPS b/DEPS index 97f3de5e45cc4..a041765b31ec1 100644 --- a/DEPS +++ b/DEPS @@ -15,7 +15,7 @@ vars = { 'flutter_git': 'https://flutter.googlesource.com', 'skia_git': 'https://skia.googlesource.com', 'llvm_git': 'https://llvm.googlesource.com', - 'skia_revision': 'ce82d32b3e03cd094a1091e3d991c66b512f481b', + 'skia_revision': '05251260fda6673f1062ad21bb774ae994caae26', # WARNING: DO NOT EDIT canvaskit_cipd_instance MANUALLY # See `lib/web_ui/README.md` for how to roll CanvasKit to a new version. @@ -59,13 +59,13 @@ vars = { # updated revision list of existing dependencies. You will need to # gclient sync before and after update deps to ensure all deps are updated. # updated revision list of existing dependencies. - 'dart_revision': '941ca325cfc9036e5e7a07ba52c09f060e58d496', + 'dart_revision': 'd41c994623cf6be66be842b5541017489a9885f5', # WARNING: DO NOT EDIT MANUALLY # The lines between blank lines above and below are generated by a script. See create_updated_flutter_deps.py 'dart_ai_rev': '3d3b7fdaddead83ce262377e5c4ce5b2a843066c', 'dart_binaryen_rev': '604f547f5ccb51cdc02c1b12fe96a6d045c602d5', - 'dart_boringssl_rev': '8743bafcfe78782a929d1b32a184d5de7e5f20ed', + 'dart_boringssl_rev': '8415495eff36b92242762f55f22aa52a7e6f89df', 'dart_core_rev': '347df4b546f315fc1ff69c6e65f2a023b0263b1d', 'dart_devtools_rev': 'fa063f322c03cc7a690d819db124c196a69cff56', 'dart_ecosystem_rev': '3bd43d6db5187d1663954227e41e2a9681004846', @@ -124,6 +124,9 @@ vars = { # the flutter engine to ensure that Dart gn has access to it as well. "checkout_llvm": False, + # Use prebuilt Dart DevTools sources. + 'build_devtools_from_sources': False, + # Setup Git hooks by default. 'setup_githooks': True, @@ -212,7 +215,8 @@ vars = { gclient_gn_args_file = 'engine/src/flutter/third_party/dart/build/config/gclient_args.gni' gclient_gn_args = [ - 'checkout_llvm' + 'checkout_llvm', + 'build_devtools_from_sources', ] # Only these hosts are allowed for dependencies in this DEPS file. @@ -340,7 +344,7 @@ deps = { Var('dart_git') + '/external/github.com/simolus3/tar.git@13479f7c2a18f499e840ad470cfcca8c579f6909', 'engine/src/flutter/third_party/dart/third_party/pkg/test': - Var('dart_git') + '/test.git@d0d5ccd037cb0eaea8e4dad900946ac54a4286c6', + Var('dart_git') + '/test.git@8bbb8474e3ff85aa9450c2b00f9476b34ebdd679', 'engine/src/flutter/third_party/dart/third_party/pkg/tools': Var('dart_git') + '/tools.git' + '@' + Var('dart_tools_rev'), @@ -800,7 +804,7 @@ deps = { 'packages': [ { 'package': 'fuchsia/sdk/core/linux-amd64', - 'version': 'i6d0NoDueUiXpePfXzF3Ii4JIdhpLTRoDUg_lZlzpJUC' + 'version': 'nnv8-SSam6yE8dw4z7dXbyNTk3Y03qC4X8q5REtIL1IC' } ], 'condition': 'download_fuchsia_deps and not download_fuchsia_sdk', diff --git a/TESTOWNERS b/TESTOWNERS index 09c67e59c449a..bcfc872032522 100644 --- a/TESTOWNERS +++ b/TESTOWNERS @@ -277,8 +277,8 @@ /dev/devicelab/bin/tasks/gradle_plugin_fat_apk_test.dart @gmackall @flutter/plugin /dev/devicelab/bin/tasks/gradle_plugin_light_apk_test.dart @gmackall @flutter/plugin /dev/devicelab/bin/tasks/hello_world_impeller_ios_sdfs.dart @gaaclarke @flutter/engine +/dev/devicelab/bin/tasks/hello_world_impeller_macos_sdfs.dart @b-luk @flutter/desktop /dev/devicelab/bin/tasks/hello_world_macos__compile.dart @cbracken @flutter/desktop -/dev/devicelab/bin/tasks/hello_world_macos_impeller_macos_sdfs.dart @b-luk @flutter/desktop /dev/devicelab/bin/tasks/hello_world_win_desktop__compile.dart @yaakovschectman @flutter/desktop /dev/devicelab/bin/tasks/hot_mode_dev_cycle_ios_simulator.dart @louisehsu @flutter/tool /dev/devicelab/bin/tasks/hot_mode_dev_cycle_macos_target__benchmark.dart @cbracken @flutter/tool diff --git a/analysis_options.yaml b/analysis_options.yaml index 8e6a35105568f..333678fbbea50 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -55,7 +55,8 @@ linter: - avoid_equals_and_hash_code_on_mutable_classes - avoid_escaping_inner_quotes - avoid_field_initializers_in_const_classes - # - avoid_final_parameters # incompatible with prefer_final_parameters + # TODO(kallentu): Remove this lint once the Dart SDK in Flutter is on version 3.13. + - avoid_final_parameters - avoid_function_literals_in_foreach_calls # - avoid_futureor_void # not yet tested # - avoid_implementing_value_types # see https://github.com/dart-lang/linter/issues/4558 diff --git a/bin/internal/flutter_packages.version b/bin/internal/flutter_packages.version index ee0ea07fd36ec..96f9c0a97ae89 100644 --- a/bin/internal/flutter_packages.version +++ b/bin/internal/flutter_packages.version @@ -1 +1 @@ -23280daaac87b6cdc8648fff8b4d1e936f80e338 +ba80f8f0ff88c1da3d9945cb07416e0b385fae58 diff --git a/dev/benchmarks/complex_layout/test/measure_scroll_smoothness.dart b/dev/benchmarks/complex_layout/test/measure_scroll_smoothness.dart index 741c8609a6d9a..f66aeac6f1d44 100644 --- a/dev/benchmarks/complex_layout/test/measure_scroll_smoothness.dart +++ b/dev/benchmarks/complex_layout/test/measure_scroll_smoothness.dart @@ -15,11 +15,11 @@ import 'package:integration_test/integration_test.dart'; /// Generates the [PointerEvent] to simulate a drag operation from /// `center - totalMove/2` to `center + totalMove/2`. Iterable dragInputEvents( - final Duration epoch, - final Offset center, { - final Offset totalMove = const Offset(0, -400), - final Duration totalTime = const Duration(milliseconds: 2000), - final double frequency = 90, + Duration epoch, + Offset center, { + Offset totalMove = const Offset(0, -400), + Duration totalTime = const Duration(milliseconds: 2000), + double frequency = 90, }) sync* { final Offset startLocation = center - totalMove / 2; // The issue is about 120Hz input on 90Hz refresh rate device. diff --git a/dev/bots/check_tests_cross_imports.dart b/dev/bots/check_tests_cross_imports.dart index 982082d700098..ca29df8203687 100644 --- a/dev/bots/check_tests_cross_imports.dart +++ b/dev/bots/check_tests_cross_imports.dart @@ -107,7 +107,6 @@ class TestsCrossImportChecker { // TODO(justinmc): Fix all of these tests so there are no cross imports. // See https://github.com/flutter/flutter/issues/177028. static final Set knownWidgetsCrossImports = { - 'packages/flutter/test/widgets/restoration_scopes_moving_test.dart', 'packages/flutter/test/widgets/page_transitions_test.dart', 'packages/flutter/test/widgets/routes_test.dart', 'packages/flutter/test/widgets/app_test.dart', diff --git a/dev/bots/suite_runners/run_test_harness_tests.dart b/dev/bots/suite_runners/run_test_harness_tests.dart index cecf60ace1ac4..c73cb70a912cb 100644 --- a/dev/bots/suite_runners/run_test_harness_tests.dart +++ b/dev/bots/suite_runners/run_test_harness_tests.dart @@ -175,7 +175,7 @@ Future _validateEngineRevision() async { } final String actualVersion; try { - actualVersion = result.flattenedStderr!.split('\n').firstWhere((final String line) { + actualVersion = result.flattenedStderr!.split('\n').firstWhere((String line) { return line.startsWith('Flutter Engine Version:'); }); } on StateError { diff --git a/docs/contributing/Android-API-And-Related-Versions.md b/docs/contributing/Android-API-And-Related-Versions.md index 09eb0294b57c3..a1e87395b1b81 100644 --- a/docs/contributing/Android-API-And-Related-Versions.md +++ b/docs/contributing/Android-API-And-Related-Versions.md @@ -88,14 +88,14 @@ dependencies { ``` // OK dependencies { - // Testing backwards compatability of feature XYZ + // Testing backwards compatibility of feature XYZ classpath "com.android.tools.build:gradle:7.5.2" } ``` ### gradle -Gradle versions are the least likley to break across minor version updates. +Gradle versions are the least likely to break across minor version updates. - In new code the "gradle" version should be the version set in flutter templates or newer. - In older code any gradle version that works with the other version constraints is ok. @@ -109,7 +109,7 @@ distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip ### Kotlin -Changing kotlin versions is most likley to have an issue with another dependency and not the code under test. +Changing kotlin versions is most likely to have an issue with another dependency and not the code under test. - "kotlin" version should be the version set in flutter templates or newer. - If the version is intentionally different there should be a comment explaining why. diff --git a/docs/contributing/Feature-flags.md b/docs/contributing/Feature-flags.md index adeeaa72b6883..2bf4ba394a8aa 100644 --- a/docs/contributing/Feature-flags.md +++ b/docs/contributing/Feature-flags.md @@ -60,7 +60,7 @@ For example: We do not consider it a breaking change to modify or remove experimental flags across releases, or to make changes guarded by experimental flags. APIs that - are guarded by flags are subject to chage at any time. + are guarded by flags are subject to change at any time. ## Adding a flag @@ -248,7 +248,7 @@ const Feature unicornEmojis = Feature( ); ``` -Flutter uses the following precendence order: +Flutter uses the following precedence order: 1. The app's `pubspec.yaml` file: diff --git a/docs/contributing/Style-guide-for-Flutter-repo.md b/docs/contributing/Style-guide-for-Flutter-repo.md index 3bf55ac9886f2..3b68d0aa59bd8 100644 --- a/docs/contributing/Style-guide-for-Flutter-repo.md +++ b/docs/contributing/Style-guide-for-Flutter-repo.md @@ -1575,7 +1575,7 @@ for consistency and readability reasons. We use `dart format` to auto-format all Dart code. This is enforced by our CI. Beyond whitespace formatting handled by the formatter, this section discusses -additional guidelines for code structure to ensure consistency and readibility. +additional guidelines for code structure to ensure consistency and readability. ### Constructors come first in a class diff --git a/docs/contributing/testing/Test-Types-Overview.md b/docs/contributing/testing/Test-Types-Overview.md new file mode 100644 index 0000000000000..9215b491b56e7 --- /dev/null +++ b/docs/contributing/testing/Test-Types-Overview.md @@ -0,0 +1,104 @@ +# Flutter Codebase Test Inventory + +This file lists and categorizes the test files found in the Flutter codebase. +Due to the large number of tests (over 6000 files), they are grouped by category and location. + +## Runtime Length +- **Sub-second**: < 1 second (Simple unit tests, e.g., in `dev/tools`) +- **Fast**: < 10 seconds (Most unit tests, simple widget tests) +- **Medium**: 10-60 seconds (Complex widget tests, some tool tests) +- **Slow**: > 60 seconds (Integration tests, devicelab tasks, analyze script) + +## Groupings + +### 1. Framework Tests +- **Location**: `packages/flutter/test/` +- **Language**: Dart +- **Description**: Unit and Widget tests for the Flutter framework. +- **Documentation**: + - [Running-and-writing-tests.md](Running-and-writing-tests.md) + - [Writing-a-golden-file-test-for-package-flutter.md](Writing-a-golden-file-test-for-package-flutter.md) + - [Leak-tracking.md](Leak-tracking.md) +- **Run Locally**: Yes. +- **Commands**: + - Run all tests in a directory: `bin/flutter test packages/flutter/test/foundation` + - Run a specific test: `bin/flutter test packages/flutter/test/foundation/assertions_test.dart` +- **Speed**: Fast (< 10 seconds) for individual tests when environment is working. +- **Count**: ~1000+ files. + +### 2. Tool Tests +- **Location**: `packages/flutter_tools/test/` +- **Language**: Dart +- **Description**: Where the flutter tool tests live. +- **Documentation**: [README.md](../../../packages/flutter_tools/README.md) +- **Run Locally**: Yes. +- **Commands**: + - Run general unit tests: `bin/flutter test packages/flutter_tools/test/general.shard` + - Run a specific test: `bin/flutter test packages/flutter_tools/test/general.shard/base_utils_test.dart` + - Run integration tests: `bin/flutter test packages/flutter_tools/test/integration.shard` (Requires `FLUTTER_ROOT` environment variable). +- **Speed**: Fast (< 10 seconds) for general shard tests, Medium to Slow for integration tests. +- **Count**: ~500+ files. + +### 3. DeviceLab Tests +- **Location**: `dev/devicelab/` +- **Language**: Dart +- **Description**: Performance and integration tests run in the Flutter DeviceLab. +- **Documentation**: + - [README.md](../../../dev/devicelab/README.md) + - [How-to-write-a-memory-test-for-Flutter.md](How-to-write-a-memory-test-for-Flutter.md) + - [How-to-write-a-render-speed-test-for-Flutter.md](How-to-write-a-render-speed-test-for-Flutter.md) +- **Run Locally**: Partially. May require a physical device or emulator and specific environment setup (e.g., `ANDROID_SDK_ROOT`). +- **Commands**: + - Run a task: `../../bin/cache/dart-sdk/bin/dart bin/test_runner.dart test -t {NAME_OF_TEST}` (Run from `dev/devicelab`). + - Example: `../../bin/cache/dart-sdk/bin/dart bin/test_runner.dart test -t complex_layout__start_up` +- **Speed**: Slow (> 60 seconds). +- **Count**: ~100+ files. + +### 4. Benchmarks +- **Location**: `dev/benchmarks/` +- **Language**: Dart +- **Description**: Performance benchmarks. +- **Documentation**: + - [README.md](../../../dev/devicelab/README.md) (Benchmarks are run as DeviceLab tasks). + - [How-to-write-a-memory-test-for-Flutter.md](How-to-write-a-memory-test-for-Flutter.md) + - [How-to-write-a-render-speed-test-for-Flutter.md](How-to-write-a-render-speed-test-for-Flutter.md) +- **Run Locally**: Partially, same requirements as DeviceLab tests. +- **Commands**: See DeviceLab Tests. +- **Speed**: Slow (> 60 seconds). +- **Count**: ~160+ files. + +### 5. Analysis and Lint Tests +- **Key Files**: + - `dev/bots/analyze.dart`: Enforces style and structure rules. + - `dev/bots/test.dart`: Main test orchestrator. +- **Documentation**: [Running-and-writing-tests.md](Running-and-writing-tests.md) +- **Run Locally**: Yes. +- **Commands**: + - Run analysis: `bin/cache/dart-sdk/bin/dart --enable-asserts dev/bots/analyze.dart` (Verified locally, takes > 60 seconds for full run). +- **Speed**: Slow (> 60 seconds) for full execution. + +### 6. Package Tests +- **Location**: `packages/*/test/` (excluding `flutter` and `flutter_tools`) +- **Language**: Dart +- **Description**: Tests for packages. Any folder with a `pubspec.yaml` file is considered a package and may contain a `test/` directory. +- **Documentation**: [Running-and-writing-tests.md](Running-and-writing-tests.md) +- **Run Locally**: Yes. +- **Commands**: + - Run tests in a package: `bin/flutter test packages//test` +- **Speed**: Fast (< 10 seconds) for individual tests. + +### 7. Engine Tests +- **Location**: `engine/src` (in the [flutter/engine](https://github.com/flutter/engine) repository) +- **Language**: C++, Dart, Java, Kotlin, etc. +- **Description**: Tests for the Flutter engine. +- **Documentation**: [Testing-the-engine.md](../../engine/testing/Testing-the-engine.md) +- **Run Locally**: Yes, but requires the complete Engine development environment (GN, Ninja, etc.). +- **Commands**: + - **Using `run_tests.py` from `engine/src/flutter`**: + - Run C++ tests: `testing/run_tests.py --type=engine` + - Run Java tests: `testing/run_tests.py --type=java` + - Run Dart tests: `testing/run_tests.py --type=dart` + - **Using `et` (Engine Tool)**: + - Run a test target: `et test //flutter/impeller:impeller_unittests` + - Query test targets: `et query targets --testonly` +- **Speed**: Fast for individual unit tests, Slow for full suites. diff --git a/docs/ecosystem/contributing/README.md b/docs/ecosystem/contributing/README.md index 3157abb801a3a..cda7ad1c6f1ad 100644 --- a/docs/ecosystem/contributing/README.md +++ b/docs/ecosystem/contributing/README.md @@ -150,7 +150,7 @@ Some dependencies should only be linked as dev_dependencies, such as `integratio ### Dependency versions Our general policy is not to update the minimum version of a dependency beyond what the package requires. For instance: -- We do not regularly make the app-facing package in a federated plugin require the latest bugfix versions of all platform implementation packages just so that people who do not regularly update their transitive dependencies get the latest bugfixes. Our view is that clients who want updates should update their transitive dependencies (`dart pub upgrade`, or `dart pub upgrade --unlock-transitive ` for targeted updates), rather than us artifically pushing transitive dependency updates on everyone. +- We do not regularly make the app-facing package in a federated plugin require the latest bugfix versions of all platform implementation packages just so that people who do not regularly update their transitive dependencies get the latest bugfixes. Our view is that clients who want updates should update their transitive dependencies (`dart pub upgrade`, or `dart pub upgrade --unlock-transitive ` for targeted updates), rather than us artificially pushing transitive dependency updates on everyone. - When a dependency updates its major version, and the breaking changes do not affect our usage, we expand the constrain to *add* the new major version to the range, rather than requiring the new major version. This minimizes version lock in the ecosystem (where clients are unable to use the latest version of two different packages because each requires a specific, different major version). We do update minimum versions when it is actually required, such as a breaking change that does affect our usage, or when adding a new feature to the app-facing package in a federated plugin that relies on new APIs in the other sub-packages of that plugin. In most cases, the `analyze_downgraded` CI task will catch cases where that is necessary, but in some cases we rely on PR authors and reviewers (for example, when exposing a new feature in a federated plugin it is often possible to compile with only the platform interface package's minimum version updated, but the feature may throw `UnimplementedError`s at runtime if the implementation package versions are not updated as well). @@ -212,12 +212,12 @@ In federated plugins, platform-specific behaviors are controlled by the platform ### OS version support -Whenever feasible, plugins should support the same OS versions that [Flutter supports](https://docs.flutter.dev/reference/supported-platforms). When a plugin's platform implementation is updated to require a version of Flutter that drops a previously-supported OS version, the plugin should be updated to drop that version as well (for example, updating minimim versions in native build files and removing runtime version checks for versions that are no longer supported). +Whenever feasible, plugins should support the same OS versions that [Flutter supports](https://docs.flutter.dev/reference/supported-platforms). When a plugin's platform implementation is updated to require a version of Flutter that drops a previously-supported OS version, the plugin should be updated to drop that version as well (for example, updating minimum versions in native build files and removing runtime version checks for versions that are no longer supported). -If a new plugin is being created, or a plugin is being extended to a new platform, it's fine to require a newer OS version if there is a good reason to do so. For instance, if it would require adding significant already-deprecated codepaths that would cause a maintenance burden, it may not be worth supporting older OS verions. +If a new plugin is being created, or a plugin is being extended to a new platform, it's fine to require a newer OS version if there is a good reason to do so. For instance, if it would require adding significant already-deprecated codepaths that would cause a maintenance burden, it may not be worth supporting older OS versions. For existing plugins, dropping support for OS versions that are still supported by Flutter (or supported by older versions of Flutter that are still within the allowance of the package's pubspec.yaml Flutter constraint) is **very disruptive**: because `pub` does not have information about OS version support, dropping previously-supported OS versions is build breaking for clients. There are three possible approaches to dropping an OS version from an existing plugin: -1. Wait for Flutter to drop that version, then set the minimum SDK version accordingly before/while dropping the OS version in the plugin. This means that a client still targetting the dropped version can never resolve to the version of the package that dropped support, so it is a no-op for clients. This is the **strongly preferred** approach. +1. Wait for Flutter to drop that version, then set the minimum SDK version accordingly before/while dropping the OS version in the plugin. This means that a client still targeting the dropped version can never resolve to the version of the package that dropped support, so it is a no-op for clients. This is the **strongly preferred** approach. 2. Drop the version as a breaking change. Keep in mind that this must be a breaking change not only for the implementation package, but also for the app-facing package when updating to use the new major version of the implementation package, so should be discussed with the ecosystem team in advance. This approach ensures that clients have appropriate breaking change notification, and have to opt in to the new version. 3. While it is **strongly discouraged**, in rare cases we have dropped OS version support without a major version change. This should be done only if a major version change would be disruptive to the ecosystem and the OS version drop cannot be delayed until after Flutter has dropped it. For example, if continuing to support an older OS version would require continuing to use an older version of an SDK that has a significant security vulnerability, this approach could be warranted. This should only be done if the costs of one of the two previous approaches is so high that causing unexpected build breakage for plugin clients is a better outcome. diff --git a/docs/ecosystem/release/README.md b/docs/ecosystem/release/README.md index 9a758651adc1b..7cac7310ef2ca 100644 --- a/docs/ecosystem/release/README.md +++ b/docs/ecosystem/release/README.md @@ -46,7 +46,7 @@ To enable batch release for a package: * Create a `pending_changes` directory in the package root containing a `template.yaml` file: ```yaml # Use this file as a template to draft an unreleased changelog file. - # Make a copy of this file in the same directory, give it an approrpriate name, and fill in the details. + # Make a copy of this file in the same directory, give it an appropriate name, and fill in the details. changelog: | - Can include a list of changes. - with markdown supported. diff --git a/docs/engine/Debugging-the-engine.md b/docs/engine/Debugging-the-engine.md index f0fcdfaa5af52..c030e315ec887 100644 --- a/docs/engine/Debugging-the-engine.md +++ b/docs/engine/Debugging-the-engine.md @@ -85,10 +85,6 @@ Add an engine symbol breakpoint via **Debug > Breakpoints > Create Symbolic Brea You can also set a breakpoint directly with [lldb](https://lldb.llvm.org/tutorial.html) by expanding **Flutter > Runner** in the Runner Project Navigator. Put a breakpoint in `AppDelegate.swift`'s `application(didFinishLaunchingWithOptions:)` (Swift project) or `main.m`'s `main()` (Objective-C project) and start the application by clicking the Run button (CMD + R). Then, set your desired breakpoint in the engine in `lldb` via `breakpoint set -...`. -## Debugging Android builds with gdb - -See https://github.com/flutter/flutter/blob/main/engine/src/flutter/sky/tools/flutter_gdb - ## Debugging native engine code on Android with Android Studio 1. Build an unoptimized local engine. i.e. `et build -c host_debug_unopt_arm64 && et build -c android_debug_unopt_arm64`. diff --git a/docs/engine/testing/Testing-the-engine.md b/docs/engine/testing/Testing-the-engine.md index 0cfa21401f5e7..6715e963ef317 100644 --- a/docs/engine/testing/Testing-the-engine.md +++ b/docs/engine/testing/Testing-the-engine.md @@ -4,7 +4,7 @@ This guide describes how to write and run various types of tests in the engine. ## C++ - core engine -If you edit `.cc` files in [`engine`](../../), +If you edit `.cc` files in [`engine`](/engine), you're working on the core, portable Flutter engine. ### Unit tests (C++) @@ -100,7 +100,7 @@ To reproduce test flakes, you can run a test multiple times: ## Java - Android embedding -If you edit `.java` files in the [`shell/platform/android`](../../shell/platform/android/) +If you edit `.java` files in the [`shell/platform/android`](/engine/src/flutter/shell/platform/android/) directory, you're working on the Android embedding which connects the core C++ engine to the Android SDK APIs and runtime. @@ -108,9 +108,9 @@ engine to the Android SDK APIs and runtime. For testing logic within a class at a unit level, create or add to a JUnit test. -Existing Java unit tests are located at [`shell/platform/android/test`](../../shell/platform/android/test) -and follow the Java package directory structure. Files in the [`shell/platform/android/io/flutter/`](../../shell/platform/android/io/flutter/) -package tree can have a parallel file in the [`shell/platform/android/test/io/flutter/`](../../shell/platform/android/test/io/flutter/) +Existing Java unit tests are located at [`shell/platform/android/test`](/engine/src/flutter/shell/platform/android/test) +and follow the Java package directory structure. Files in the [`shell/platform/android/io/flutter/`](/engine/src/flutter/shell/platform/android/io/flutter/) +package tree can have a parallel file in the [`shell/platform/android/test/io/flutter/`](/engine/src/flutter/shell/platform/android/test/io/flutter/) package tree. Files in matching directories are considered [package visible](https://docs.oracle.com/javase/tutorial/java/javaOO/accesscontrol.html) as is the case in standard Java. @@ -118,7 +118,7 @@ When editing production files in `shell/platform/android/io/flutter/`, the easiest step to add tests is to look for a matching `...Test.java` file in `shell/platform/android/test/io/flutter/`. -See the [Java unit test README](../../shell/platform/android/test/README.md) +See the [Java unit test README](/engine/src/flutter/shell/platform/android/test/README.md) for details. The engine repo has a unified build system to build C, C++, Objective-C, @@ -198,11 +198,11 @@ submitting PRs to the `flutter/engine` repository. End-to-end tests for the Android embedder exist as part of the test suites in the root of the monorepo. See -[`dev/integration_tests`](../../../../../dev/integration_tests/). +[`dev/integration_tests`](/dev/integration_tests/). ## Objective-C - iOS embedding -If you edit `.h` or `.mm` files in the [`shell/platform/darwin/ios`](../../shell/platform/darwin/ios) +If you edit `.h` or `.mm` files in the [`shell/platform/darwin/ios`](/engine/src/flutter/shell/platform/darwin/ios) directory, you're working on the iOS embedding which connects the core C++ engine to the iOS SDK APIs and runtime. @@ -211,18 +211,18 @@ engine to the iOS SDK APIs and runtime. For testing logic within a class in isolation, create or add to a XCTestCase. The iOS unit testing infrastructure is split in 2 different locations. The -`...Test.mm` files in [`shell/platform/darwin/ios`](../../shell/platform/darwin/ios) -contain the unit tests themselves. The [`testing/ios/IosUnitTests`](../../testing/ios/IosUnitTests/) +`...Test.mm` files in [`shell/platform/darwin/ios`](/engine/src/flutter/shell/platform/darwin/ios) +contain the unit tests themselves. The [`testing/ios/IosUnitTests`](/engine/src/flutter/testing/ios/IosUnitTests/) directory contains an Xcode container project to execute the test. -See [`testing/ios/IosUnitTests/README.md`](../../testing/ios/IosUnitTests/README.md) +See [`testing/ios/IosUnitTests/README.md`](/engine/src/flutter/testing/ios/IosUnitTests/README.md) for details on adding new test files. The engine repo has a unified build system to build C, C++, Objective-C, Objective-C++, and Java files using [GN](https://gn.googlesource.com/gn/) and [Ninja](https://ninja-build.org/). Since GN and Ninja has to build the C++ dependencies that the Objective-C classes reference, the tests aren't built by -the Xcode project in [`testing/ios/IosUnitTests`](../../testing/ios/IosUnitTests/). +the Xcode project in [`testing/ios/IosUnitTests`](/engine/src/flutter/testing/ios/IosUnitTests/). Instead, the engine provides the script: @@ -280,7 +280,7 @@ End-to-end tests exercise the entire iOS embedding with the C++ engine on a headless iOS simulator. It's an integration test ensuring that the engine as a whole on iOS is functioning correctly. -The project containing the iOS end-to-end engine test is at [`testing/ios_scenario_app/ios`](../../testing/ios_scenario_app/ios/). +The project containing the iOS end-to-end engine test is at [`testing/ios_scenario_app/ios`](/engine/src/flutter/testing/ios_scenario_app/ios/). This test project is build similarly to a normal debug Flutter app. The Dart code is bundled in JIT mode and is brought into Xcode with a `.framework` @@ -288,7 +288,7 @@ dependency on the prebuilt local engine. It's then installed and executed on a simulator via Xcode. Unlike a normal Flutter app, the Flutter framework on the Dart side is a -lightweight fake at [`testing/ios_scenario_app/lib`](../../testing/ios_scenario_app/lib/). +lightweight fake at [`testing/ios_scenario_app/lib`](/engine/src/flutter/testing/ios_scenario_app/lib/). that implements some of the basic functionalities of `dart:ui` Window rather than using the real Flutter framework at `flutter/flutter`. @@ -298,11 +298,11 @@ The end-to-end test can be executed by running: testing/ios_scenario_app/run_ios_tests.sh ``` -Additional end-to-end instrumented tests can be added to [`testing/ios_scenario_app/ios/Scenarios/ScenariosTests`](../../testing/ios_scenario_app/ios/Scenarios/ScenariosTests/). +Additional end-to-end instrumented tests can be added to [`testing/ios_scenario_app/ios/Scenarios/ScenariosTests`](/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/ScenariosTests/). If supporting logic is needed for the test case, it can be added to the -Android app under-test in [`testing/ios_scenario_app/ios/Scenarios/Scenarios`](../../testing/ios_scenario_app/ios/Scenarios/Scenarios/). -or to the fake Flutter framework under-test in [`testing/ios_scenario_app/lib`](../../testing/ios_scenario_app/lib/). +Android app under-test in [`testing/ios_scenario_app/ios/Scenarios/Scenarios`](/engine/src/flutter/testing/ios_scenario_app/ios/Scenarios/Scenarios/). +or to the fake Flutter framework under-test in [`testing/ios_scenario_app/lib`](/engine/src/flutter/testing/ios_scenario_app/lib/). As best practice, favor adding unit tests if possible since end-to-end tests are, by nature, non-hermetic, slow and flaky. @@ -312,14 +312,14 @@ submitting PRs. ## Dart - dart:ui -If you edit `.dart` files in [`lib/ui`](../../lib/ui/) you're working on the +If you edit `.dart` files in [`lib/ui`](/engine/src/flutter/lib/ui/) you're working on the `dart:ui` package which is the interface between the C++ engine and the Dart Flutter framework. ### Unit tests (Dart) -Dart classes in [`lib/ui`](../../lib/ui/) have matching unit tests at -[`testing/dart`](../../testing/dart/). +Dart classes in [`lib/ui`](/engine/src/flutter/lib/ui/) have matching unit tests at +[`testing/dart`](/engine/src/flutter/testing/dart/). When editing production files in the 'dart:ui' package, add to or create a test file in `testing/dart`. @@ -359,4 +359,4 @@ Assuming your `flutter` and `engine` working directories are siblings, you can r ## Web engine -Web tests are run via the `felt` command. More details can be found in [lib/web_ui/README.md](../../lib/web_ui/README.md). +Web tests are run via the `felt` command. More details can be found in [lib/web_ui/README.md](/engine/src/flutter/lib/web_ui/README.md). diff --git a/docs/infra/Ci-Best-Practices.md b/docs/infra/Ci-Best-Practices.md index 281325b4490ca..5df33ad638642 100644 --- a/docs/infra/Ci-Best-Practices.md +++ b/docs/infra/Ci-Best-Practices.md @@ -113,7 +113,7 @@ While it's important to have a good test suite, consider: - Is there an existing test target that I can add my test to that is not close to the timeout limit? -Consider consulting with the infrastruture team (`team-infra`) before adding a +Consider consulting with the infrastructure team (`team-infra`) before adding a large number of new test targets, or to get advice on how to best create new test targets. diff --git a/docs/infra/Experimental-Branch.md b/docs/infra/Experimental-Branch.md index 56466ac27db1b..82046146af7d2 100644 --- a/docs/infra/Experimental-Branch.md +++ b/docs/infra/Experimental-Branch.md @@ -51,7 +51,7 @@ Once a PR is submitted, it will show up on the Dashboard at a specific URL: The engine is then built and uploaded to GCS, and every test is automatically marked as skipped. -_Manually_, tests can be scheduled against a sucessfully built engine. Either +_Manually_, tests can be scheduled against a successfully built engine. Either click the individual test, and hit "re-run", or, for supported branches, use the "Run all tasks" feature to schedule every task for the commit to be run asynchronously (typically a few minutes, though may take longer when postsubmit diff --git a/docs/platforms/android/Uploading-New-Java-Version-to-CIPD.md b/docs/platforms/android/Uploading-New-Java-Version-to-CIPD.md index 9ef3aea2e9e71..cc88db0089494 100644 --- a/docs/platforms/android/Uploading-New-Java-Version-to-CIPD.md +++ b/docs/platforms/android/Uploading-New-Java-Version-to-CIPD.md @@ -69,4 +69,4 @@ Some links in the instructions below are Google-internal. If you accidentally uploaded the incorrect package to CIPD, you can delete the tag using these instructions [here](https://goto.google.com/flutter-luci-playbook#remove-duplicated-cipd-tags). -Then, re-upload the correct Java version pacakge to CIPD. +Then, re-upload the correct Java version package to CIPD. diff --git a/docs/triage/Infra-Triage.md b/docs/triage/Infra-Triage.md index 462966bccc6f8..1619517cd6506 100644 --- a/docs/triage/Infra-Triage.md +++ b/docs/triage/Infra-Triage.md @@ -76,7 +76,7 @@ An **emergency** that needs to be addressed ASAP as there is no reasonable workaround. P0s are worked on actively, with an update shared with the core team at least -once a week, and supercede _all_ other priorities (i.e. are a "stop work" order +once a week, and supersede _all_ other priorities (i.e. are a "stop work" order on other issues). Examples might include: diff --git a/docs/triage/README.md b/docs/triage/README.md index 4c0838ae646ca..23e1207ee1ce1 100644 --- a/docs/triage/README.md +++ b/docs/triage/README.md @@ -62,7 +62,7 @@ When closing an issue because it is a help request rather than an actionable iss #### Issues in other products. -If an issue is in a product that is not part of the Flutter project, such as a third-party package, close the issue with a comment suggesting that the reporter file the issue with the authors of that product, and add `r: invalid`. However, if there's a reason to believe that an issue involving a third-party product is *caused* by Flutter (for exmaple, a tool or engine change that unexpectedly breaks a third-party plugin), don't close it, and triage it based on the potential Flutter cause. +If an issue is in a product that is not part of the Flutter project, such as a third-party package, close the issue with a comment suggesting that the reporter file the issue with the authors of that product, and add `r: invalid`. However, if there's a reason to believe that an issue involving a third-party product is *caused* by Flutter (for example, a tool or engine change that unexpectedly breaks a third-party plugin), don't close it, and triage it based on the potential Flutter cause. ### Labels diff --git a/engine/src/flutter/.ci.yaml b/engine/src/flutter/.ci.yaml index 12a9d757a2525..829a86698a653 100644 --- a/engine/src/flutter/.ci.yaml +++ b/engine/src/flutter/.ci.yaml @@ -302,6 +302,26 @@ targets: # at https://github.com/flutter/flutter/issues/152186. cores: "8" + - name: Linux linux_arm64_android_aot_engine + bringup: true + recipe: engine_v2/engine_v2 + timeout: 120 + properties: + add_recipes_cq: "true" + release_build: "true" + config_name: linux_arm64_android_aot_engine + # Do not remove(https://github.com/flutter/flutter/issues/144644) + # Scheduler will fail to get the platform + drone_dimensions: + - os=Linux + - cpu=arm64 + dimensions: + # This is needed so that orchestrators that only spawn subbuilds are not + # assigned to the large 32 core workers when doing release builds. + # For more details see the issue + # at https://github.com/flutter/flutter/issues/152186. + cores: "8" + - name: Linux linux_android_aot_engine_ddm recipe: engine_v2/engine_v2 timeout: 120 diff --git a/engine/src/flutter/ci/builders/linux_arm64_android_aot_engine.json b/engine/src/flutter/ci/builders/linux_arm64_android_aot_engine.json new file mode 100644 index 0000000000000..12beae96f4ea6 --- /dev/null +++ b/engine/src/flutter/ci/builders/linux_arm64_android_aot_engine.json @@ -0,0 +1,357 @@ +{ + "_comment": [ + "The builds defined in this file should not contain tests, ", + "and the file should not contain builds that are essentially tests. ", + "The only builds in this file should be the builds necessary to produce ", + "release artifacts. ", + "Tests to run on linux hosts should go in one of the other linux_ build ", + "definition files." + ], + "luci_flags": { + "upload_content_hash": true + }, + "builds": [ + { + "archives": [ + { + "base_path": "out/ci/android_profile/zip_archives/", + "type": "gcs", + "include_paths": [ + "out/ci/android_profile/zip_archives/android-arm-profile/linux-arm64.zip" + ], + "name": "ci/android_profile", + "realm": "production" + } + ], + "drone_dimensions": [ + "device_type=none", + "os=Linux", + "cpu=arm64" + ], + "gclient_variables": { + "use_rbe": true + }, + "gn": [ + "--target-dir", + "ci/android_profile", + "--runtime-mode", + "profile", + "--android", + "--android-cpu", + "arm", + "--rbe", + "--no-goma" + ], + "name": "ci/android_profile", + "description": "Produces profile mode artifacts to target 32-bit arm Android from an ARM64 Linux host.", + "ninja": { + "config": "ci/android_profile", + "targets": [ + "flutter/lib/snapshot", + "flutter/shell/platform/android:gen_snapshot" + ] + } + }, + { + "archives": [ + { + "base_path": "out/ci/android_release/zip_archives/", + "type": "gcs", + "include_paths": [ + "out/ci/android_release/zip_archives/android-arm-release/linux-arm64.zip" + ], + "name": "ci/android_release", + "realm": "production" + } + ], + "drone_dimensions": [ + "device_type=none", + "os=Linux", + "cpu=arm64" + ], + "gclient_variables": { + "use_rbe": true + }, + "gn": [ + "--target-dir", + "ci/android_release", + "--runtime-mode", + "release", + "--android", + "--android-cpu", + "arm", + "--rbe", + "--no-goma" + ], + "name": "ci/android_release", + "description": "Produces release mode artifacts to target 32-bit arm Android from an ARM64 Linux host.", + "ninja": { + "config": "ci/android_release", + "targets": [ + "flutter/lib/snapshot", + "flutter/shell/platform/android:gen_snapshot" + ] + } + }, + { + "archives": [ + { + "base_path": "out/ci/android_profile_arm64/zip_archives/", + "type": "gcs", + "include_paths": [ + "out/ci/android_profile_arm64/zip_archives/android-arm64-profile/linux-arm64.zip", + "out/ci/android_profile_arm64/zip_archives/android-arm64-profile/analyze-snapshot-linux-arm64.zip" + ], + "name": "ci/android_profile_arm64", + "realm": "production" + } + ], + "drone_dimensions": [ + "device_type=none", + "os=Linux", + "cpu=arm64" + ], + "gclient_variables": { + "use_rbe": true + }, + "gn": [ + "--target-dir", + "ci/android_profile_arm64", + "--runtime-mode", + "profile", + "--android", + "--android-cpu", + "arm64", + "--rbe", + "--no-goma" + ], + "name": "ci/android_profile_arm64", + "description": "Produces profile mode artifacts to target 64-bit arm Android from an ARM64 Linux host.", + "ninja": { + "config": "ci/android_profile_arm64", + "targets": [ + "flutter/lib/snapshot", + "flutter/shell/platform/android:gen_snapshot", + "flutter/shell/platform/android:analyze_snapshot" + ] + } + }, + { + "archives": [ + { + "base_path": "out/ci/android_release_arm64/zip_archives/", + "type": "gcs", + "include_paths": [ + "out/ci/android_release_arm64/zip_archives/android-arm64-release/linux-arm64.zip", + "out/ci/android_release_arm64/zip_archives/android-arm64-release/analyze-snapshot-linux-arm64.zip" + ], + "name": "ci/android_release_arm64", + "realm": "production" + } + ], + "drone_dimensions": [ + "device_type=none", + "os=Linux", + "cpu=arm64" + ], + "gclient_variables": { + "use_rbe": true + }, + "gn": [ + "--target-dir", + "ci/android_release_arm64", + "--runtime-mode", + "release", + "--android", + "--android-cpu", + "arm64", + "--rbe", + "--no-goma" + ], + "name": "ci/android_release_arm64", + "description": "Produces release mode artifacts to target 64-bit arm Android from an ARM64 Linux host.", + "ninja": { + "config": "ci/android_release_arm64", + "targets": [ + "flutter/lib/snapshot", + "flutter/shell/platform/android:gen_snapshot", + "flutter/shell/platform/android:analyze_snapshot" + ] + } + }, + { + "archives": [ + { + "base_path": "out/ci/android_profile_x64/zip_archives/", + "type": "gcs", + "include_paths": [ + "out/ci/android_profile_x64/zip_archives/android-x64-profile/linux-arm64.zip", + "out/ci/android_profile_x64/zip_archives/android-x64-profile/analyze-snapshot-linux-arm64.zip" + ], + "name": "ci/android_profile_x64", + "realm": "production" + } + ], + "drone_dimensions": [ + "device_type=none", + "os=Linux", + "cpu=arm64" + ], + "gclient_variables": { + "use_rbe": true + }, + "gn": [ + "--target-dir", + "ci/android_profile_x64", + "--runtime-mode", + "profile", + "--android", + "--android-cpu", + "x64", + "--rbe", + "--no-goma" + ], + "name": "ci/android_profile_x64", + "description": "Produces profile mode artifacts to target x64 Android from an ARM64 Linux host.", + "ninja": { + "config": "ci/android_profile_x64", + "targets": [ + "flutter/lib/snapshot", + "flutter/shell/platform/android:gen_snapshot", + "flutter/shell/platform/android:analyze_snapshot" + ] + } + }, + { + "archives": [ + { + "base_path": "out/ci/android_release_x64/zip_archives/", + "type": "gcs", + "include_paths": [ + "out/ci/android_release_x64/zip_archives/android-x64-release/linux-arm64.zip", + "out/ci/android_release_x64/zip_archives/android-x64-release/analyze-snapshot-linux-arm64.zip" + ], + "name": "ci/android_release_x64", + "realm": "production" + } + ], + "drone_dimensions": [ + "device_type=none", + "os=Linux", + "cpu=arm64" + ], + "gclient_variables": { + "use_rbe": true + }, + "gn": [ + "--target-dir", + "ci/android_release_x64", + "--runtime-mode", + "release", + "--android", + "--android-cpu", + "x64", + "--rbe", + "--no-goma" + ], + "name": "ci/android_release_x64", + "description": "Produces release mode artifacts to target x64 Android from an ARM64 Linux host.", + "ninja": { + "config": "ci/android_release_x64", + "targets": [ + "flutter/lib/snapshot", + "flutter/shell/platform/android:gen_snapshot", + "flutter/shell/platform/android:analyze_snapshot" + ] + } + }, + { + "archives": [ + { + "base_path": "out/ci/android_profile_riscv64/zip_archives/", + "type": "gcs", + "include_paths": [ + "out/ci/android_profile_riscv64/zip_archives/android-riscv64-profile/linux-arm64.zip", + "out/ci/android_profile_riscv64/zip_archives/android-riscv64-profile/analyze-snapshot-linux-arm64.zip" + ], + "name": "ci/android_profile_riscv64", + "realm": "production" + } + ], + "drone_dimensions": [ + "device_type=none", + "os=Linux", + "cpu=arm64" + ], + "gclient_variables": { + "use_rbe": true + }, + "gn": [ + "--target-dir", + "ci/android_profile_riscv64", + "--runtime-mode", + "profile", + "--android", + "--android-cpu", + "riscv64", + "--rbe", + "--no-goma" + ], + "name": "ci/android_profile_riscv64", + "description": "Produces profile mode artifacts to target riscv64 Android from an ARM64 Linux host.", + "ninja": { + "config": "ci/android_profile_riscv64", + "targets": [ + "flutter/lib/snapshot", + "flutter/shell/platform/android:gen_snapshot", + "flutter/shell/platform/android:analyze_snapshot" + ] + } + }, + { + "archives": [ + { + "base_path": "out/ci/android_release_riscv64/zip_archives/", + "type": "gcs", + "include_paths": [ + "out/ci/android_release_riscv64/zip_archives/android-riscv64-release/linux-arm64.zip", + "out/ci/android_release_riscv64/zip_archives/android-riscv64-release/analyze-snapshot-linux-arm64.zip" + ], + "name": "ci/android_release_riscv64", + "realm": "production" + } + ], + "drone_dimensions": [ + "device_type=none", + "os=Linux", + "cpu=arm64" + ], + "gclient_variables": { + "use_rbe": true + }, + "gn": [ + "--target-dir", + "ci/android_release_riscv64", + "--runtime-mode", + "release", + "--android", + "--android-cpu", + "riscv64", + "--rbe", + "--no-goma" + ], + "name": "ci/android_release_riscv64", + "description": "Produces release mode artifacts to target riscv64 Android from an ARM64 Linux host.", + "ninja": { + "config": "ci/android_release_riscv64", + "targets": [ + "flutter/lib/snapshot", + "flutter/shell/platform/android:gen_snapshot", + "flutter/shell/platform/android:analyze_snapshot" + ] + } + } + ], + "generators": {}, + "archives": [] +} diff --git a/engine/src/flutter/impeller/renderer/backend/metal/context_mtl.mm b/engine/src/flutter/impeller/renderer/backend/metal/context_mtl.mm index 74baf948b4133..30cee619104cd 100644 --- a/engine/src/flutter/impeller/renderer/backend/metal/context_mtl.mm +++ b/engine/src/flutter/impeller/renderer/backend/metal/context_mtl.mm @@ -488,6 +488,8 @@ new ContextMTL(flags, device, command_queue, current_capture_scope_ = [[MTLCaptureManager sharedCaptureManager] newCaptureScopeWithDevice:device]; [current_capture_scope_ setLabel:@"Impeller Frame"]; + [[MTLCaptureManager sharedCaptureManager] + setDefaultCaptureScope:current_capture_scope_]; } bool ImpellerMetalCaptureManager::CaptureScopeActive() const { diff --git a/engine/src/flutter/lib/web_ui/docs/FALLBACK_FONT_SERVICE.md b/engine/src/flutter/lib/web_ui/docs/FALLBACK_FONT_SERVICE.md new file mode 100644 index 0000000000000..681abbdc55d6c --- /dev/null +++ b/engine/src/flutter/lib/web_ui/docs/FALLBACK_FONT_SERVICE.md @@ -0,0 +1,131 @@ +# FallbackFontService Design Document + +## Section 0: The Problem Statement + +In Flutter Web applications, text rendering depends on the fonts provided by the developer in the application's asset bundle. When a piece of text contains characters (such as CJK scripts, emojis, or rare symbols) that are not covered by any of the fonts included in the asset bundle, the engine invokes an automatic "font fallback" system. This system identifies the missing characters and attempts to download the appropriate "Noto" fonts from a CDN (typically Google Fonts) to ensure the text is legible. + +Currently, this fallback system has a critical reliability flaw: **It does not gracefully handle network or server failures.** + +If a required fallback font fails to download—whether due to a 404 error, a broken CDN link, or a transient network interruption—the engine enters an **infinite loop**. Because it does not track these failures effectively, it continuously attempts to download the same failing font. Each attempt notifies the application that "fonts have changed," triggering a UI relayout. This relayout rediscovers that the characters are still missing and restarts the download attempt immediately. + +For the end-user and the business, this results in: +1. **System Instability:** The infinite loop consumes excessive CPU and battery power, causing the web application to become laggy, hot, or unresponsive. +2. **Network Abuse:** The application spams font servers with thousands of redundant requests, which can lead to rate-limiting, increased infrastructure costs, and a poor reputation for the app's network behavior. +3. **Broken Aesthetics:** Users are left with "tofu" (empty boxes), and the engine fails to provide a "best-effort" recovery—such as attempting a secondary fallback font that might actually be reachable. +4. **Operational Noise:** Browser consoles are flooded with identical error logs, making it nearly impossible for developers to debug other issues or maintain a production-grade application. + +We need a solution that makes the font fallback system **autonomous and resilient**, ensuring it can recover from transient errors, find alternative fonts when primary ones fail, and—above all—terminate its attempts once it has exhausted all viable options. + +## Section 1: The Technical Implementation Plan + +To solve this problem, we are rebuilding the font fallback system as a dedicated, smart background service called the **FallbackFontService**. This service will act like a "concierge" for missing characters: instead of the rendering engine frantically trying to manage downloads on its own, it will simply hand a list of missing characters to this service and trust it to handle the rest. + +The plan consists of four major components working together: + +### 1. The "Request Queue" (Skia-Driven Discovery) +Currently, the engine manually checks every string of text to see if it *might* need a fallback font based on its own records. We will replace this manual check with a more direct approach: we will ask the underlying graphics engine (**Skia**) for the truth. During the "layout" phase (when the app calculates exactly where text should go), we will call a specific function to get the exact list of characters that the current fonts—including any fallback fonts already loaded—could not handle. These "unresolved" characters are added to a global "Unprocessed" list in the service. By letting Skia be the source of truth, we ensure the service only acts when a character is genuinely missing from the screen. + +### 2. The Autonomous Manager (FallbackFontService) +The service manages its own state independently of the main application's UI cycle, using an event-driven "convergence" model. When new characters arrive in the "Unprocessed" list, the service: +* **Filters out duplicates:** It ignores characters it is already trying to download or that it has already failed to find. +* **Picks the best fonts:** It uses "greedy" logic to find the smallest number of fonts that cover the most missing characters. +* **Consults the Fallback Data:** The service uses a mapping of characters to fonts stored in `lib/src/engine/font_fallback_data.dart`. This is a large, generated file that encodes exactly which Noto fonts provide glyphs for which Unicode ranges. +* **Manages downloads:** It handles the actual HTTP requests, ensuring we don't overwhelm the user's connection by limiting how many fonts download at once. + +### 3. The Smart Retry & Recovery System +This is the "brain" of the fix. If a font fails to download, the service doesn't just give up or loop forever. +* **Transient Errors:** If the network blips, it waits 1 second and tries again (up to 3 times). +* **Permanent Failures:** If a font is simply not there (a 404 error) or fails all retries, the service marks that font as "Permanently Unavailable." +* **Self-Healing:** The service then immediately re-evaluates the missing characters. Because it knows which fonts are broken, it will automatically look for the "next best" font to cover those characters. If no other fonts exist, it marks those characters as "Unsupported" and stops trying. This is what finally breaks the infinite loop. +* **Global Kill Switch:** To protect against systemic misconfigurations (e.g., a broken `fontFallbackBaseUrl`), the service tracks total permanent failures. If 10 fonts fail permanently and zero have succeeded, the service declares itself "broken" and stops all future attempts for the session. +* **Per-Component Cap:** To prevent a single character from triggering hundreds of requests (e.g., a common character covered by many fonts), the service limits the number of candidate fonts it will attempt for any single Unicode component to 5. If all 5 fail, the component is marked as unsupported. + +### 4. The Feedback Loop +The service only talks back to the Flutter framework when it actually succeeds. When a font is successfully downloaded and registered, the service tells the framework: *"I have new fonts; please redraw the screen."* If a font fails and no replacement can be found, the service stays silent. The user will see a placeholder (like an empty box), but the app will remain stable and the network will go quiet. + +### Ecosystem Fit +This feature sits deep within the "Web Engine" layer of Flutter. It bridges the gap between the high-level Flutter framework and the low-level browser environment. By moving this logic into a centralized service, we make the engine more efficient for all Flutter Web developers, providing a "fire and forget" system that handles the complexities of global typography and unreliable networks automatically. + +## Section 2: Alternatives Considered + +During the design process, we explored several other approaches but ultimately ruled them out in favor of the more robust `FallbackFontService` model. + +### 1. Immediate "Best-Effort" Replacement +We considered a strategy where, if the primary font (Font A) failed even once, the service would immediately start downloading the secondary font (Font B). +* **Why we ruled it out:** This was deemed too aggressive and wasteful of the user's bandwidth. In many cases, a single network blip is temporary. By jumping to Font B immediately, we risked downloading multiple large fonts for the same set of characters, potentially causing "layout jitter" where text changes its appearance multiple times as different fonts arrive. We decided it was better to give the primary font a fair chance to succeed through retries before looking for a substitute. + +### 2. Dependency on the Framework for Retries +One early thought was to keep the current model where the framework's "re-layout" signal drives the retry logic. In this model, we would simply stop the loop by marking a font as failed. +* **Why we ruled it out:** This kept the engine in a "passive" state. If we failed to download any fonts in a batch and didn't notify the framework, the engine would essentially "fall asleep" and never try to find a replacement for those missing characters until some other unrelated UI change happened. We realized the engine needs to be **proactive**—it should be able to say, "Font A failed; let me immediately see if Font B can help," without needing the framework to ask it to try again. + +### 3. Filtering characters before enqueuing them +We discussed filtering out characters that were already covered by "pending" downloads before they even entered the service's queue. +* **Why we ruled it out:** This created a dangerous "memory loss" problem. If we filtered out a character because Font A was *supposed* to cover it, and then Font A failed to download, the service would have no record that the character still needed covering. By keeping all missing characters in the "Unprocessed" list until they are truly resolved or exhausted, we ensure that no requirement is ever forgotten, regardless of how many individual fonts fail. + +### 4. Maintaining a manual "Shadow Cache" of fonts +We considered having the service maintain its own comprehensive list of every character covered by every font it has ever seen to avoid talking to Skia so often. +* **Why we ruled it out:** This added unnecessary complexity and the risk of the service getting "out of sync" with the actual graphics engine. Since Skia is the ultimate authority on what it can and cannot render, it is much simpler and more accurate to ask it for the "unresolved" list directly during layout. This eliminates the need for the service to try and mirror Skia's complex internal font-matching logic. + +## Section 3: Detailed Implementation Plan + +This section outlines the surgical changes required to implement the `FallbackFontService` architecture. The goal is to centralize fallback logic, utilize Skia’s internal layout state, and implement a resilient retry/recovery loop. + +### 1. New Core Infrastructure + +* **File: `lib/src/engine/font_fallback_service.dart` (New File)** + * **Rationale:** To house the `FallbackFontService` class. This centralizes the `_unprocessedCodePoints`, `_pendingFonts`, and `_permanentlyUnavailableFonts` state. It will contain the "Event-Driven Convergence" logic, the stateless greedy algorithm, and the smart fetcher. +* **File: `lib/src/engine/noto_font.dart` (Refactor)** + * **Rationale:** To make the `NotoFont` data class stateless, removing any internal tracking that would interfere with the `FallbackFontService` greedy selection algorithm. +* **File: `lib/src/engine/font_fallbacks.dart` (Major Refactor)** + * **Rationale:** We will refactor the existing `FontFallbackManager` and `_FallbackFontDownloadQueue` logic into the new service. The `NotoFont` and `FallbackFontComponent` classes must be made stateless (removing `coverCount` and `coverComponents`) to allow the greedy algorithm to run safely and predictably during autonomous re-evaluations. + +### 2. Renderer Interface Updates + +* **File: `lib/src/engine/canvaskit/canvaskit_api.dart`** + * **Rationale:** Add the missing JS-Interop binding for `getUnresolvedCodepoints()` to the `SkParagraph` extension type. + * **Technical Detail:** The underlying JS/WASM method on the `SkParagraph` object takes **no arguments** and returns a `JSArray` representing the Unicode code points. + * **Usage:** This is the "source of truth" required to move away from string-based discovery. +* **File: `lib/src/engine/skwasm/skwasm_impl/raw/text/raw_paragraph.dart`** + * **Rationale:** Ensure the FFI binding `paragraphGetUnresolvedCodePoints` is correctly exposed and documented for use in the unified fallback path. +* **File: `lib/src/engine/font_fallbacks.dart` (Interface change)** + * **Rationale:** Update the `FallbackFontRegistry` abstract class. Change `loadFallbackFont(String name, String url)` to `Future loadFallbackFont(String name, Uint8List bytes)`. The return value indicates whether the renderer successfully registered the font. This shifts HTTP responsibility to the `FallbackFontService` and allows it to track registration failures. + +### 3. Renderer Implementation Updates + +* **File: `lib/src/engine/canvaskit/fonts.dart` (`SkiaFontCollection`)** +* **File: `lib/src/engine/skwasm/skwasm_impl/font_collection.dart` (`SkwasmFontCollection`)** + * **Rationale:** Both font collections now implement the unified `FlutterFontCollection` interface, which mandates the presence of a `FontFallbackManager` and a `FallbackFontRegistry`. + * **Architecture:** Each collection now owns its respective registry implementation (`SkiaFallbackRegistry` and `SkwasmFallbackRegistry`) and initializes a `FontFallbackManager` to bridge the gap between the `FallbackFontService` and the renderer-specific font stack. + * **State Management:** + * `SkiaFontCollection` was updated to maintain a `registeredFallbackFonts` list, and its `_registerWithFontProvider()` method now rebuilds the Skia font provider by combining both asset fonts and dynamically loaded fallback fonts. + * `SkwasmFontCollection` now utilizes `setDefaultFontFamilies()` to synchronize the renderer's default font stack with the global fallback list managed by the service. + * **Lifecycle:** Added `debugResetFallbackFonts()` to both implementations to ensure clean state during unit and golden testing, allowing the `FallbackFontService` to be reset independently of the main font stack. + +* **File: `lib/src/engine/canvaskit/fonts.dart` (`SkiaFallbackRegistry`)** +* **File: `lib/src/engine/skwasm/skwasm_impl/font_collection.dart` (`SkwasmFallbackRegistry`)** + * **Rationale:** These new registry classes implement the `FallbackFontRegistry` interface, providing the concrete logic for injecting font bytes into the respective WASM heaps and triggering the necessary font-provider updates. + * **Technical Detail:** + * `loadFallbackFont(name, bytes)` handles the creation of a typeface from raw bytes. + * `updateFallbackFontFamilies(families)` triggers the renderer-specific logic to update the font-matching order (e.g., rebuilding the `TypefaceFontProvider` in Skia or updating the default text style in Skwasm). +* **File: `lib/src/engine/canvaskit/text.dart` (`CkParagraph.layout`)** +* **File: `lib/src/engine/skwasm/skwasm_impl/paragraph.dart` (`SkwasmParagraph.layout`)** + * **Rationale:** Update the `layout()` method in both renderers to call `getUnresolvedCodepoints()` from Skia. If unresolved characters are found, they will call `FallbackFontService.instance.addMissingCodePoints(list)`. This unified the discovery mechanism for both backends. + * **Optimization:** Added a `_hasCheckedForMissingCodePoints` flag to both paragraph implementations to ensure that we only query Skia once per paragraph life-cycle, avoiding redundant work during repeated layouts. +* **File: `lib/src/engine/canvaskit/text.dart` (`CkParagraphBuilder.addText`)** + * **Rationale:** Remove the call to `ensureFontsSupportText()`. This eliminates the expensive string-based check during paragraph building, significantly improving performance for text-heavy applications. + +### 4. Cleanup and Performance + +* **File: `lib/src/engine/font_change_util.dart`** + * **Rationale:** Verify the debouncing logic in `sendFontChangeMessage()`. We will rely on this to ensure that if multiple fonts in a batch succeed, we only trigger a single framework relayout per animation frame. +* **File: `lib/src/engine/dom.dart`** + * **Rationale:** Ensure `httpFetch` is robustly exposed for the `FallbackFontService` to use for its "sophisticated" fetching (checking `response.ok` for 404s). + +### 5. Testing and Validation + +* **File: `test/engine/font_fallback_service_test.dart` (New File)** + * **Rationale:** Create a suite of unit tests for the new service. These will mock network failures, 404s, and successful downloads to verify that the "True Missing" logic correctly falls back to alternative fonts and eventually terminates the retry loop. +* **File: `test/ui/fallback_fonts_golden_test.dart`** + * **Rationale:** Update existing golden tests to use the new `waitForIdle()` definition and ensure that "Permanent Failures" correctly render as tofu without causing infinite test timeouts. +* **File: `lib/src/engine/configuration.dart`** + * **Rationale:** Add a new configuration flag (e.g., `debugSkipFontRetryDelay`) to allow tests to run without waiting for the 1-second backoff timer. diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine.dart b/engine/src/flutter/lib/web_ui/lib/src/engine.dart index 622fe8dd511da..8ea8ab8480a98 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine.dart @@ -55,6 +55,7 @@ export 'engine/display.dart'; export 'engine/dom.dart'; export 'engine/font_change_util.dart'; export 'engine/font_fallback_data.dart'; +export 'engine/font_fallback_service.dart'; export 'engine/font_fallbacks.dart'; export 'engine/fonts.dart'; export 'engine/frame_service.dart'; diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart index 44d8f40d4633a..07435247a2502 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/canvaskit_api.dart @@ -2265,6 +2265,13 @@ extension type SkParagraph(JSObject _) implements JSObject { ui.GlyphInfo? getClosestGlyphInfoAt(double x, double y) => _getClosestGlyphInfoAtCoordinate(x, y)?._glyphInfo; + @JS('unresolvedCodepoints') + external JSArray _getUnresolvedCodePoints(); + List getUnresolvedCodePoints() { + final List jsNumbers = _getUnresolvedCodePoints().toDart; + return List.generate(jsNumbers.length, (int i) => jsNumbers[i].toDartInt); + } + external SkTextRange getWordBoundary(double position); external void layout(double width); external void delete(); diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/fonts.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/fonts.dart index aeef3cf47bc8e..23a3cfd49f25c 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/fonts.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/fonts.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'dart:typed_data'; +import 'package:meta/meta.dart'; import 'package:ui/src/engine.dart'; import 'package:ui/ui_web/src/ui_web.dart' as ui_web; @@ -18,10 +19,26 @@ String _robotoUrl = /// Manages the fonts used in the Skia-based backend. class SkiaFontCollection implements FlutterFontCollection { + SkiaFontCollection() { + _fallbackRegistry = SkiaFallbackRegistry(this); + fontFallbackManager = FontFallbackManager(_fallbackRegistry); + } + final Set _downloadedFontFamilies = {}; @override - late FontFallbackManager fontFallbackManager = FontFallbackManager(SkiaFallbackRegistry(this)); + late FontFallbackManager? fontFallbackManager; + + late FallbackFontRegistry _fallbackRegistry; + + @override + FallbackFontRegistry get fallbackFontRegistry => _fallbackRegistry; + + @visibleForTesting + @override + set fallbackFontRegistry(FallbackFontRegistry? registry) { + _fallbackRegistry = registry!; + } /// Fonts that started the download process, but are not yet registered. /// @@ -71,7 +88,7 @@ class SkiaFontCollection implements FlutterFontCollection { } @override - Future loadFontFromList(Uint8List list, {String? fontFamily}) async { + Future loadFontFromBytes(Uint8List list, {String? fontFamily}) async { // Make sure CanvasKit is actually loaded await renderer.initialize(); @@ -206,6 +223,8 @@ class SkiaFontCollection implements FlutterFontCollection { @override void debugResetFallbackFonts() { + // ignore: invalid_use_of_visible_for_testing_member + FallbackFontService.instance.debugReset(); fontFallbackManager = FontFallbackManager(SkiaFallbackRegistry(this)); registeredFallbackFonts.clear(); } @@ -254,48 +273,20 @@ class SkiaFallbackRegistry implements FallbackFontRegistry { final SkiaFontCollection _fontCollection; @override - List getMissingCodePoints(List codeUnits, List fontFamilies) { - final fonts = []; - for (final font in fontFamilies) { - final List? typefacesForFamily = _fontCollection.familyToFontMap[font]; - if (typefacesForFamily != null) { - fonts.addAll(typefacesForFamily); - } - } - final codePointsSupported = List.filled(codeUnits.length, false); - final testString = String.fromCharCodes(codeUnits); - for (final font in fonts) { - final Uint16List glyphs = font.getGlyphIDs(testString); - assert(glyphs.length == codePointsSupported.length); - for (var i = 0; i < glyphs.length; i++) { - codePointsSupported[i] |= glyphs[i] != 0; - } - } - - final missingCodeUnits = []; - for (var i = 0; i < codePointsSupported.length; i++) { - if (!codePointsSupported[i]) { - missingCodeUnits.add(codeUnits[i]); - } - } - return missingCodeUnits; - } - - @override - Future loadFallbackFont(String familyName, String url) async { - final ByteBuffer buffer = await httpFetchByteBuffer(url); + Future loadFallbackFont(String familyName, Uint8List bytes) async { + final ByteBuffer buffer = bytes.buffer; final SkTypeface? typeface = canvasKit.Typeface.MakeFreeTypeFaceFromData(buffer); if (typeface == null) { - printWarning('Failed to parse fallback font $familyName as a font.'); - return; + return false; } _fontCollection.registeredFallbackFonts.add( RegisteredFont(buffer.asUint8List(), familyName, typeface), ); + return true; } @override void updateFallbackFontFamilies(List families) { - _fontCollection.registerDownloadedFonts(); + _fontCollection._registerWithFontProvider(); } } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/text.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/text.dart index 5ca36106cfede..f6f0ce979e28e 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/text.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/canvaskit/text.dart @@ -807,6 +807,8 @@ class CkParagraph implements ui.Paragraph { /// is deleted. double _lastLayoutConstraints = double.negativeInfinity; + bool _hasCheckedForMissingCodePoints = false; + /// The paragraph style used to build this paragraph. /// /// This is used to resurrect the paragraph if the initial paragraph @@ -949,6 +951,15 @@ class CkParagraph implements ui.Paragraph { _minIntrinsicWidth = paragraph.getMinIntrinsicWidth(); _width = paragraph.getMaxWidth(); _boxesForPlaceholders = skRectsToTextBoxes(paragraph.getRectsForPlaceholders()); + + if (!ui_web.TestEnvironment.instance.disableFontFallbacks && + !_hasCheckedForMissingCodePoints) { + _hasCheckedForMissingCodePoints = true; + final List unresolvedCodePoints = paragraph.getUnresolvedCodePoints(); + if (unresolvedCodePoints.isNotEmpty) { + FallbackFontService.instance.addMissingCodePoints(unresolvedCodePoints); + } + } } catch (e) { printWarning( 'CanvasKit threw an exception while laying ' @@ -1139,15 +1150,6 @@ class CkParagraphBuilder implements ui.ParagraphBuilder { @override void addText(String text) { - final fontFamilies = []; - final CkTextStyle style = _peekStyle(); - if (style.effectiveFontFamily != null) { - fontFamilies.add(style.effectiveFontFamily!); - } - if (style.effectiveFontFamilyFallback != null) { - fontFamilies.addAll(style.effectiveFontFamilyFallback!); - } - renderer.fontCollection.fontFallbackManager!.ensureFontsSupportText(text, fontFamilies); _paragraphBuilder.addText(text); } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/configuration.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/configuration.dart index e9e7274367df5..d44cca328cc63 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/configuration.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/configuration.dart @@ -361,6 +361,12 @@ class FlutterConfiguration { String get fontFallbackBaseUrl => _configuration?.fontFallbackBaseUrl ?? 'https://fonts.gstatic.com/s/'; + /// If set to true, the engine will skip the 1 second delay between font + /// download retries. + /// + /// This is used for testing. + bool get debugSkipFontRetryDelay => _configuration?.debugSkipFontRetryDelay ?? false; + bool get forceSingleThreadedSkwasm => _configuration?.forceSingleThreadedSkwasm ?? false; } @@ -382,6 +388,7 @@ extension type JsFlutterConfiguration._(JSObject _) implements JSObject { String? nonce, String? renderer, String? fontFallbackBaseUrl, + bool? debugSkipFontRetryDelay, bool? forceSingleThreadedSkwasm, }); @@ -397,6 +404,7 @@ extension type JsFlutterConfiguration._(JSObject _) implements JSObject { external String? get nonce; external String? get renderer; external String? get fontFallbackBaseUrl; + external bool? get debugSkipFontRetryDelay; external bool? get forceSingleThreadedSkwasm; } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/font_fallback_service.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/font_fallback_service.dart new file mode 100644 index 0000000000000..28eba5db65a67 --- /dev/null +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/font_fallback_service.dart @@ -0,0 +1,642 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; + +import 'configuration.dart'; +import 'dom.dart'; +import 'font_change_util.dart'; +import 'font_fallbacks.dart'; +import 'noto_font.dart'; +import 'renderer.dart'; +import 'util.dart'; + +class FallbackFontService { + FallbackFontService._(); + + static final FallbackFontService instance = FallbackFontService._(); + + /// Code points that we have discovered are missing but haven't processed yet. + final Set _unprocessedCodePoints = {}; + + /// Code points that we have already tried to find fonts for and failed. + final Set _unsupportedCodePoints = {}; + + /// Fonts that are currently being downloaded. + final Set _pendingFonts = {}; + + /// Fonts that have failed to download or register permanently. + final Set _permanentlyUnavailableFonts = {}; + + /// Fonts that have been successfully downloaded and registered. + final Set _registeredFonts = {}; + + /// Tracks how many fonts have failed for each component. + final Map _failedFontsPerComponent = {}; + + /// The total number of fonts that have failed permanently across all requests. + int _totalPermanentFailures = 0; + + /// Whether the service has been disabled due to too many global failures. + bool _isBroken = false; + + /// Timer used for debouncing the processing loop. + Timer? _processTimer; + + /// Timer used for debouncing the font change notification. + Timer? _notifyTimer; + + /// Completer for [waitForIdle]. + Completer? _idleCompleter; + + /// The number of retries per font. + static const int _maxRetries = 3; + + /// The delay between retries. + static const Duration _retryDelay = Duration(seconds: 1); + + /// The maximum number of global permanent failures allowed before the service + /// is disabled (if no fonts have been successfully registered). + static const int _maxGlobalFailuresBeforeBroken = 10; + + /// The maximum number of candidate fonts we will attempt to download for any + /// single [FallbackFontComponent] before marking it as unsupported. + static const int _maxFontsPerComponent = 5; + + /// Adds a list of missing code points to be processed. + void addMissingCodePoints(List codePoints) { + var added = false; + final FontFallbackManager manager = renderer.fontCollection.fontFallbackManager!; + + // Caches to avoid redundant binary-search lookups for consecutive + // characters (common in script blocks like CJK or Arabic). + FallbackFontComponent? lastComponent; + bool? lastComponentCovered; + + // Filter out code points that we already know are unsupported or are + // already in the queue to be processed. For the remaining code points, + // determine which fallback font component covers them and whether that + // component is already satisfied by a registered font. + for (final codePoint in codePoints) { + // Skip if we already know this character can't be rendered or if it's + // already scheduled for processing. + if (!_unsupportedCodePoints.contains(codePoint) && + !_unprocessedCodePoints.contains(codePoint)) { + final FallbackFontComponent component = manager.codePointToComponents.lookup(codePoint); + + bool isCovered; + if (component == lastComponent) { + isCovered = lastComponentCovered!; + } else { + // Check if any of the fonts belonging to this component's block + // have already been successfully loaded. + isCovered = component.fonts.any((NotoFont f) => _registeredFonts.contains(f)); + lastComponent = component; + lastComponentCovered = isCovered; + } + + if (!isCovered) { + _unprocessedCodePoints.add(codePoint); + added = true; + } + } + } + + if (added) { + // If we added new requirements, ensure we are tracking the "idle" state + // and schedule a processing run. + _idleCompleter ??= Completer(); + _scheduleProcess(); + } + } + + /// Schedules a processing run in the next microtask. + /// + /// Debouncing processing allows multiple calls to [addMissingCodePoints] + /// in the same turn of the event loop (e.g. from different paragraphs in the + /// same frame) to be processed together. + void _scheduleProcess() { + _processTimer ??= Timer(Duration.zero, _process); + } + + /// Core processing loop of the service. + /// + /// Categorizes all unprocessed code points into: + /// 1. Resolved (already covered by a registered font) + /// 2. Pending (currently being downloaded) + /// 3. Unsupported (all candidate fonts failed permanently) + /// 4. The Gap (missing characters that need a new font download) + /// + /// Once the "Gap" is identified, it runs the greedy selection algorithm and + /// triggers download tasks for the chosen fonts. + void _process() { + _processTimer = null; + + final FontFallbackManager manager = renderer.fontCollection.fontFallbackManager!; + + // The "Gap" represents unique components that we need to find new fonts for. + // We map Component -> Count of missing codepoints it covers in this batch. + final gapComponentCounts = {}; + + // Lists to track codepoints that should be removed from the active queue. + final resolvedCodePoints = []; + final newlyUnsupported = []; + + // Caches to avoid redundant font-set iterations for codepoints that share a component. + final coveredCache = {}; + final pendingCache = {}; + final unavailableCache = {}; + + // Perform a single pass over all unprocessed codepoints to categorize them. + for (final int cp in _unprocessedCodePoints) { + if (_unsupportedCodePoints.contains(cp)) { + resolvedCodePoints.add(cp); + continue; + } + + final FallbackFontComponent component = manager.codePointToComponents.lookup(cp); + + // 1. Prune: Is it already covered by a font we successfully registered? + // This can happen if a font finished downloading while these characters + // were still in the unprocessed queue. + final bool isCovered = coveredCache.putIfAbsent( + component, + () => component.fonts.any((NotoFont f) => _registeredFonts.contains(f)), + ); + if (isCovered) { + resolvedCodePoints.add(cp); + continue; + } + + // 2. Pending: Are we already in the middle of downloading a font for this? + // We don't want to start multiple concurrent downloads for the same script block. + final bool isPending = pendingCache.putIfAbsent( + component, + () => component.fonts.any((NotoFont f) => _pendingFonts.contains(f)), + ); + if (isPending) { + // Keep in _unprocessedCodePoints so we re-evaluate when the download finishes, + // but don't add to the "Gap" (don't start a duplicate download). + continue; + } + + // 3. Permanent Failure: Have all fonts for this component failed already? + // If all candidate fonts for a character are dead, we stop trying to avoid infinite loops. + // + // We also stop if the service has been declared "broken" due to too many + // global failures, or if this specific component has exhausted its + // allowed attempt budget. + final bool isUnavailable = unavailableCache.putIfAbsent( + component, + () => + _isBroken || + component.fonts.every((NotoFont f) => _permanentlyUnavailableFonts.contains(f)) || + (_failedFontsPerComponent[component] ?? 0) >= _maxFontsPerComponent, + ); + if (isUnavailable) { + newlyUnsupported.add(cp); + resolvedCodePoints.add(cp); + continue; + } + + // 4. The Gap: This component is missing and we aren't fetching it yet. + // We track the count of codepoints to weight the greedy algorithm later. + gapComponentCounts[component] = (gapComponentCounts[component] ?? 0) + 1; + } + + // Efficiently remove resolved/failed codepoints from the processing set. + _unprocessedCodePoints.removeAll(resolvedCodePoints); + + if (newlyUnsupported.isNotEmpty) { + printWarning( + 'Could not find a set of Noto fonts to display all missing ' + 'characters. Please add a font asset for the missing characters.' + ' See: https://docs.flutter.dev/cookbook/design/fonts', + ); + _unsupportedCodePoints.addAll(newlyUnsupported); + } + + if (gapComponentCounts.isEmpty) { + _checkIdle(); + return; + } + + // Resolve the Gap: Run the greedy algorithm on unique components. + // This finds the smallest set of fonts that will resolve all current "Gaps". + final List newFonts = _findFontsForComponents(gapComponentCounts); + + if (newFonts.isEmpty) { + _checkIdle(); + return; + } + + // Fire and Forget: Start downloads for the selected fonts. + newFonts.forEach(_startDownloadTask); + } + + /// Wraps the download and registration process in a managed task. + /// + /// This ensures the [_pendingFonts] set is correctly updated and triggers + /// a re-evaluation of the queue once the task finishes (either successfully + /// or with a failure that might allow secondary fonts to be selected). + Future _startDownloadTask(NotoFont font) async { + _pendingFonts.add(font); + try { + await _downloadAndRegisterFontWithRetries(font); + } catch (e) { + printWarning('Unexpected error during fallback font download task for ${font.name}: $e'); + } finally { + _pendingFonts.remove(font); + // Only re-schedule if we potentially finished the entire queue or if a failure + // occurred that requires us to re-evaluate alternative fonts. + // If a font failed, we need to run _process again to see if there's a + // secondary fallback font that can cover the now-unmet requirements. + if (!_registeredFonts.contains(font) || _pendingFonts.isEmpty) { + _scheduleProcess(); + } + } + } + + /// Checks if the service has finished all work and notifies [waitForIdle]. + void _checkIdle() { + // We are only truly "idle" when nothing is left to process and no downloads are in flight. + if (_unprocessedCodePoints.isEmpty && _pendingFonts.isEmpty) { + if (_idleCompleter != null) { + final Completer completer = _idleCompleter!; + _idleCompleter = null; + completer.complete(); + } + } + } + + /// Implements a greedy algorithm to find the minimal set of fonts covering + /// the required components, optimized by operating on component counts. + List _findFontsForComponents(Map componentCoverCounts) { + if (componentCoverCounts.isEmpty) { + return []; + } + + final List requiredComponents = componentCoverCounts.keys.toList(); + final FontFallbackManager manager = renderer.fontCollection.fontFallbackManager!; + + // Initialize coverage state for candidate fonts. We only consider fonts + // that haven't been marked as permanently unavailable. For each candidate + // font, we track the total number of missing code points it covers across + // all components, and maintain a mapping of the font to the components it + // covers to facilitate efficient updates during the greedy selection process. + final fontCoverCounts = {}; + final fontToComponents = >{}; + final candidateFonts = {}; + + for (final component in requiredComponents) { + final int count = componentCoverCounts[component]!; + for (final NotoFont font in component.fonts) { + // Skip fonts that we've already tried and failed to load permanently. + if (_permanentlyUnavailableFonts.contains(font)) { + continue; + } + if (!fontCoverCounts.containsKey(font)) { + fontCoverCounts[font] = 0; + fontToComponents[font] = []; + candidateFonts.add(font); + } + // A font's weight is the sum of all codepoints in the blocks it covers. + fontCoverCounts[font] = fontCoverCounts[font]! + count; + fontToComponents[font]!.add(component); + } + } + + final selectedFonts = []; + while (candidateFonts.isNotEmpty) { + // Pick the "best" font based on coverage and language priority. + final NotoFont selectedFont = _selectBestFont( + candidateFonts.toList(), + fontCoverCounts, + manager, + ); + selectedFonts.add(selectedFont); + + // Once a font is selected, we consider all of its covered components + // "resolved" for this batch. + final List componentsToRemove = fontToComponents[selectedFont]!; + for (final component in componentsToRemove) { + final int count = componentCoverCounts[component]!; + if (count == 0) { + continue; + } + // Update the coverage weights for all OTHER candidate fonts that + // also covered this now-resolved component. + for (final NotoFont font in component.fonts) { + if (candidateFonts.contains(font)) { + fontCoverCounts[font] = fontCoverCounts[font]! - count; + } + } + componentCoverCounts[component] = 0; + } + + // Prune fonts that no longer provide unique coverage (weight <= 0). + candidateFonts.removeWhere((NotoFont f) => fontCoverCounts[f]! <= 0); + } + + return selectedFonts; + } + + /// Data-driven mapping of BCP47 language tags to Noto font family prefixes. + /// The order within each list represents the priority for that specific language. + static const Map> _kLanguageFontPreferences = >{ + 'zh-Hant': ['Noto Sans TC'], + 'zh-TW': ['Noto Sans TC'], + 'zh-MO': ['Noto Sans TC'], + 'zh-HK': ['Noto Sans HK', 'Noto Sans TC'], + 'ja': ['Noto Sans JP'], + 'ko': ['Noto Sans KR'], + 'zh': ['Noto Sans SC'], + 'zh-Hans': ['Noto Sans SC'], + 'zh-CN': ['Noto Sans SC', 'Noto Sans TC'], + }; + + /// Global priority list for tie-breaking when multiple fonts offer equal coverage. + static const List _kGlobalTieBreakers = [ + 'Noto Color Emoji', + 'Noto Sans Symbols', + 'Noto Sans SC', + 'Noto Sans TC', + 'Noto Sans HK', + 'Noto Sans JP', + 'Noto Sans KR', + ]; + + /// Selects the optimal font from a list of [fonts] based on a multi-stage + /// tie-breaking process. + /// + /// The selection criteria, in order of priority, are: + /// 1. **Language Preference**: Choose a font that matches the user's + /// preferred language (if specified). + /// 2. **Maximum Coverage**: Choose the font(s) that cover the greatest number + /// of currently missing code points. + /// 3. **Global Tie-Breakers**: If multiple fonts have equal maximum coverage, + /// use a predefined global priority list (e.g., favoring Emojis). + /// 4. **Deterministic Fallback**: If still tied, pick the font with the + /// lowest original index. + NotoFont _selectBestFont( + List fonts, + Map coverCounts, + FontFallbackManager manager, + ) { + assert(fonts.every((NotoFont f) => coverCounts.containsKey(f))); + final String language = manager.preferredLanguage; + + // 1. Language-Specific Preference + final List preferredPrefixes = _getPrefixesForLanguage(language); + for (final prefix in preferredPrefixes) { + final NotoFont? match = fonts.firstWhereOrNull((NotoFont f) => f.name.startsWith(prefix)); + if (match != null && coverCounts[match]! > 0) { + return match; + } + } + + // 2. Maximum Coverage Selection + final List bestFonts = _findFontsWithMaxCoverage(fonts, coverCounts); + if (bestFonts.length == 1) { + return bestFonts.first; + } + + // 3. Global Tie-Breaking + for (final String prefix in _kGlobalTieBreakers) { + final NotoFont? match = bestFonts.firstWhereOrNull((NotoFont f) => f.name.startsWith(prefix)); + if (match != null) { + return match; + } + } + + // 4. Deterministic fallback: pick the one with the smallest original index. + return bestFonts.first; + } + + /// Returns the prioritized font prefixes for a given BCP47 [lang] tag. + List _getPrefixesForLanguage(String lang) { + // 1. Exact match (e.g., 'zh-HK') + if (_kLanguageFontPreferences.containsKey(lang)) { + return _kLanguageFontPreferences[lang]!; + } + + // 2. Base language match (e.g., 'en-US' -> 'en') + final int dashIndex = lang.indexOf('-'); + if (dashIndex != -1) { + final String baseLang = lang.substring(0, dashIndex); + if (_kLanguageFontPreferences.containsKey(baseLang)) { + return _kLanguageFontPreferences[baseLang]!; + } + } + + return const []; + } + + /// Finds the subset of [fonts] that provide the maximum possible coverage + /// of missing code points, as defined by [coverCounts]. + /// + /// If multiple fonts provide the same maximum coverage, all are returned, + /// sorted by their original index to ensure deterministic behavior in + /// subsequent tie-breaking steps. + List _findFontsWithMaxCoverage(List fonts, Map coverCounts) { + assert(fonts.every((NotoFont f) => coverCounts.containsKey(f))); + var maxCovered = -1; + final bestFonts = []; + + for (final font in fonts) { + final int count = coverCounts[font]!; + if (count > maxCovered) { + // Found a new maximum; start a new list. + maxCovered = count; + bestFonts.clear(); + bestFonts.add(font); + } else if (count == maxCovered) { + // Another font with the same coverage; add to the tie-break set. + bestFonts.add(font); + } + } + + // Ensure deterministic order for tie-breaking step. + bestFonts.sort((NotoFont a, NotoFont b) => a.index.compareTo(b.index)); + return bestFonts; + } + + /// Downloads and registers a single fallback [font], implementing a resilient + /// retry strategy for network and server errors. + /// + /// The process follows these steps: + /// 1. **HTTP Fetch**: Attempts to download the font file from the configured + /// CDN. + /// 2. **Registration**: Once downloaded, the font is registered with the + /// renderer. If registration fails (e.g., due to corrupt font data), it + /// is treated as a permanent failure. + /// 3. **Retry Strategy**: + /// - **Transient Failures**: Errors like network timeouts or specific HTTP + /// status codes (e.g., 408, 429) trigger a retry after a 1-second delay. + /// A font is retried up to 3 times. + /// - **Permanent Failures**: Errors like 404 (Not Found) or illegal font + /// data cause the font to be marked as "Permanently Unavailable." + /// 4. **Notification**: On successful registration, the service notifies the + /// framework to trigger a UI relayout. + Future _downloadAndRegisterFontWithRetries(NotoFont font) async { + if (_isBroken) { + return; + } + + var attempts = 0; + final String baseUrl = configuration.fontFallbackBaseUrl; + + // Resolve the full URL from the configured CDN base. + final url = Uri.parse(baseUrl).resolve(font.url).toString(); + + // Attempt the download multiple times if transient errors occur. + while (attempts < _maxRetries) { + try { + final HttpFetchResponse response = await httpFetch(url); + + if (response.hasPayload) { + // If we got data, try to register it as a typeface in the browser. + final Uint8List bytes = await response.asUint8List(); + final bool success = await renderer.fontCollection.fallbackFontRegistry!.loadFallbackFont( + font.name, + bytes, + ); + + if (success) { + _registeredFonts.add(font); + renderer.fontCollection.fontFallbackManager!.registerFallbackFont(font.name); + _notifyFontsChanged(); + return; + } else { + // Registration failure is usually due to corrupt or invalid font data. + printWarning( + 'Failed to parse font data for ${font.name} from $url. ' + 'Treating as permanent failure.', + ); + break; + } + } else if (_isPermanentStatus(response.status)) { + // 404s and similar are permanent; other errors might be temporary. + printWarning( + 'Permanent HTTP failure (status ${response.status}) for ' + '${font.name} at $url.', + ); + break; + } else { + printWarning( + 'Transient HTTP failure (status ${response.status}) for ' + '${font.name} at $url. Retrying (attempt ${attempts + 1})...', + ); + } + } catch (e) { + // Only retry for network-level failures or timeouts. + if (e is HttpFetchError || e is TimeoutException) { + printWarning( + 'Transient error (attempt ${attempts + 1}) for ${font.name}: $e. ' + 'Retrying...', + ); + } else { + printWarning('Unexpected permanent error for ${font.name}: $e'); + break; + } + } + + attempts++; + if (attempts < _maxRetries) { + // Optional delay between retries to give the network time to recover. + if (!configuration.debugSkipFontRetryDelay) { + await Future.delayed(_retryDelay); + } + } + } + + // If all retries failed, stop trying this font forever. + printWarning('Font ${font.name} at $url is permanently unavailable.'); + _permanentlyUnavailableFonts.add(font); + + // Track failures for the global kill switch and per-component cap. + _totalPermanentFailures++; + final FontFallbackManager manager = renderer.fontCollection.fontFallbackManager!; + // Note: This is slightly inefficient as we repeat the search, but it keeps + // the download task's signature simple. + // We check all components to see which one was supposed to be covered by this font. + for (final FallbackFontComponent component in manager.fontComponents) { + if (component.fonts.contains(font)) { + _failedFontsPerComponent[component] = (_failedFontsPerComponent[component] ?? 0) + 1; + } + } + + if (_registeredFonts.isEmpty && _totalPermanentFailures >= _maxGlobalFailuresBeforeBroken) { + printWarning( + 'Font fallback service has reached the maximum number of global failures ' + 'without a single success. This may indicate a problem with the ' + 'fontFallbackBaseUrl (currently "$baseUrl"). Disabling service for this session.', + ); + _isBroken = true; + } + } + + /// Determines if an HTTP [status] code should be treated as a permanent error. + bool _isPermanentStatus(int status) { + // 4xx errors are generally permanent, except 408 (Request Timeout) and + // 429 (Too Many Requests). + return (status >= 400 && status < 500) && status != 408 && status != 429; + } + + /// Notifies the Flutter framework that fonts have changed. + /// + /// This is debounced to avoid redundant relayouts when multiple fonts are + /// registered in the same turn of the event loop. + void _notifyFontsChanged() { + // Debounce the notification to the framework. Since font downloads are + // asynchronous and can finish in batches, we wait for the next microtask + // to ensure we only send a single "font change" message even if multiple + // fonts were registered in the same turn of the event loop. + _notifyTimer ??= Timer(Duration.zero, () { + _notifyTimer = null; + final FontFallbackManager manager = renderer.fontCollection.fontFallbackManager!; + + // Update the font family lists used by Skia/CanvasKit. + manager.updateFallbackFontFamilies(); + + // Send the platform message to the Flutter framework to trigger a re-layout. + sendFontChangeMessage(); + }); + } + + /// Returns a future that completes when the service is idle. + /// + /// The service is considered idle when there are no more missing code points + /// to process and no more fonts being downloaded. + /// + /// Note that this idle state is transient; if the framework triggers another + /// layout immediately after a font is registered, new code points may be + /// enqueued, making the service no longer idle. + Future waitForIdle() { + if (_idleCompleter == null) { + return Future.value(); + } + return _idleCompleter!.future; + } + + @visibleForTesting + void debugReset() { + _unprocessedCodePoints.clear(); + _unsupportedCodePoints.clear(); + _pendingFonts.clear(); + _permanentlyUnavailableFonts.clear(); + _registeredFonts.clear(); + _failedFontsPerComponent.clear(); + _totalPermanentFailures = 0; + _isBroken = false; + _processTimer?.cancel(); + _processTimer = null; + _notifyTimer?.cancel(); + _notifyTimer = null; + _idleCompleter = null; + } +} diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/font_fallbacks.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/font_fallbacks.dart index 29f66a31baab7..8920fe8094ac7 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/font_fallbacks.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/font_fallbacks.dart @@ -3,53 +3,32 @@ // found in the LICENSE file. import 'dart:async'; +import 'dart:typed_data'; import 'package:meta/meta.dart'; import 'package:ui/src/engine.dart'; -import 'package:ui/ui_web/src/ui_web.dart' as ui_web; abstract class FallbackFontRegistry { - List getMissingCodePoints(List codePoints, List fontFamilies); - Future loadFallbackFont(String familyName, String string); + Future loadFallbackFont(String familyName, Uint8List bytes); void updateFallbackFontFamilies(List families); } -bool _isNotoSansSC(NotoFont font) => font.name.startsWith('Noto Sans SC'); -bool _isNotoSansTC(NotoFont font) => font.name.startsWith('Noto Sans TC'); -bool _isNotoSansHK(NotoFont font) => font.name.startsWith('Noto Sans HK'); -bool _isNotoSansJP(NotoFont font) => font.name.startsWith('Noto Sans JP'); -bool _isNotoSansKR(NotoFont font) => font.name.startsWith('Noto Sans KR'); - /// Global static font fallback data. class FontFallbackManager { factory FontFallbackManager(FallbackFontRegistry registry) => FontFallbackManager._(registry, getFallbackFontList()); - FontFallbackManager._(this._registry, this._fallbackFonts) - : _notoSymbols = _fallbackFonts.singleWhere( - (NotoFont font) => font.name == 'Noto Sans Symbols', - ) { - _downloadQueue = _FallbackFontDownloadQueue(this); - } + FontFallbackManager._(this._registry, this._fallbackFonts); final FallbackFontRegistry _registry; - late final _FallbackFontDownloadQueue _downloadQueue; - - /// Code points that no known font has a glyph for. - final Set _codePointsWithNoKnownFont = {}; - - /// Code points which are known to be covered by at least one fallback font. - final Set _knownCoveredCodePoints = {}; - final List _fallbackFonts; // By default, we use the system language to determine the user's preferred // language. This can be overridden through [debugUserPreferredLanguage] for testing. String _language = domWindow.navigator.language; - @visibleForTesting - String get debugUserPreferredLanguage => _language; + String get preferredLanguage => _language; @visibleForTesting set debugUserPreferredLanguage(String value) { @@ -59,98 +38,10 @@ class FontFallbackManager { @visibleForTesting void Function(String family)? debugOnLoadFontFamily; - final NotoFont _notoSymbols; - - Future _idleFuture = Future.value(); - final List globalFontFallbacks = ['Roboto']; - /// A list of code points to check against the global fallback fonts. - final Set _codePointsToCheckAgainstFallbackFonts = {}; - - /// This is [true] if we have scheduled a check for missing code points. - /// - /// We only do this once a frame, since checking if a font supports certain - /// code points is very expensive. - bool _scheduledCodePointCheck = false; - - Future debugWhenIdle() { - Future? result; - assert(() { - result = _idleFuture; - return true; - }()); - - if (result != null) { - return result!; - } - - throw UnimplementedError(); - } - - /// Determines if the given [text] contains any code points which are not - /// supported by the current set of fonts. - void ensureFontsSupportText(String text, List fontFamilies) { - // TODO(hterkelsen): Make this faster for the common case where the text - // is supported by the given fonts. - if (ui_web.TestEnvironment.instance.disableFontFallbacks) { - return; - } - - // We have a cache of code points which are known to be covered by at least - // one of our fallback fonts, and a cache of code points which are known not - // to be covered by any fallback font. From the given text, construct a set - // of code points which need to be checked. - final runesToCheck = {}; - for (final int rune in text.runes) { - // Filter out code points that don't need checking. - if (!(rune < 160 || // ASCII and Unicode control points. - _knownCoveredCodePoints.contains(rune) || // Points we've already covered - _codePointsWithNoKnownFont.contains(rune)) // Points that don't have a fallback font - ) { - runesToCheck.add(rune); - } - } - if (runesToCheck.isEmpty) { - return; - } - - final List codePoints = runesToCheck.toList(); - final List missingCodePoints = _registry.getMissingCodePoints(codePoints, fontFamilies); - - if (missingCodePoints.isNotEmpty) { - addMissingCodePoints(codePoints); - } - } - - void addMissingCodePoints(List codePoints) { - _codePointsToCheckAgainstFallbackFonts.addAll(codePoints); - if (!_scheduledCodePointCheck) { - _scheduledCodePointCheck = true; - _idleFuture = Future.delayed(Duration.zero, () async { - _ensureFallbackFonts(); - _scheduledCodePointCheck = false; - await _downloadQueue.waitForIdle(); - }); - } - } - - /// Checks the missing code points against the current set of fallback fonts - /// and starts downloading new fallback fonts if the current set can't cover - /// the code points. - void _ensureFallbackFonts() { - _scheduledCodePointCheck = false; - // We don't know if the remaining code points are covered by our fallback - // fonts. Check them and update the cache. - if (_codePointsToCheckAgainstFallbackFonts.isEmpty) { - return; - } - final List codePoints = _codePointsToCheckAgainstFallbackFonts.toList(); - _codePointsToCheckAgainstFallbackFonts.clear(); - findFontsForMissingCodePoints(codePoints); - } - void registerFallbackFont(String family) { + debugOnLoadFontFamily?.call(family); // Insert emoji font before all other fallback fonts so we use the emoji // whenever it's available. if (family.startsWith('Noto Color Emoji') || family == 'Noto Emoji') { @@ -164,135 +55,8 @@ class FontFallbackManager { } } - /// Finds the minimum set of fonts which covers all of the [codePoints]. - /// - /// Since set cover is NP-complete, we approximate using a greedy algorithm - /// which finds the font which covers the most code points. If multiple CJK - /// fonts match the same number of code points, we choose one based on the - /// user's locale. - /// - /// If a code point is not covered by any font, it is added to - /// [_codePointsWithNoKnownFont] so it can be omitted next time to avoid - /// searching for fonts unnecessarily. - void findFontsForMissingCodePoints(List codePoints) { - final missingCodePoints = []; - - final requiredComponents = []; - final candidateFonts = []; - - // Collect the components that cover the code points. - for (final codePoint in codePoints) { - final FallbackFontComponent component = codePointToComponents.lookup(codePoint); - if (component.fonts.isEmpty) { - missingCodePoints.add(codePoint); - } else { - // A zero cover count means we have not yet seen this component. - if (component.coverCount == 0) { - requiredComponents.add(component); - } - component.coverCount++; - } - } - - // Aggregate the component cover counts to the fonts that use the component. - for (final component in requiredComponents) { - for (final NotoFont font in component.fonts) { - // A zero cover cover count means we have not yet seen this font. - if (font.coverCount == 0) { - candidateFonts.add(font); - } - font.coverCount += component.coverCount; - font.coverComponents.add(component); - } - } - - final selectedFonts = []; - - while (candidateFonts.isNotEmpty) { - final NotoFont selectedFont = _selectFont(candidateFonts); - selectedFonts.add(selectedFont); - - // All the code points in the selected font are now covered. Zero out each - // component that is used by the font and adjust the counts of other fonts - // that use the same components. - for (final component in [...selectedFont.coverComponents]) { - for (final NotoFont font in component.fonts) { - font.coverCount -= component.coverCount; - font.coverComponents.remove(component); - } - component.coverCount = 0; - } - assert(selectedFont.coverCount == 0); - assert(selectedFont.coverComponents.isEmpty); - // The selected font will have a zero cover count, but other fonts may - // too. Remove these from further consideration. - candidateFonts.removeWhere((NotoFont font) => font.coverCount == 0); - } - - selectedFonts.forEach(_downloadQueue.add); - - // Report code points not covered by any fallback font and ensure we don't - // process those code points again. - if (missingCodePoints.isNotEmpty) { - if (!_downloadQueue.isPending) { - printWarning( - 'Could not find a set of Noto fonts to display all missing ' - 'characters. Please add a font asset for the missing characters.' - ' See: https://docs.flutter.dev/cookbook/design/fonts', - ); - _codePointsWithNoKnownFont.addAll(missingCodePoints); - } - } - } - - NotoFont _selectFont(List fonts) { - // Priority is given to fonts that match the language. - NotoFont? bestFont = switch (_language) { - 'zh-Hans' || 'zh-CN' || 'zh-SG' || 'zh-MY' => fonts.firstWhereOrNull(_isNotoSansSC), - 'zh-Hant' || 'zh-TW' || 'zh-MO' => fonts.firstWhereOrNull(_isNotoSansTC), - 'zh-HK' => fonts.firstWhereOrNull(_isNotoSansHK), - 'ja' => fonts.firstWhereOrNull(_isNotoSansJP), - 'ko' => fonts.firstWhereOrNull(_isNotoSansKR), - _ => null, - }; - - if (bestFont != null) { - return bestFont; - } - - var maxCodePointsCovered = -1; - final List bestFonts = []; - - for (final font in fonts) { - if (font.coverCount > maxCodePointsCovered) { - bestFonts.clear(); - bestFonts.add(font); - bestFont = font; - maxCodePointsCovered = font.coverCount; - } else if (font.coverCount == maxCodePointsCovered) { - bestFonts.add(font); - // Tie-break with the lowest index which corresponds to a font name - // being earlier in the list of fonts in the font fallback data - // generator. - if (font.index < bestFont!.index) { - bestFont = font; - } - } - } - - if (bestFonts.length > 1) { - // To be predictable, if there is a tie for best font, choose a font - // from this list first, then just choose the first font. - if (bestFonts.contains(_notoSymbols)) { - bestFont = _notoSymbols; - } else { - final NotoFont? notoSansSC = bestFonts.firstWhereOrNull(_isNotoSansSC); - if (notoSansSC != null) { - bestFont = notoSansSC; - } - } - } - return bestFont!; + void updateFallbackFontFamilies() { + _registry.updateFallbackFontFamilies(globalFontFallbacks); } late final List fontComponents = _decodeFontComponents(encodedFontSets); @@ -420,79 +184,3 @@ class _UnicodePropertyLookup

{ } } } - -class _FallbackFontDownloadQueue { - _FallbackFontDownloadQueue(this.fallbackManager); - - final FontFallbackManager fallbackManager; - - final Set downloadedFonts = {}; - final Map pendingFonts = {}; - - bool get isPending => pendingFonts.isNotEmpty; - - Completer? _idleCompleter; - - Future waitForIdle() { - if (_idleCompleter == null) { - // We're already idle - return Future.value(); - } else { - return _idleCompleter!.future; - } - } - - void add(NotoFont font) { - if (downloadedFonts.contains(font) || pendingFonts.containsKey(font.url)) { - return; - } - final bool firstInBatch = pendingFonts.isEmpty; - pendingFonts[font.url] = font; - _idleCompleter ??= Completer(); - if (firstInBatch) { - Timer.run(startDownloads); - } - } - - Future startDownloads() async { - final downloads = >{}; - final downloadedFontFamilies = []; - for (final NotoFont font in pendingFonts.values) { - downloads[font.url] = Future(() async { - final url = '${configuration.fontFallbackBaseUrl}${font.url}'; - try { - fallbackManager.debugOnLoadFontFamily?.call(font.name); - await fallbackManager._registry.loadFallbackFont(font.name, url); - downloadedFontFamilies.add(font.url); - } catch (e) { - pendingFonts.remove(font.url); - printWarning('Failed to load font ${font.name} at $url'); - printWarning(e.toString()); - return; - } - downloadedFonts.add(font); - }); - } - - await Future.wait(downloads.values); - - // Register fallback fonts in a predictable order. Otherwise, the fonts - // change their precedence depending on the download order causing - // visual differences between app reloads. - downloadedFontFamilies.sort(); - for (final url in downloadedFontFamilies) { - final NotoFont font = pendingFonts.remove(url)!; - fallbackManager.registerFallbackFont(font.name); - } - - if (pendingFonts.isEmpty) { - fallbackManager._registry.updateFallbackFontFamilies(fallbackManager.globalFontFallbacks); - sendFontChangeMessage(); - final Completer idleCompleter = _idleCompleter!; - _idleCompleter = null; - idleCompleter.complete(); - } else { - await startDownloads(); - } - } -} diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/fonts.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/fonts.dart index 9da5a434aa28f..9a370d2aa7a71 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/fonts.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/fonts.dart @@ -7,6 +7,7 @@ import 'dart:convert'; import 'dart:js_interop'; import 'dart:typed_data'; +import 'package:meta/meta.dart'; import 'package:ui/src/engine.dart'; import 'package:ui/ui_web/src/ui_web.dart' as ui_web; @@ -124,7 +125,7 @@ class AssetFontsResult { abstract class FlutterFontCollection { /// Loads a font directly from font data. - Future loadFontFromList(Uint8List list, {String? fontFamily}); + Future loadFontFromBytes(Uint8List list, {String? fontFamily}); /// Completes when fonts from FontManifest.json have been loaded. Future loadAssetFonts(FontManifest manifest); @@ -134,7 +135,17 @@ abstract class FlutterFontCollection { // properly. FontFallbackManager? get fontFallbackManager; + @visibleForTesting + set fontFallbackManager(FontFallbackManager? value); + + /// The font fallback registry for this font collection. + FallbackFontRegistry? get fallbackFontRegistry; + + @visibleForTesting + set fallbackFontRegistry(FallbackFontRegistry? value); + // Reset the state of font fallbacks. Only to be used in testing. + @visibleForTesting void debugResetFallbackFonts(); // Unregisters all fonts. diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/initialization.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/initialization.dart index 939e3e08c5c4b..b990ca3e3df14 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/initialization.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/initialization.dart @@ -214,7 +214,7 @@ Future _downloadAssetFonts() async { if (ui_web.TestEnvironment.instance.forceTestFonts) { // Load the embedded test font before loading fonts from the assets so that // the embedded test font is the default (first) font. - await renderer.fontCollection.loadFontFromList( + await renderer.fontCollection.loadFontFromBytes( EmbeddedTestFont.flutterTest.data, fontFamily: EmbeddedTestFont.flutterTest.fontFamily, ); diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/noto_font.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/noto_font.dart index 5ae5308ed1dee..d699c6f978903 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/noto_font.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/noto_font.dart @@ -10,16 +10,6 @@ class NotoFont { final int index = _index++; static int _index = 0; - - /// During fallback font selection this is the number of missing code points - /// that are covered by (i.e. in) this font. - int coverCount = 0; - - /// During fallback font selection this is a list of [FallbackFontComponent]s - /// from this font that are required to cover some of the missing code - /// points. The cover count for the font is the sum of the cover counts for - /// the components that make up the font. - final List coverComponents = []; } /// A component is a set of code points common to some fonts. Each code point is @@ -31,8 +21,4 @@ class NotoFont { class FallbackFontComponent { FallbackFontComponent(this.fonts); final List fonts; - - /// During fallback font selection this is the number of missing code points - /// that are covered by this component. - int coverCount = 0; } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/font_collection.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/font_collection.dart index 2b3086924020d..20a3381a83472 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/font_collection.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/font_collection.dart @@ -7,6 +7,7 @@ import 'dart:ffi'; import 'dart:js_interop'; import 'dart:typed_data'; +import 'package:meta/meta.dart'; import 'package:ui/src/engine.dart'; import 'package:ui/src/engine/skwasm/skwasm_impl.dart'; import 'package:ui/ui_web/src/ui_web.dart' as ui_web; @@ -26,6 +27,8 @@ class SkwasmTypeface extends SkwasmObjectWrapper { class SkwasmFontCollection implements FlutterFontCollection { SkwasmFontCollection() { + _fallbackRegistry = SkwasmFallbackRegistry(this); + fontFallbackManager = FontFallbackManager(_fallbackRegistry); setDefaultFontFamilies(['Roboto']); } @@ -52,8 +55,27 @@ class SkwasmFontCollection implements FlutterFontCollection { } }); + @visibleForTesting @override - late FontFallbackManager fontFallbackManager = FontFallbackManager(SkwasmFallbackRegistry(this)); + set fontFallbackManager(FontFallbackManager? value) { + _fontFallbackManager = value; + } + + FontFallbackManager? _fontFallbackManager; + + @override + FontFallbackManager? get fontFallbackManager => _fontFallbackManager; + + late FallbackFontRegistry _fallbackRegistry; + + @override + FallbackFontRegistry get fallbackFontRegistry => _fallbackRegistry; + + @visibleForTesting + @override + set fallbackFontRegistry(FallbackFontRegistry? registry) { + _fallbackRegistry = registry!; + } @override void clear() { @@ -104,34 +126,35 @@ class SkwasmFontCollection implements FlutterFontCollection { if (!response.hasPayload) { return FontNotFoundError(ui_web.assetManager.getAssetUrl(asset.asset)); } - var length = 0; - final chunks = []; - await response.read((JSUint8Array chunk) { - length += chunk.length; - chunks.add(chunk); - }); - final SkDataHandle fontData = skDataCreate(length); - int dataAddress = skDataGetPointer(fontData).cast().address; - final wasmMemory = JSUint8Array(skwasmInstance.wasmMemory.buffer); - for (final chunk in chunks) { - wasmMemory.set(chunk, dataAddress); - dataAddress += chunk.length; - } - final typeface = SkwasmTypeface(fontData); + + final SkDataHandle fontData = await _loadDataFromResponse(response); + final bool success = _registerTypeface(family, fontData); skDataDispose(fontData); - if (typeface.handle != nullptr) { - final SkStringHandle familyNameHandle = skStringFromDartString(family); - fontCollectionRegisterTypeface(handle, typeface.handle, familyNameHandle); - registeredTypefaces.putIfAbsent(family, () => []).add(typeface); - skStringFree(familyNameHandle); + + if (success) { return null; } else { return FontInvalidDataError(ui_web.assetManager.getAssetUrl(asset.asset)); } } - Future loadFontFromUrl(String familyName, String url) async { - final HttpFetchResponse response = await httpFetch(url); + @override + Future loadFontFromBytes(Uint8List list, {String? fontFamily}) async { + final SkDataHandle fontData = skDataCreate(list.length); + final int dataAddress = skDataGetPointer(fontData).cast().address; + final wasmMemory = JSUint8Array(skwasmInstance.wasmMemory.buffer); + wasmMemory.set(list.toJS, dataAddress); + + final bool success = _registerTypeface(fontFamily, fontData); + skDataDispose(fontData); + + if (success) { + fontCollectionClearCaches(handle); + } + return success; + } + + Future _loadDataFromResponse(HttpFetchResponse response) async { var length = 0; final chunks = []; await response.read((JSUint8Array chunk) { @@ -145,45 +168,29 @@ class SkwasmFontCollection implements FlutterFontCollection { wasmMemory.set(chunk, dataAddress); dataAddress += chunk.length; } + return fontData; + } + bool _registerTypeface(String? familyName, SkDataHandle fontData) { final typeface = SkwasmTypeface(fontData); - skDataDispose(fontData); if (typeface.handle == nullptr) { return false; } - final SkStringHandle familyNameHandle = skStringFromDartString(familyName); + final SkStringHandle familyNameHandle = familyName != null + ? skStringFromDartString(familyName) + : nullptr; fontCollectionRegisterTypeface(handle, typeface.handle, familyNameHandle); - registeredTypefaces.putIfAbsent(familyName, () => []).add(typeface); - skStringFree(familyNameHandle); - return true; - } - - @override - Future loadFontFromList(Uint8List list, {String? fontFamily}) async { - final SkDataHandle dataHandle = skDataCreate(list.length); - final Pointer dataPointer = skDataGetPointer(dataHandle).cast(); - for (var i = 0; i < list.length; i++) { - dataPointer[i] = list[i]; - } - final typeface = SkwasmTypeface(dataHandle); - skDataDispose(dataHandle); - if (typeface.handle == nullptr) { - return false; - } - - if (fontFamily != null) { - final SkStringHandle familyHandle = skStringFromDartString(fontFamily); - fontCollectionRegisterTypeface(handle, typeface.handle, familyHandle); - skStringFree(familyHandle); - } else { - fontCollectionRegisterTypeface(handle, typeface.handle, nullptr); + if (familyName != null) { + registeredTypefaces.putIfAbsent(familyName, () => []).add(typeface); + skStringFree(familyNameHandle); } - fontCollectionClearCaches(handle); return true; } @override void debugResetFallbackFonts() { + // ignore: invalid_use_of_visible_for_testing_member + FallbackFontService.instance.debugReset(); setDefaultFontFamilies(['Roboto']); fontFallbackManager = FontFallbackManager(SkwasmFallbackRegistry(this)); fontCollectionClearCaches(handle); @@ -196,38 +203,8 @@ class SkwasmFallbackRegistry implements FallbackFontRegistry { final SkwasmFontCollection _fontCollection; @override - List getMissingCodePoints(List codePoints, List fontFamilies) => - withStackScope((StackScope scope) { - final List typefaces = fontFamilies - .map((String family) => _fontCollection.registeredTypefaces[family]) - .fold( - const Iterable.empty(), - (Iterable accumulated, List? typefaces) => - typefaces == null ? accumulated : accumulated.followedBy(typefaces), - ) - .toList(); - final Pointer typefaceBuffer = scope - .allocPointerArray(typefaces.length) - .cast(); - for (var i = 0; i < typefaces.length; i++) { - typefaceBuffer[i] = typefaces[i].handle; - } - final Pointer codePointBuffer = scope.allocInt32Array(codePoints.length); - for (var i = 0; i < codePoints.length; i++) { - codePointBuffer[i] = codePoints[i]; - } - final int missingCodePointCount = typefacesFilterCoveredCodePoints( - typefaceBuffer, - typefaces.length, - codePointBuffer, - codePoints.length, - ); - return List.generate(missingCodePointCount, (int index) => codePointBuffer[index]); - }); - - @override - Future loadFallbackFont(String familyName, String url) => - _fontCollection.loadFontFromUrl(familyName, url); + Future loadFallbackFont(String familyName, Uint8List bytes) => + _fontCollection.loadFontFromBytes(bytes, fontFamily: familyName); @override void updateFallbackFontFamilies(List families) => diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/paragraph.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/paragraph.dart index 366873af76519..f05e1b1a574f7 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/paragraph.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/paragraph.dart @@ -143,7 +143,7 @@ class SkwasmParagraph extends SkwasmObjectWrapper implements ui.Pa missingCodePointCount, ); assert(missingCodePointCount == returnedCodePointCount); - renderer.fontCollection.fontFallbackManager!.addMissingCodePoints( + FallbackFontService.instance.addMissingCodePoints( List.generate(missingCodePointCount, (int index) => codePointBuffer[index]), ); }); diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/raw/raw_fonts.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/raw/raw_fonts.dart index a02503e111fcd..0a57a11504368 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/raw/raw_fonts.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/skwasm/skwasm_impl/raw/raw_fonts.dart @@ -29,17 +29,6 @@ external TypefaceHandle typefaceCreate(SkDataHandle fontData); @Native(symbol: 'typeface_dispose', isLeaf: true) external void typefaceDispose(TypefaceHandle handle); -@Native, Int, Pointer, Int)>( - symbol: 'typefaces_filterCoveredCodePoints', - isLeaf: true, -) -external int typefacesFilterCoveredCodePoints( - Pointer typefaces, - int typefaceCount, - Pointer codepoints, - int codePointCount, -); - @Native( symbol: 'fontCollection_registerTypeface', isLeaf: true, diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/text_editing.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/text_editing.dart index 253d4acce1af2..1c099470e831f 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/text_editing.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/text_editing.dart @@ -2716,7 +2716,7 @@ class EditableTextGeometry { assert(encodedGeometry.containsKey('transform')); final transformList = List.from( - encodedGeometry.readList('transform').map((final dynamic e) => (e as num).toDouble()), + encodedGeometry.readList('transform').map((dynamic e) => (e as num).toDouble()), ); return EditableTextGeometry( width: encodedGeometry.readDouble('width'), diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/font_collection.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/font_collection.dart index 79bdc77dfc379..95f5257eba2a9 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/font_collection.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/web_paragraph/font_collection.dart @@ -5,12 +5,9 @@ import 'dart:async'; import 'dart:typed_data'; +import 'package:ui/src/engine.dart'; import 'package:ui/ui_web/src/ui_web.dart' as ui_web; -import '../dom.dart'; -import '../fonts.dart'; -import '../util.dart'; - /// This class is responsible for registering and loading fonts. /// /// Once an asset manager has been set in the framework, call [loadAssetFonts] with it to register @@ -45,7 +42,7 @@ class WebFontCollection implements FlutterFontCollection { } @override - Future loadFontFromList(Uint8List list, {String? fontFamily}) async { + Future loadFontFromBytes(Uint8List list, {String? fontFamily}) async { if (fontFamily == null) { printWarning('Font family must be provided to WebFontCollection.'); return false; @@ -54,7 +51,16 @@ class WebFontCollection implements FlutterFontCollection { } @override - Null get fontFallbackManager => null; + FontFallbackManager? get fontFallbackManager => null; + + @override + set fontFallbackManager(FontFallbackManager? value) {} + + @override + FallbackFontRegistry? get fallbackFontRegistry => null; + + @override + set fallbackFontRegistry(FallbackFontRegistry? value) {} /// Unregister all fonts that have been registered. @override diff --git a/engine/src/flutter/lib/web_ui/lib/text.dart b/engine/src/flutter/lib/web_ui/lib/text.dart index f91b78d9c81b2..079b21420c11f 100644 --- a/engine/src/flutter/lib/web_ui/lib/text.dart +++ b/engine/src/flutter/lib/web_ui/lib/text.dart @@ -684,6 +684,6 @@ abstract class ParagraphBuilder { } Future loadFontFromList(Uint8List list, {String? fontFamily}) async { - await engine.renderer.fontCollection.loadFontFromList(list, fontFamily: fontFamily); + await engine.renderer.fontCollection.loadFontFromBytes(list, fontFamily: fontFamily); engine.sendFontChangeMessage(); } diff --git a/engine/src/flutter/lib/web_ui/test/common/matchers.dart b/engine/src/flutter/lib/web_ui/test/common/matchers.dart index 7b38f1d79472a..e9c7d97e00ef0 100644 --- a/engine/src/flutter/lib/web_ui/test/common/matchers.dart +++ b/engine/src/flutter/lib/web_ui/test/common/matchers.dart @@ -257,7 +257,7 @@ class HtmlPatternMatcher extends Matcher { final html.Element pattern; @override - bool matches(final Object? object, Map matchState) { + bool matches(Object? object, Map matchState) { // TODO(srujzs): Replace this with `!object.isJSAny` once we have that API // in `dart:js_interop`. // https://github.com/dart-lang/sdk/issues/56905 diff --git a/engine/src/flutter/lib/web_ui/test/engine/font_fallback_service_test.dart b/engine/src/flutter/lib/web_ui/test/engine/font_fallback_service_test.dart new file mode 100644 index 0000000000000..b53e192a57051 --- /dev/null +++ b/engine/src/flutter/lib/web_ui/test/engine/font_fallback_service_test.dart @@ -0,0 +1,306 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:test/bootstrap/browser.dart'; +import 'package:test/test.dart'; +import 'package:ui/src/engine.dart'; + +void main() { + internalBootstrapBrowserTest(() => testMain); +} + +class MockFallbackFontRegistry implements FallbackFontRegistry { + final Map loadedFonts = {}; + final List updatedFamilies = []; + bool failNextLoad = false; + + @override + Future loadFallbackFont(String familyName, Uint8List bytes) async { + if (failNextLoad) { + failNextLoad = false; + return false; + } + loadedFonts[familyName] = bytes; + return true; + } + + @override + void updateFallbackFontFamilies(List families) { + updatedFamilies.clear(); + updatedFamilies.addAll(families); + } +} + +void testMain() { + group('FallbackFontService', () { + late MockFallbackFontRegistry mockRegistry; + late FontFallbackManager fontFallbackManager; + + setUp(() async { + await renderer.initialize(); + mockRegistry = MockFallbackFontRegistry(); + fontFallbackManager = FontFallbackManager(mockRegistry); + + // Inject our mock manager and registry into the renderer. + renderer.fontCollection.fontFallbackManager = fontFallbackManager; + renderer.fontCollection.fallbackFontRegistry = mockRegistry; + + FallbackFontService.instance.debugReset(); + + debugOverrideJsConfiguration(JsFlutterConfiguration(debugSkipFontRetryDelay: true)); + }); + + tearDown(() async { + await FallbackFontService.instance.waitForIdle(); + debugOverrideJsConfiguration(null); + }); + + test('successfully downloads and registers a font', () async { + // Code point 0x4E00 is CJK Unified Ideograph, usually covered by Noto Sans SC/TC/JP/KR. + FallbackFontService.instance.addMissingCodePoints([0x4E00]); + await FallbackFontService.instance.waitForIdle(); + + // Check if some font was loaded. + expect(mockRegistry.loadedFonts.isNotEmpty, isTrue); + expect(mockRegistry.updatedFamilies.isNotEmpty, isTrue); + }); + + test('retries on failure and eventually marks as permanently unavailable', () async { + debugOverrideJsConfiguration( + JsFlutterConfiguration( + debugSkipFontRetryDelay: true, + fontFallbackBaseUrl: 'http://invalid-url-that-fails.com/', + ), + ); + + // Arabic code point + FallbackFontService.instance.addMissingCodePoints([0x0627]); + await FallbackFontService.instance.waitForIdle(); + + // Since it failed, no fonts should be registered. + expect(mockRegistry.loadedFonts, isEmpty); + }); + + test('re-evaluates code points when a font fails', () async { + String? failedFontUrl; + mockHttpFetchResponseFactory = (String url) async { + // Fail the very first font that the service decides to download. + if (failedFontUrl == null) { + failedFontUrl = url; + return MockHttpFetchResponse( + url: url, + status: 404, // Permanent failure + ); + } + // Return a successful response for the subsequent attempts (alternatives). + return MockHttpFetchResponse( + url: url, + status: 200, + payload: MockHttpFetchPayload(byteBuffer: Uint8List(0).buffer), + ); + }; + + FallbackFontService.instance.addMissingCodePoints([0x4E00]); + await FallbackFontService.instance.waitForIdle(); + + expect(failedFontUrl, isNotNull); + + // The service should have tried another font that covers 0x4E00. + final List loadedFamilies = mockRegistry.loadedFonts.keys.toList(); + expect(loadedFamilies, isNotEmpty); + + // Verify that the loaded font also covers 0x4E00 (is a CJK font). + final bool coveredByAlternative = loadedFamilies.any( + (family) => + family.startsWith('Noto Sans SC') || + family.startsWith('Noto Sans TC') || + family.startsWith('Noto Sans HK') || + family.startsWith('Noto Sans JP') || + family.startsWith('Noto Sans KR'), + ); + + expect( + coveredByAlternative, + isTrue, + reason: 'Should have loaded an alternative font for 0x4E00', + ); + }); + + test('treats 403 as permanent failure and tries alternative', () async { + String? failedFontUrl; + mockHttpFetchResponseFactory = (String url) async { + if (failedFontUrl == null) { + failedFontUrl = url; + return MockHttpFetchResponse(url: url, status: 403); + } + return MockHttpFetchResponse( + url: url, + status: 200, + payload: MockHttpFetchPayload(byteBuffer: Uint8List(0).buffer), + ); + }; + + FallbackFontService.instance.addMissingCodePoints([0x4E00]); + await FallbackFontService.instance.waitForIdle(); + + expect(failedFontUrl, isNotNull); + expect(mockRegistry.loadedFonts.isNotEmpty, isTrue); + }); + + test('treats 500 as transient and retries', () async { + var attemptsForFirstFont = 0; + String? firstFontUrl; + + mockHttpFetchResponseFactory = (String url) async { + if (firstFontUrl == null || firstFontUrl == url) { + firstFontUrl = url; + attemptsForFirstFont++; + if (attemptsForFirstFont < 3) { + return MockHttpFetchResponse(url: url, status: 500); + } + } + return MockHttpFetchResponse( + url: url, + status: 200, + payload: MockHttpFetchPayload(byteBuffer: Uint8List(0).buffer), + ); + }; + + FallbackFontService.instance.addMissingCodePoints([0x4E00]); + await FallbackFontService.instance.waitForIdle(); + + expect(attemptsForFirstFont, greaterThan(1)); + expect( + mockRegistry.loadedFonts.keys, + contains(mockRegistry.loadedFonts.keys.firstWhere((k) => true)), + ); + }); + + test('treats font registration failure as permanent', () async { + mockHttpFetchResponseFactory = (String url) async { + return MockHttpFetchResponse( + url: url, + status: 200, + payload: MockHttpFetchPayload(byteBuffer: Uint8List(0).buffer), + ); + }; + + // Mock the registry to fail the first time, but succeed afterwards. + mockRegistry.failNextLoad = true; + + FallbackFontService.instance.addMissingCodePoints([0x4E00]); + await FallbackFontService.instance.waitForIdle(); + + // The first font failed to register, so it should have tried an alternative. + expect(mockRegistry.loadedFonts.isNotEmpty, isTrue); + }); + + test('correctly resolves URLs with and without trailing slashes in base URL', () async { + final requestedUrls = []; + mockHttpFetchResponseFactory = (String url) async { + requestedUrls.add(url); + return MockHttpFetchResponse( + url: url, + status: 200, + payload: MockHttpFetchPayload(byteBuffer: Uint8List(0).buffer), + ); + }; + + // Test with trailing slash + debugOverrideJsConfiguration( + JsFlutterConfiguration( + debugSkipFontRetryDelay: true, + fontFallbackBaseUrl: 'https://example.com/fonts/', + ), + ); + FallbackFontService.instance.addMissingCodePoints([0x0627]); + await FallbackFontService.instance.waitForIdle(); + expect(requestedUrls.last, startsWith('https://example.com/fonts/')); + + // Test without trailing slash. + // NOTE: Uri.resolve('a/b').resolve('c') results in 'a/c' if 'a/b' is not recognized as a directory. + // But usually base URLs for fonts are intended to be directories. + // If the user provides 'https://example.com/fonts', Uri.resolve will replace 'fonts' with the font path + // unless 'fonts' ends with a slash. + debugOverrideJsConfiguration( + JsFlutterConfiguration( + debugSkipFontRetryDelay: true, + fontFallbackBaseUrl: 'https://example.com/fonts', + ), + ); + // Reset so it tries to download again. + FallbackFontService.instance.debugReset(); + FallbackFontService.instance.addMissingCodePoints([0x0627]); + await FallbackFontService.instance.waitForIdle(); + // 'https://example.com/fonts' resolved with 'noto...' becomes 'https://example.com/noto...' + expect(requestedUrls.last, startsWith('https://example.com/')); + expect(requestedUrls.last, isNot(contains('fonts'))); + }); + + test('global kill switch disables service after enough failures with no success', () async { + var callCount = 0; + mockHttpFetchResponseFactory = (String url) async { + callCount++; + return MockHttpFetchResponse(url: url, status: 404); + }; + + // We need enough missing codepoints to trigger at least 10 unique font requests. + // 0x4E00 (CJK), 0x0627 (Arabic), 0x05D0 (Hebrew), 0x0905 (Devanagari), + // 0x03B1 (Greek), 0x0410 (Cyrillic), 0x0E01 (Thai), 0x1200 (Ethiopic), + // 0x10A0 (Georgian), 0x0531 (Armenian), 0x13A0 (Cherokee) + final missing = [ + 0x4E00, + 0x0627, + 0x05D0, + 0x0905, + 0x03B1, + 0x0410, + 0x0E01, + 0x1200, + 0x10A0, + 0x0531, + 0x13A0, + ]; + + FallbackFontService.instance.addMissingCodePoints(missing); + await FallbackFontService.instance.waitForIdle(); + + // It should have stopped once it hit the threshold. + // Since downloads are in parallel, it might have started a few more + // than exactly 10, but it should definitely be in that ballpark. + expect(callCount, greaterThanOrEqualTo(10)); + expect(callCount, lessThan(20)); + + // Subsequent requests should fail immediately. + callCount = 0; + FallbackFontService.instance.addMissingCodePoints([0x1100]); // Hangul Jamo + await FallbackFontService.instance.waitForIdle(); + expect(callCount, 0); + }); + + test('per-component cap stops attempts after 5 failures for a single component', () async { + var callCount = 0; + mockHttpFetchResponseFactory = (String url) async { + callCount++; + return MockHttpFetchResponse(url: url, status: 404); + }; + + // CJK Unified Ideograph (0x4E00) is covered by many fonts (SC, TC, HK, JP, KR, etc). + FallbackFontService.instance.addMissingCodePoints([0x4E00]); + await FallbackFontService.instance.waitForIdle(); + + // It should have stopped after trying 5 fonts for this component. + expect(callCount, 5); + + // The service should NOT be broken yet (global limit is 10). + callCount = 0; + FallbackFontService.instance.addMissingCodePoints([0x0627]); // Arabic + await FallbackFontService.instance.waitForIdle(); + expect(callCount, greaterThan(0)); + }); + }); +} diff --git a/engine/src/flutter/lib/web_ui/test/ui/fallback_fonts_golden_test.dart b/engine/src/flutter/lib/web_ui/test/ui/fallback_fonts_golden_test.dart index b79d43542ab76..685e44459f2af 100644 --- a/engine/src/flutter/lib/web_ui/test/ui/fallback_fonts_golden_test.dart +++ b/engine/src/flutter/lib/web_ui/test/ui/fallback_fonts_golden_test.dart @@ -33,6 +33,7 @@ void testMain() { final downloadedFontFamilies = []; setUp(() { + FallbackFontService.instance.debugReset(); renderer.fontCollection.debugResetFallbackFonts(); debugOverrideJsConfiguration( {'fontFallbackBaseUrl': 'assets/fallback_fonts/'}.jsify() @@ -44,7 +45,8 @@ void testMain() { savedCallback = ui.PlatformDispatcher.instance.onPlatformMessage; }); - tearDown(() { + tearDown(() async { + await FallbackFontService.instance.waitForIdle(); downloadedFontFamilies.clear(); ui.PlatformDispatcher.instance.onPlatformMessage = savedCallback; }); @@ -72,7 +74,7 @@ void testMain() { pb.addText('مرحبا'); pb.build().layout(const ui.ParagraphConstraints(width: 1000)); - await renderer.fontCollection.fontFallbackManager!.debugWhenIdle(); + await FallbackFontService.instance.waitForIdle(); expect( renderer.fontCollection.fontFallbackManager!.globalFontFallbacks, @@ -107,7 +109,7 @@ void testMain() { pb.addText('表紙がゆっくりと開き始める。ページの間から淡い光が漏れ出る、'); pb.build().layout(const ui.ParagraphConstraints(width: 1000)); - await renderer.fontCollection.fontFallbackManager!.debugWhenIdle(); + await FallbackFontService.instance.waitForIdle(); expect( renderer.fontCollection.fontFallbackManager!.globalFontFallbacks, @@ -140,7 +142,7 @@ void testMain() { pb.addText('مرحبا'); pb.build().layout(const ui.ParagraphConstraints(width: 1000)); - await renderer.fontCollection.fontFallbackManager!.debugWhenIdle(); + await FallbackFontService.instance.waitForIdle(); expect(renderer.fontCollection.fontFallbackManager!.globalFontFallbacks, [ 'Roboto', @@ -154,7 +156,7 @@ void testMain() { final ui.Paragraph paragraph = pb.build(); paragraph.layout(const ui.ParagraphConstraints(width: 1000)); - await renderer.fontCollection.fontFallbackManager!.debugWhenIdle(); + await FallbackFontService.instance.waitForIdle(); expect(renderer.fontCollection.fontFallbackManager!.globalFontFallbacks, [ 'Roboto', @@ -172,7 +174,7 @@ void testMain() { pb.addText('Hello 😊'); pb.build().layout(const ui.ParagraphConstraints(width: 1000)); - await renderer.fontCollection.fontFallbackManager!.debugWhenIdle(); + await FallbackFontService.instance.waitForIdle(); expect( renderer.fontCollection.fontFallbackManager!.globalFontFallbacks, @@ -209,7 +211,7 @@ void testMain() { pb.addText(text); pb.build().layout(const ui.ParagraphConstraints(width: 1000)); - await renderer.fontCollection.fontFallbackManager!.debugWhenIdle(); + await FallbackFontService.instance.waitForIdle(); expect(downloadedFontFamilies, expectedFamilies); // Do the same thing but this time with loaded fonts. @@ -218,7 +220,7 @@ void testMain() { pb.addText(text); pb.build().layout(const ui.ParagraphConstraints(width: 1000)); - await renderer.fontCollection.fontFallbackManager!.debugWhenIdle(); + await FallbackFontService.instance.waitForIdle(); expect(downloadedFontFamilies, isEmpty); } @@ -237,7 +239,7 @@ void testMain() { // renderer.fontCollection.debugResetFallbackFonts(); final FontFallbackManager fallbackManager = renderer.fontCollection.fontFallbackManager!; - final String oldLanguage = fallbackManager.debugUserPreferredLanguage; + final String oldLanguage = fallbackManager.preferredLanguage; if (userPreferredLanguage != null) { fallbackManager.debugUserPreferredLanguage = userPreferredLanguage; } @@ -247,7 +249,7 @@ void testMain() { pb.addText(String.fromCharCode(charCode)); pb.build().layout(const ui.ParagraphConstraints(width: 1000)); - await renderer.fontCollection.fontFallbackManager!.debugWhenIdle(); + await FallbackFontService.instance.waitForIdle(); if (userPreferredLanguage != null) { fallbackManager.debugUserPreferredLanguage = oldLanguage; } @@ -583,19 +585,6 @@ void testMain() { expect(fontsForPoint, isNotEmpty); fonts.addAll(fontsForPoint); } - - try { - renderer.fontCollection.fontFallbackManager!.findFontsForMissingCodePoints( - codePoints.toList(), - ); - } catch (e) { - print( - 'findFontsForMissingCodePoints failed:\n' - ' Code points: ${codePoints.join(', ')}\n' - ' Fonts: ${fonts.map((NotoFont f) => f.name).join(', ')}', - ); - rethrow; - } } }); @@ -618,7 +607,7 @@ void testMain() { pb.addText('Hello 😊'); pb.build().layout(const ui.ParagraphConstraints(width: 1000)); - await renderer.fontCollection.fontFallbackManager!.debugWhenIdle(); + await FallbackFontService.instance.waitForIdle(); // Make sure we didn't download the fallback font. expect( diff --git a/engine/src/flutter/lib/web_ui/test/ui/font_collection_test.dart b/engine/src/flutter/lib/web_ui/test/ui/font_collection_test.dart index e4705b1d0e9b3..29d2510913a99 100644 --- a/engine/src/flutter/lib/web_ui/test/ui/font_collection_test.dart +++ b/engine/src/flutter/lib/web_ui/test/ui/font_collection_test.dart @@ -29,20 +29,17 @@ Future testMain() async { fakeAssetManager.popAssetScope(testScope); }); - test( - 'Loading valid font from data succeeds without family name (except in HTML renderer)', - () async { - final FlutterFontCollection collection = renderer.fontCollection; - final ByteBuffer ahemData = await httpFetchByteBuffer('/assets/fonts/ahem.ttf'); - expect(await collection.loadFontFromList(ahemData.asUint8List()), isTrue); - }, - ); + test('Loading valid font from data succeeds without family name', () async { + final FlutterFontCollection collection = renderer.fontCollection; + final ByteBuffer ahemData = await httpFetchByteBuffer('/assets/fonts/ahem.ttf'); + expect(await collection.loadFontFromBytes(ahemData.asUint8List()), isTrue); + }); test('Loading valid font from data succeeds with family name', () async { final FlutterFontCollection collection = renderer.fontCollection; final ByteBuffer ahemData = await httpFetchByteBuffer('/assets/fonts/ahem.ttf'); expect( - await collection.loadFontFromList(ahemData.asUint8List(), fontFamily: 'FamilyName'), + await collection.loadFontFromBytes(ahemData.asUint8List(), fontFamily: 'FamilyName'), true, ); }); @@ -51,7 +48,7 @@ Future testMain() async { final FlutterFontCollection collection = renderer.fontCollection; final List invalidFontData = utf8.encode('This is not valid font data'); expect( - await collection.loadFontFromList( + await collection.loadFontFromBytes( Uint8List.fromList(invalidFontData), fontFamily: 'FamilyName', ), diff --git a/engine/src/flutter/lib/web_ui/test/ui/scene_builder_test.dart b/engine/src/flutter/lib/web_ui/test/ui/scene_builder_test.dart index cfb2eab288048..3ac3756d679b3 100644 --- a/engine/src/flutter/lib/web_ui/test/ui/scene_builder_test.dart +++ b/engine/src/flutter/lib/web_ui/test/ui/scene_builder_test.dart @@ -663,7 +663,7 @@ ui.Picture drawPicture(void Function(ui.Canvas) drawCommands) { return recorder.endRecording(); } -ui.Scene backdropBlurWithTileMode(ui.TileMode? tileMode, final double rectSize, final int count) { +ui.Scene backdropBlurWithTileMode(ui.TileMode? tileMode, double rectSize, int count) { final double imgSize = rectSize * count; const white = ui.Color(0xFFFFFFFF); diff --git a/engine/src/flutter/lib/web_ui/test/ui/text_golden_test.dart b/engine/src/flutter/lib/web_ui/test/ui/text_golden_test.dart index e84ad5da2f953..0428db929897d 100644 --- a/engine/src/flutter/lib/web_ui/test/ui/text_golden_test.dart +++ b/engine/src/flutter/lib/web_ui/test/ui/text_golden_test.dart @@ -187,7 +187,7 @@ Future testMain() async { '/assets/fonts/RobotoSlab-VariableFont_wght.ttf', ); expect( - await collection.loadFontFromList(robotoSlabData.asUint8List(), fontFamily: 'Roboto Slab'), + await collection.loadFontFromBytes(robotoSlabData.asUint8List(), fontFamily: 'Roboto Slab'), true, ); await testTextStyle('after font load', fontFamily: 'Roboto Slab'); @@ -764,7 +764,7 @@ Future testTextStyle( // Render once to trigger font downloads. renderPicture(); - await renderer.fontCollection.fontFallbackManager?.debugWhenIdle(); + await FallbackFontService.instance.waitForIdle(); final ui.Picture picture = renderPicture(); await drawPictureUsingCurrentRenderer(picture); @@ -792,7 +792,7 @@ Future testSampleText( // Render once to trigger font downloads. renderPicture(); - await renderer.fontCollection.fontFallbackManager?.debugWhenIdle(); + await FallbackFontService.instance.waitForIdle(); final ui.Picture picture = renderPicture(); await drawPictureUsingCurrentRenderer(picture); await matchGoldenFile( diff --git a/engine/src/flutter/shell/gpu/gpu_surface_metal_impeller.mm b/engine/src/flutter/shell/gpu/gpu_surface_metal_impeller.mm index fe94bdf1f06f6..f259669bab2f5 100644 --- a/engine/src/flutter/shell/gpu/gpu_surface_metal_impeller.mm +++ b/engine/src/flutter/shell/gpu/gpu_surface_metal_impeller.mm @@ -65,10 +65,21 @@ } if (!render_to_surface_) { +#ifdef IMPELLER_DEBUG + impeller::ContextMTL::Cast(*aiks_context_->GetContext()).GetCaptureManager()->StartCapture(); +#endif // IMPELLER_DEBUG return std::make_unique( nullptr, SurfaceFrame::FramebufferInfo(), [](const SurfaceFrame& surface_frame, DlCanvas* canvas) { return true; }, - [](const SurfaceFrame& surface_frame) { return true; }, frame_size); +#ifdef IMPELLER_DEBUG + [context = aiks_context_->GetContext()](const SurfaceFrame& surface_frame) { + impeller::ContextMTL::Cast(*context).GetCaptureManager()->FinishCapture(); + return true; + }, +#else + [](const SurfaceFrame& surface_frame) { return true; }, +#endif // IMPELLER_DEBUG + frame_size); } switch (render_target_type_) { diff --git a/engine/src/flutter/shell/platform/android/BUILD.gn b/engine/src/flutter/shell/platform/android/BUILD.gn index a53399c5b9690..8992c3d59d01a 100644 --- a/engine/src/flutter/shell/platform/android/BUILD.gn +++ b/engine/src/flutter/shell/platform/android/BUILD.gn @@ -48,6 +48,7 @@ executable("flutter_shell_native_unittests") { "apk_asset_provider_unittests.cc", "flutter_shell_native_unittests.cc", "image_lru_unittests.cc", + "platform_view_android_jni_impl_unittests.cc", "platform_view_android_unittests.cc", ] if (!slimpeller) { @@ -58,6 +59,7 @@ executable("flutter_shell_native_unittests") { ":flutter_shell_native_src", "//flutter/fml", "//flutter/shell/platform/android/jni:jni_mock", + "//flutter/shell/platform/android/jni:mock_jni_env", "//flutter/third_party/googletest:gmock", "//flutter/third_party/googletest:gtest", ] @@ -779,7 +781,9 @@ if (target_cpu != "x86") { gen_snapshot_dest = "gen_snapshot.exe" } - if (host_os == "linux") { + if (host_os == "linux" && host_cpu == "arm64") { + output = "$android_zip_archive_dir/linux-arm64.zip" + } else if (host_os == "linux") { output = "$android_zip_archive_dir/linux-x64.zip" } else if (host_os == "mac") { output = "$android_zip_archive_dir/darwin-x64.zip" @@ -821,7 +825,9 @@ if (host_os == "linux" && analyze_snapshot_path = rebase_path("$analyze_snapshot_out_dir/$analyze_snapshot_bin") - if (host_os == "linux") { + if (host_os == "linux" && host_cpu == "arm64") { + output = "$android_zip_archive_dir/analyze-snapshot-linux-arm64.zip" + } else if (host_os == "linux") { output = "$android_zip_archive_dir/analyze-snapshot-linux-x64.zip" } else if (host_os == "mac") { output = "$android_zip_archive_dir/analyze-snapshot-darwin-x64.zip" diff --git a/engine/src/flutter/shell/platform/android/image_external_texture_gl.cc b/engine/src/flutter/shell/platform/android/image_external_texture_gl.cc index 21cf09ae59e94..94ab2f4cf8c66 100644 --- a/engine/src/flutter/shell/platform/android/image_external_texture_gl.cc +++ b/engine/src/flutter/shell/platform/android/image_external_texture_gl.cc @@ -72,6 +72,9 @@ void ImageExternalTextureGL::ProcessFrame(PaintContext& context, return; } JavaLocalRef hardware_buffer = HardwareBufferFor(image); + if (hardware_buffer.is_null()) { + return; + } UpdateImage(hardware_buffer, bounds, context); CloseHardwareBuffer(hardware_buffer); } diff --git a/engine/src/flutter/shell/platform/android/image_external_texture_vk_impeller.cc b/engine/src/flutter/shell/platform/android/image_external_texture_vk_impeller.cc index 557cc19402b48..6ce13961a44d9 100644 --- a/engine/src/flutter/shell/platform/android/image_external_texture_vk_impeller.cc +++ b/engine/src/flutter/shell/platform/android/image_external_texture_vk_impeller.cc @@ -43,6 +43,9 @@ void ImageExternalTextureVKImpeller::ProcessFrame(PaintContext& context, return; } JavaLocalRef hardware_buffer = HardwareBufferFor(image); + if (hardware_buffer.is_null()) { + return; + } AHardwareBuffer* latest_hardware_buffer = AHardwareBufferFor(hardware_buffer); auto hb_desc = diff --git a/engine/src/flutter/shell/platform/android/io/flutter/embedding/android/KeyEmbedderResponder.java b/engine/src/flutter/shell/platform/android/io/flutter/embedding/android/KeyEmbedderResponder.java index 520f55ab6ccf0..a5dd8fda9b050 100644 --- a/engine/src/flutter/shell/platform/android/io/flutter/embedding/android/KeyEmbedderResponder.java +++ b/engine/src/flutter/shell/platform/android/io/flutter/embedding/android/KeyEmbedderResponder.java @@ -194,6 +194,12 @@ void synchronizePressingKey( } } + // Virtual keyboards (like Gboard) often keep meta bits active without sending + // corresponding physical key events. For these, we trust the meta state + // and skip synthesizing physical modifier keys to avoid "stuck" modifiers. + boolean isVirtualKeyboard = + event.getDeviceId() == android.view.KeyCharacterMap.VIRTUAL_KEYBOARD; + // Fill the rest of the pre-event states to match the true state. if (truePressed) { // It is required that at least one key is pressed. @@ -201,14 +207,14 @@ void synchronizePressingKey( if (preEventStates[keyIdx] != null) { continue; } - if (postEventAnyPressed) { + if (postEventAnyPressed || isVirtualKeyboard) { preEventStates[keyIdx] = nowStates[keyIdx]; } else { preEventStates[keyIdx] = true; postEventAnyPressed = true; } } - if (!postEventAnyPressed) { + if (!postEventAnyPressed && !isVirtualKeyboard) { preEventStates[0] = true; } } else { diff --git a/engine/src/flutter/shell/platform/android/jni/BUILD.gn b/engine/src/flutter/shell/platform/android/jni/BUILD.gn index 7a05be011d2f7..2114e96c84a29 100644 --- a/engine/src/flutter/shell/platform/android/jni/BUILD.gn +++ b/engine/src/flutter/shell/platform/android/jni/BUILD.gn @@ -32,6 +32,14 @@ source_set("jni_mock") { deps = [ ":jni" ] } +source_set("mock_jni_env") { + testonly = true + + sources = [ "mock_jni_env.h" ] + + public_configs = [ "//flutter:config" ] +} + test_fixtures("jni_fixtures") { fixtures = [] } diff --git a/engine/src/flutter/shell/platform/android/jni/mock_jni_env.h b/engine/src/flutter/shell/platform/android/jni/mock_jni_env.h new file mode 100644 index 0000000000000..b774ee0359c4c --- /dev/null +++ b/engine/src/flutter/shell/platform/android/jni/mock_jni_env.h @@ -0,0 +1,215 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef FLUTTER_SHELL_PLATFORM_ANDROID_JNI_MOCK_JNI_ENV_H_ +#define FLUTTER_SHELL_PLATFORM_ANDROID_JNI_MOCK_JNI_ENV_H_ + +#include + +namespace flutter { + +class MockJavaVM : public JavaVM { + public: + MockJavaVM() { + functions = &jni_invoke_; + + jni_invoke_.DestroyJavaVM = DoDestroyJavaVM; + jni_invoke_.AttachCurrentThread = DoAttachCurrentThread; + jni_invoke_.DetachCurrentThread = DoDetachCurrentThread; + jni_invoke_.GetEnv = DoGetEnv; + jni_invoke_.AttachCurrentThreadAsDaemon = DoAttachCurrentThreadAsDaemon; + } + + void SetJNIEnv(JNIEnv* env) { env_ = env; } + + private: + static jint DoDestroyJavaVM(JavaVM* vm) { return JNI_OK; } + static jint DoAttachCurrentThread(JavaVM* vm, + JNIEnv** p_env, + void* thr_args) { + return JNI_OK; + } + static jint DoDetachCurrentThread(JavaVM* vm) { return JNI_OK; } + static jint DoGetEnv(JavaVM* vm, void** env, jint version) { + *env = static_cast(vm)->env_; + return JNI_OK; + } + static jint DoAttachCurrentThreadAsDaemon(JavaVM* vm, + JNIEnv** p_env, + void* thr_args) { + return JNI_OK; + } + + JNIEnv* env_ = nullptr; + JNIInvokeInterface jni_invoke_; +}; + +class MockableJNIEnv : public JNIEnv { + public: + MockableJNIEnv() { + // Replace the JNIEnv's function table with wrappers that invoke the + // mockable virtual methods in this class. + functions = &jni_; + jni_.CallObjectMethod = WrapCallObjectMethod; + jni_.CallObjectMethodV = WrapCallObjectMethodV; + jni_.DeleteGlobalRef = WrapDeleteGlobalRef; + jni_.DeleteLocalRef = WrapDeleteLocalRef; + jni_.ExceptionCheck = WrapExceptionCheck; + jni_.ExceptionClear = WrapExceptionClear; + jni_.ExceptionDescribe = WrapExceptionDescribe; + jni_.ExceptionOccurred = WrapExceptionOccurred; + jni_.FindClass = WrapFindClass; + jni_.GetFieldID = WrapGetFieldID; + jni_.GetMethodID = WrapGetMethodID; + jni_.GetObjectRefType = WrapGetObjectRefType; + jni_.GetStaticFieldID = WrapGetStaticFieldID; + jni_.GetStaticMethodID = WrapGetStaticMethodID; + jni_.NewGlobalRef = WrapNewGlobalRef; + jni_.NewLocalRef = WrapNewLocalRef; + jni_.RegisterNatives = WrapRegisterNatives; + } + + virtual jobject CallObjectMethodV(jobject, jmethodID, va_list) = 0; + virtual void DeleteGlobalRef(jobject) = 0; + virtual void DeleteLocalRef(jobject) = 0; + virtual jboolean ExceptionCheck() = 0; + virtual void ExceptionClear() = 0; + virtual void ExceptionDescribe() = 0; + virtual jthrowable ExceptionOccurred() = 0; + virtual jclass FindClass(const char*) = 0; + virtual jfieldID GetFieldID(jclass, const char*, const char*) = 0; + virtual jmethodID GetMethodID(jclass, const char*, const char*) = 0; + virtual jobjectRefType GetObjectRefType(jobject) = 0; + virtual jfieldID GetStaticFieldID(jclass, const char*, const char*) = 0; + virtual jmethodID GetStaticMethodID(jclass, const char*, const char*) = 0; + virtual jobject NewGlobalRef(jobject) = 0; + virtual jobject NewLocalRef(jobject) = 0; + virtual jint RegisterNatives(jclass, const JNINativeMethod*, jint) = 0; + + private: + static jobject WrapCallObjectMethod(JNIEnv* env, + jobject obj, + jmethodID methodID, + ...) { + va_list args; + va_start(args, methodID); + jobject result = WrapCallObjectMethodV(env, obj, methodID, args); + va_end(args); + return result; + } + static jobject WrapCallObjectMethodV(JNIEnv* env, + jobject obj, + jmethodID methodID, + va_list args) { + return static_cast(env)->CallObjectMethodV(obj, methodID, + args); + } + static void WrapDeleteGlobalRef(JNIEnv* env, jobject globalRef) { + static_cast(env)->DeleteGlobalRef(globalRef); + } + static void WrapDeleteLocalRef(JNIEnv* env, jobject localRef) { + static_cast(env)->DeleteLocalRef(localRef); + } + static jboolean WrapExceptionCheck(JNIEnv* env) { + return static_cast(env)->ExceptionCheck(); + } + static void WrapExceptionClear(JNIEnv* env) { + static_cast(env)->ExceptionClear(); + } + static void WrapExceptionDescribe(JNIEnv* env) { + static_cast(env)->ExceptionDescribe(); + } + static jthrowable WrapExceptionOccurred(JNIEnv* env) { + return static_cast(env)->ExceptionOccurred(); + } + static jclass WrapFindClass(JNIEnv* env, const char* name) { + return static_cast(env)->FindClass(name); + } + static jfieldID WrapGetFieldID(JNIEnv* env, + jclass clazz, + const char* name, + const char* sig) { + return static_cast(env)->GetFieldID(clazz, name, sig); + } + static jmethodID WrapGetMethodID(JNIEnv* env, + jclass clazz, + const char* name, + const char* sig) { + return static_cast(env)->GetMethodID(clazz, name, sig); + } + static jobjectRefType WrapGetObjectRefType(JNIEnv* env, jobject obj) { + return static_cast(env)->GetObjectRefType(obj); + } + static jfieldID WrapGetStaticFieldID(JNIEnv* env, + jclass clazz, + const char* name, + const char* sig) { + return static_cast(env)->GetStaticFieldID(clazz, name, + sig); + } + static jmethodID WrapGetStaticMethodID(JNIEnv* env, + jclass clazz, + const char* name, + const char* sig) { + return static_cast(env)->GetStaticMethodID(clazz, name, + sig); + } + static jobject WrapNewGlobalRef(JNIEnv* env, jobject ref) { + return static_cast(env)->NewGlobalRef(ref); + } + static jobject WrapNewLocalRef(JNIEnv* env, jobject ref) { + return static_cast(env)->NewLocalRef(ref); + } + static jint WrapRegisterNatives(JNIEnv* env, + jclass clazz, + const JNINativeMethod* methods, + jint nMethods) { + return static_cast(env)->RegisterNatives(clazz, methods, + nMethods); + } + + JNINativeInterface jni_ = {}; +}; + +class MockJNIEnv : public MockableJNIEnv { + public: + MOCK_METHOD(jobject, + CallObjectMethodV, + (jobject, jmethodID, va_list), + (override)); + MOCK_METHOD(void, DeleteGlobalRef, (jobject), (override)); + MOCK_METHOD(void, DeleteLocalRef, (jobject), (override)); + MOCK_METHOD(jboolean, ExceptionCheck, (), (override)); + MOCK_METHOD(void, ExceptionClear, (), (override)); + MOCK_METHOD(void, ExceptionDescribe, (), (override)); + MOCK_METHOD(jthrowable, ExceptionOccurred, (), (override)); + MOCK_METHOD(jclass, FindClass, (const char*), (override)); + MOCK_METHOD(jfieldID, + GetFieldID, + (jclass, const char*, const char*), + (override)); + MOCK_METHOD(jmethodID, + GetMethodID, + (jclass, const char*, const char*), + (override)); + MOCK_METHOD(jobjectRefType, GetObjectRefType, (jobject), (override)); + MOCK_METHOD(jfieldID, + GetStaticFieldID, + (jclass, const char*, const char*), + (override)); + MOCK_METHOD(jmethodID, + GetStaticMethodID, + (jclass, const char*, const char*), + (override)); + MOCK_METHOD(jobject, NewGlobalRef, (jobject), (override)); + MOCK_METHOD(jobject, NewLocalRef, (jobject), (override)); + MOCK_METHOD(jint, + RegisterNatives, + (jclass, const JNINativeMethod*, jint), + (override)); +}; + +} // namespace flutter + +#endif // FLUTTER_SHELL_PLATFORM_ANDROID_JNI_MOCK_JNI_ENV_H_ diff --git a/engine/src/flutter/shell/platform/android/platform_view_android_jni_impl.cc b/engine/src/flutter/shell/platform/android/platform_view_android_jni_impl.cc index 0d1db93bb4a88..922d2ce88d884 100644 --- a/engine/src/flutter/shell/platform/android/platform_view_android_jni_impl.cc +++ b/engine/src/flutter/shell/platform/android/platform_view_android_jni_impl.cc @@ -1697,7 +1697,9 @@ JavaLocalRef PlatformViewAndroidJNIImpl::ImageGetHardwareBuffer( JavaLocalRef r = JavaLocalRef( env, env->CallObjectMethod(image.obj(), g_image_get_hardware_buffer_method)); - FML_CHECK(fml::jni::CheckException(env)); + if (fml::jni::ClearException(env, false)) { + return JavaLocalRef(); + } return r; } diff --git a/engine/src/flutter/shell/platform/android/platform_view_android_jni_impl_unittests.cc b/engine/src/flutter/shell/platform/android/platform_view_android_jni_impl_unittests.cc new file mode 100644 index 0000000000000..02f784a8964a8 --- /dev/null +++ b/engine/src/flutter/shell/platform/android/platform_view_android_jni_impl_unittests.cc @@ -0,0 +1,107 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "gmock/gmock.h" +#include "gtest/gtest.h" + +#include "flutter/fml/platform/android/jni_util.h" +#include "flutter/fml/platform/android/jni_weak_ref.h" +#include "flutter/fml/platform/android/scoped_java_ref.h" +#include "flutter/shell/platform/android/jni/mock_jni_env.h" +#include "flutter/shell/platform/android/platform_view_android.h" +#include "flutter/shell/platform/android/platform_view_android_jni_impl.h" + +namespace flutter { +namespace testing { + +using ::testing::_; +using ::testing::Return; +using ::testing::ReturnArg; + +class PlatformViewAndroidJNIImplTest : public ::testing::Test { + public: + static void SetUpTestSuite() { + static std::once_flag jvm_init_flag; + std::call_once(jvm_init_flag, SetUpJVM); + } + + private: + friend class MockJNIEnvProvider; + static MockJavaVM jvm_; + static void SetUpJVM(); +}; + +MockJavaVM PlatformViewAndroidJNIImplTest::jvm_; + +class MockJNIEnvProvider { + public: + MockJNIEnvProvider() { + PlatformViewAndroidJNIImplTest::jvm_.SetJNIEnv(&env_); + } + ~MockJNIEnvProvider() { + PlatformViewAndroidJNIImplTest::jvm_.SetJNIEnv(nullptr); + } + MockJNIEnv& env() { return env_; } + + private: + MockJNIEnv env_; +}; + +void PlatformViewAndroidJNIImplTest::SetUpJVM() { + fml::jni::InitJavaVM(&jvm_); + + MockJNIEnvProvider env_provider; + MockJNIEnv& mock_env = env_provider.env(); + + const jclass kPlaceholderClass = reinterpret_cast(100); + const jfieldID kPlaceholderFieldID = reinterpret_cast(200); + const jmethodID kPlaceholderMethodID = reinterpret_cast(300); + + EXPECT_CALL(mock_env, GetObjectRefType(_)) + .WillRepeatedly(Return(JNILocalRefType)); + EXPECT_CALL(mock_env, NewLocalRef(_)).WillRepeatedly(ReturnArg<0>()); + EXPECT_CALL(mock_env, DeleteLocalRef(_)).WillRepeatedly(Return()); + EXPECT_CALL(mock_env, NewGlobalRef(_)).WillRepeatedly(ReturnArg<0>()); + EXPECT_CALL(mock_env, DeleteGlobalRef(_)).WillRepeatedly(Return()); + EXPECT_CALL(mock_env, FindClass(_)).WillRepeatedly(Return(kPlaceholderClass)); + EXPECT_CALL(mock_env, GetFieldID(_, _, _)) + .WillRepeatedly(Return(kPlaceholderFieldID)); + EXPECT_CALL(mock_env, GetMethodID(_, _, _)) + .WillRepeatedly(Return(kPlaceholderMethodID)); + EXPECT_CALL(mock_env, GetStaticFieldID(_, _, _)) + .WillRepeatedly(Return(kPlaceholderFieldID)); + EXPECT_CALL(mock_env, GetStaticMethodID(_, _, _)) + .WillRepeatedly(Return(kPlaceholderMethodID)); + EXPECT_CALL(mock_env, ExceptionCheck()).WillRepeatedly(Return(JNI_FALSE)); + EXPECT_CALL(mock_env, RegisterNatives(_, _, _)).WillRepeatedly(Return(0)); + + PlatformViewAndroid::Register(&mock_env); +} + +TEST_F(PlatformViewAndroidJNIImplTest, ImageGetHardwareBufferException) { + MockJNIEnvProvider env_provider; + MockJNIEnv& mock_env = env_provider.env(); + + // Call ImageGetHardwareBuffer and simulate throwing an exception. + // Verify that it clears the exception and does not abort the process. + EXPECT_CALL(mock_env, GetObjectRefType(_)) + .WillRepeatedly(Return(JNILocalRefType)); + EXPECT_CALL(mock_env, NewLocalRef(_)).WillRepeatedly(ReturnArg<0>()); + EXPECT_CALL(mock_env, DeleteLocalRef(_)).WillRepeatedly(Return()); + EXPECT_CALL(mock_env, CallObjectMethodV(_, _, _)) + .WillRepeatedly(Return(nullptr)); + EXPECT_CALL(mock_env, ExceptionCheck()).WillOnce(Return(JNI_TRUE)); + EXPECT_CALL(mock_env, ExceptionDescribe()).WillOnce(Return()); + EXPECT_CALL(mock_env, ExceptionClear()).Times(1).WillOnce(Return()); + + fml::jni::JavaObjectWeakGlobalRef flutter_jni_object; + PlatformViewAndroidJNIImpl android_jni(flutter_jni_object); + + fml::jni::ScopedJavaLocalRef image(&mock_env, + reinterpret_cast(123)); + android_jni.ImageGetHardwareBuffer(image); +} + +} // namespace testing +} // namespace flutter diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/android/KeyboardManagerTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/android/KeyboardManagerTest.java index c347cae538b5f..ec74f5e8df73c 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/android/KeyboardManagerTest.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/android/KeyboardManagerTest.java @@ -799,6 +799,100 @@ public void tapLowerA() { calls.clear(); } + @Test + public void virtualKeyboardShiftUpClearsState() { + final KeyboardTester tester = new KeyboardTester(); + final ArrayList calls = new ArrayList<>(); + + tester.recordEmbedderCallsTo(calls); + tester.respondToTextInputWith(true); // Suppress redispatching + + final long virtualShiftLeftPhysicalKey = KEYCODE_SHIFT_LEFT | KeyboardMap.kAndroidPlane; + + // 1. Simulate ShiftLeft DOWN from virtual keyboard (scancode = 0) + assertTrue( + tester.keyboardManager.handleEvent( + new FakeKeyEvent( + ACTION_DOWN, + 0, + KEYCODE_SHIFT_LEFT, + 0, + '\0', + META_SHIFT_ON, + KeyCharacterMap.VIRTUAL_KEYBOARD, + 0))); + + verifyEmbedderEvents( + calls, + new KeyData[] { + buildKeyData( + Type.kDown, + virtualShiftLeftPhysicalKey, + LOGICAL_SHIFT_LEFT, + null, + false, + DeviceType.kKeyboard), + }); + calls.clear(); + + // 2. Simulate ShiftLeft UP from virtual keyboard (scancode = 0) with meta bit still active + assertTrue( + tester.keyboardManager.handleEvent( + new FakeKeyEvent( + ACTION_UP, + 0, + KEYCODE_SHIFT_LEFT, + 0, + '\0', + META_SHIFT_ON, + KeyCharacterMap.VIRTUAL_KEYBOARD, + 0))); + + // Verify that it does NOT synthesize SHIFT_RIGHT DOWN. + // It should only send SHIFT_LEFT UP. + verifyEmbedderEvents( + calls, + new KeyData[] { + buildKeyData( + Type.kUp, + virtualShiftLeftPhysicalKey, + LOGICAL_SHIFT_LEFT, + null, + false, + DeviceType.kKeyboard), + }); + calls.clear(); + + // 3. Simulate Backspace DOWN from virtual keyboard (scancode = 0) with meta bit still active + final long virtualBackspacePhysicalKey = KEYCODE_DEL | KeyboardMap.kAndroidPlane; + assertTrue( + tester.keyboardManager.handleEvent( + new FakeKeyEvent( + ACTION_DOWN, + 0, + KEYCODE_DEL, + 0, + '\0', + META_SHIFT_ON, + KeyCharacterMap.VIRTUAL_KEYBOARD, + 0))); + + // Verify that it does NOT synthesize SHIFT_LEFT DOWN or SHIFT_RIGHT DOWN. + // It should only send Backspace DOWN. + verifyEmbedderEvents( + calls, + new KeyData[] { + buildKeyData( + Type.kDown, + virtualBackspacePhysicalKey, + LOGICAL_BACKSPACE, + null, + false, + DeviceType.kKeyboard), + }); + calls.clear(); + } + @Test public void tapUpperA() { final KeyboardTester tester = new KeyboardTester(); diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/util/FakeKeyEvent.java b/engine/src/flutter/shell/platform/android/test/io/flutter/util/FakeKeyEvent.java index 35ef6731d6ec8..eabf3f95355e5 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/util/FakeKeyEvent.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/util/FakeKeyEvent.java @@ -26,6 +26,19 @@ public FakeKeyEvent( this.character = character; } + public FakeKeyEvent( + int action, + int scancode, + int code, + int repeat, + char character, + int metaState, + int deviceId, + int source) { + super(0, 0, action, code, repeat, metaState, deviceId, scancode, 0, source); + this.character = character; + } + private char character = 0; public final int getUnicodeChar() { diff --git a/engine/src/flutter/shell/platform/darwin/ios/BUILD.gn b/engine/src/flutter/shell/platform/darwin/ios/BUILD.gn index 9256fe6f44d50..d2b479ac25969 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/BUILD.gn +++ b/engine/src/flutter/shell/platform/darwin/ios/BUILD.gn @@ -45,8 +45,12 @@ source_set("InternalFlutterSwift") { # Targets should only ever depend on the framework target. # This allows code in this target to use types declared in the bridging # header, but defined in the framework, while avoiding linking errors. - visibility = [ ":flutter_framework_source" ] + visibility = [ + ":flutter_framework_source", + ":ios_test_flutter", + ] configs += [ "//flutter/shell/platform/darwin/common:config" ] + deps = [ ":flutter_engine_bindings" ] include_dirs = [ "//flutter/shell/platform/darwin/common/framework/Headers", "//flutter/shell/platform/darwin/ios/framework/Headers", @@ -212,23 +216,52 @@ source_set("flutter_framework_source") { "//flutter/third_party/icu", "//flutter/third_party/spring_animation", ] - public_deps = [ ":InternalFlutterSwift" ] + public_deps = [ + ":InternalFlutterSwift", + ":flutter_engine_bindings", + ] } if (enable_ios_unittests) { + source_set("flutter_engine_bindings_test_helpers") { + testonly = true + visibility = [ ":*" ] + configs += [ "//flutter/shell/platform/darwin/common:test_config" ] + sources = [ + "framework/Source/FlutterFMLTaskRunnerTestHelper.h", + "framework/Source/FlutterFMLTaskRunnerTestHelper.mm", + ] + deps = [ + ":flutter_engine_bindings", + "//flutter/fml", + ] + } + source_set("ios_test_flutter_swift") { testonly = true visibility = [ ":*" ] configs += [ "//flutter/shell/platform/darwin/common:test_config" ] + include_dirs = [ + "//flutter/shell/platform/darwin/common/framework/Headers", + "//flutter/shell/platform/darwin/ios/framework/Headers", + "//flutter/shell/platform/darwin/ios/framework", + ] + bridge_header = "FlutterTests-Bridging-Header.h" sources = [ "framework/Source/AccessibilityFeaturesTests.swift", "framework/Source/ConnectionCollectionTest.swift", "framework/Source/FakeUIPressProxy.swift", "framework/Source/LaunchEngineTest.swift", "framework/Source/SplashScreenManagerTests.swift", + "framework/Source/TaskRunnerTests.swift", ] frameworks = [ "XCTest.framework" ] - deps = [ ":flutter_framework_source" ] + deps = [ + ":flutter_engine_bindings", + ":flutter_engine_bindings_test_helpers", + ":flutter_framework_source", + "//flutter/fml", + ] } shared_library("ios_test_flutter") { @@ -291,6 +324,8 @@ if (enable_ios_unittests) { "platform_view_ios_test.mm", ] deps = [ + ":InternalFlutterSwift", + ":flutter_engine_bindings_test_helpers", ":flutter_framework", ":flutter_framework_source", ":ios_gpu_configuration", @@ -314,6 +349,34 @@ if (enable_ios_unittests) { } } # if (enable_ios_unittests) +source_set("flutter_engine_bindings") { + visibility = [ ":*" ] + cflags_objc = flutter_cflags_objc + cflags_objcc = flutter_cflags_objcc + + defines = [ "FLUTTER_FRAMEWORK=1" ] + configs += [ "//flutter/shell/platform/darwin/common:config" ] + include_dirs = [ + "//flutter/shell/platform/darwin/common/framework/Headers", + "//flutter/shell/platform/darwin/ios/framework/Headers", + "//flutter/shell/platform/darwin/ios/framework", # For module.modulemap + ] + sources = [ + "framework/Source/FlutterFMLTaskRunner+FML.h", + "framework/Source/FlutterFMLTaskRunner.h", + "framework/Source/FlutterFMLTaskRunner.mm", + "framework/Source/FlutterFMLTaskRunners.h", + "framework/Source/FlutterFMLTaskRunners.mm", + ] + deps = [ + "//flutter/common", + "//flutter/fml", + "//flutter/shell/common", + "//flutter/shell/platform/darwin/common:framework_common", + ] + public_deps = [ "//flutter/third_party/spring_animation" ] +} + shared_library("create_flutter_framework_dylib") { visibility = [ ":*" ] diff --git a/engine/src/flutter/shell/platform/darwin/ios/FlutterTests-Bridging-Header.h b/engine/src/flutter/shell/platform/darwin/ios/FlutterTests-Bridging-Header.h new file mode 100644 index 0000000000000..815a21f916555 --- /dev/null +++ b/engine/src/flutter/shell/platform/darwin/ios/FlutterTests-Bridging-Header.h @@ -0,0 +1,12 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FLUTTERTESTS_BRIDGING_HEADER_H_ +#define FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FLUTTERTESTS_BRIDGING_HEADER_H_ + +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunner.h" +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunnerTestHelper.h" +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunners.h" + +#endif // FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FLUTTERTESTS_BRIDGING_HEADER_H_ diff --git a/engine/src/flutter/shell/platform/darwin/ios/InternalFlutterSwift-Bridging-Header.h b/engine/src/flutter/shell/platform/darwin/ios/InternalFlutterSwift-Bridging-Header.h index 82525e94c2652..ab8d6163d0955 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/InternalFlutterSwift-Bridging-Header.h +++ b/engine/src/flutter/shell/platform/darwin/ios/InternalFlutterSwift-Bridging-Header.h @@ -6,5 +6,7 @@ #define FLUTTER_SHELL_PLATFORM_DARWIN_IOS_INTERNALFLUTTERSWIFT_BRIDGING_HEADER_H_ #import "flutter/shell/platform/darwin/ios/framework/Headers/Flutter.h" +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunner.h" +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunners.h" #endif // FLUTTER_SHELL_PLATFORM_DARWIN_IOS_INTERNALFLUTTERSWIFT_BRIDGING_HEADER_H_ diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunner+FML.h b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunner+FML.h new file mode 100644 index 0000000000000..f743489dc9bef --- /dev/null +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunner+FML.h @@ -0,0 +1,21 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_FLUTTERFMLTASKRUNNER_FML_H_ +#define FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_FLUTTERFMLTASKRUNNER_FML_H_ + +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunner.h" + +#include "flutter/fml/memory/ref_ptr.h" +#include "flutter/fml/task_runner.h" + +@interface FlutterFMLTaskRunner () + +- (instancetype)initWithTaskRunner:(fml::RefPtr)task_runner; + +- (fml::RefPtr)taskRunner; + +@end + +#endif // FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_FLUTTERFMLTASKRUNNER_FML_H_ diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunner.h b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunner.h new file mode 100644 index 0000000000000..f4937ff575c7b --- /dev/null +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunner.h @@ -0,0 +1,22 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_FLUTTERFMLTASKRUNNER_H_ +#define FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_FLUTTERFMLTASKRUNNER_H_ + +#import + +NS_SWIFT_NAME(TaskRunner) +@interface FlutterFMLTaskRunner : NSObject + +- (void)postTask:(void (^)(void))task; +- (void)runNowOrPostTask:(void (^)(void))task; +- (void)postTaskWithDelay:(NSTimeInterval)delay + task:(void (^)(void))task NS_SWIFT_NAME(postTask(delay:task:)); + +- (BOOL)runsTasksOnCurrentThread; + +@end + +#endif // FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_FLUTTERFMLTASKRUNNER_H_ diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunner.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunner.mm new file mode 100644 index 0000000000000..1709030891fc6 --- /dev/null +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunner.mm @@ -0,0 +1,46 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunner+FML.h" + +#include + +#include "flutter/fml/logging.h" + +@implementation FlutterFMLTaskRunner { + fml::RefPtr _taskRunner; +} + +- (instancetype)initWithTaskRunner:(fml::RefPtr)task_runner { + FML_DCHECK(task_runner); + if (self = [super init]) { + _taskRunner = std::move(task_runner); + } + return self; +} + +- (void)postTask:(void (^)(void))task { + FML_DCHECK(task); + _taskRunner->PostTask([task]() { task(); }); +} + +- (void)runNowOrPostTask:(void (^)(void))task { + FML_DCHECK(task); + fml::TaskRunner::RunNowOrPostTask(_taskRunner, [task]() { task(); }); +} + +- (void)postTaskWithDelay:(NSTimeInterval)delay task:(void (^)(void))task { + FML_DCHECK(task); + _taskRunner->PostDelayedTask([task]() { task(); }, fml::TimeDelta::FromSecondsF(delay)); +} + +- (BOOL)runsTasksOnCurrentThread { + return _taskRunner->RunsTasksOnCurrentThread(); +} + +- (fml::RefPtr)taskRunner { + return _taskRunner; +} + +@end diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunnerTestHelper.h b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunnerTestHelper.h new file mode 100644 index 0000000000000..c4949b4ef5a73 --- /dev/null +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunnerTestHelper.h @@ -0,0 +1,32 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_FLUTTERFMLTASKRUNNERTESTHELPER_H_ +#define FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_FLUTTERFMLTASKRUNNERTESTHELPER_H_ + +#import + +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunner.h" +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunners.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface FlutterFMLTaskRunnerTestHelper : NSObject + +/** + * Returns a FlutterFMLTaskRunner for the current thread. + */ ++ (FlutterFMLTaskRunner*)makeCurrentThreadTaskRunner; + +/** + * Returns a FlutterFMLTaskRunners object where all runners point to the same task runner. + */ ++ (FlutterFMLTaskRunners*)makeTaskRunnersWithLabel:(NSString*)label + taskRunner:(FlutterFMLTaskRunner*)taskRunner; + +@end + +NS_ASSUME_NONNULL_END + +#endif // FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_FLUTTERFMLTASKRUNNERTESTHELPER_H_ diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunnerTestHelper.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunnerTestHelper.mm new file mode 100644 index 0000000000000..d656466cafea8 --- /dev/null +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunnerTestHelper.mm @@ -0,0 +1,27 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunnerTestHelper.h" + +#include "flutter/fml/message_loop.h" +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunner+FML.h" + +@implementation FlutterFMLTaskRunnerTestHelper + ++ (FlutterFMLTaskRunner*)makeCurrentThreadTaskRunner { + fml::MessageLoop::EnsureInitializedForCurrentThread(); + return [[FlutterFMLTaskRunner alloc] + initWithTaskRunner:fml::MessageLoop::GetCurrent().GetTaskRunner()]; +} + ++ (FlutterFMLTaskRunners*)makeTaskRunnersWithLabel:(NSString*)label + taskRunner:(FlutterFMLTaskRunner*)taskRunner { + return [[FlutterFMLTaskRunners alloc] initWithLabel:label + platformTaskRunner:taskRunner + rasterTaskRunner:taskRunner + uiTaskRunner:taskRunner + ioTaskRunner:taskRunner]; +} + +@end diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunners+FML.h b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunners+FML.h new file mode 100644 index 0000000000000..5379aa2c19179 --- /dev/null +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunners+FML.h @@ -0,0 +1,17 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_FLUTTERFMLTASKRUNNERS_FML_H_ +#define FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_FLUTTERFMLTASKRUNNERS_FML_H_ + +#include "flutter/common/task_runners.h" +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunners.h" + +@interface FlutterFMLTaskRunners (FML) + +@property(nonatomic, readonly) const flutter::TaskRunners& taskRunners; + +@end + +#endif // FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_FLUTTERFMLTASKRUNNERS_FML_H_ diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunners.h b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunners.h new file mode 100644 index 0000000000000..eb17dc3b47866 --- /dev/null +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunners.h @@ -0,0 +1,33 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_FLUTTERFMLTASKRUNNERS_H_ +#define FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_FLUTTERFMLTASKRUNNERS_H_ + +#import + +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunner.h" + +NS_ASSUME_NONNULL_BEGIN + +NS_SWIFT_NAME(TaskRunners) +@interface FlutterFMLTaskRunners : NSObject + +@property(nonatomic, readonly) NSString* label; +@property(nonatomic, readonly) FlutterFMLTaskRunner* platformTaskRunner; +@property(nonatomic, readonly) FlutterFMLTaskRunner* rasterTaskRunner; +@property(nonatomic, readonly) FlutterFMLTaskRunner* uiTaskRunner; +@property(nonatomic, readonly) FlutterFMLTaskRunner* ioTaskRunner; + +- (instancetype)initWithLabel:(NSString*)label + platformTaskRunner:(FlutterFMLTaskRunner*)platformTaskRunner + rasterTaskRunner:(FlutterFMLTaskRunner*)rasterTaskRunner + uiTaskRunner:(FlutterFMLTaskRunner*)uiTaskRunner + ioTaskRunner:(FlutterFMLTaskRunner*)ioTaskRunner; + +@end + +NS_ASSUME_NONNULL_END + +#endif // FLUTTER_SHELL_PLATFORM_DARWIN_IOS_FRAMEWORK_SOURCE_FLUTTERFMLTASKRUNNERS_H_ diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunners.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunners.mm new file mode 100644 index 0000000000000..6202ef5dabb48 --- /dev/null +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunners.mm @@ -0,0 +1,43 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunners.h" + +#include + +#include "flutter/common/task_runners.h" +#import "flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunner+FML.h" + +@interface FlutterFMLTaskRunners () { + std::unique_ptr _taskRunners; +} +@end + +@implementation FlutterFMLTaskRunners + +- (instancetype)initWithLabel:(nonnull NSString*)label + platformTaskRunner:(nonnull FlutterFMLTaskRunner*)platformTaskRunner + rasterTaskRunner:(nonnull FlutterFMLTaskRunner*)rasterTaskRunner + uiTaskRunner:(nonnull FlutterFMLTaskRunner*)uiTaskRunner + ioTaskRunner:(nonnull FlutterFMLTaskRunner*)ioTaskRunner { + self = [super init]; + if (self) { + _label = label; + _platformTaskRunner = platformTaskRunner; + _rasterTaskRunner = rasterTaskRunner; + _uiTaskRunner = uiTaskRunner; + _ioTaskRunner = ioTaskRunner; + + _taskRunners = std::make_unique( + label.UTF8String, platformTaskRunner.taskRunner, rasterTaskRunner.taskRunner, + uiTaskRunner.taskRunner, ioTaskRunner.taskRunner); + } + return self; +} + +- (const flutter::TaskRunners&)taskRunners { + return *_taskRunners; +} + +@end diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject+UIFocusSystem.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject+UIFocusSystem.mm index c9b64f5e1656b..c21012fc40cdf 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject+UIFocusSystem.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObject+UIFocusSystem.mm @@ -61,7 +61,7 @@ - (void)didUpdateFocusInContext:(UIFocusUpdateContext*)context - (id)parentFocusEnvironment { // The root SemanticsObject node's parent is the FlutterView. - return self.parent.focusItem ?: self.bridge->view(); + return self.parent.focusItem ?: ([self isAccessibilityBridgeAlive] ? self.bridge->view() : nil); } - (NSArray>*)preferredFocusEnvironments { @@ -89,6 +89,9 @@ - (BOOL)canBecomeFocused { // See also the `coordinateSpace` implementation. // TODO(LongCatIsLooong): use CoreGraphics types. - (CGRect)frame { + if (![self isAccessibilityBridgeAlive]) { + return CGRectZero; + } SkPoint quad[4] = {SkPoint::Make(self.node.rect.left(), self.node.rect.top()), SkPoint::Make(self.node.rect.left(), self.node.rect.bottom()), SkPoint::Make(self.node.rect.right(), self.node.rect.top()), @@ -159,7 +162,8 @@ - (CGRect)frame { - (id)coordinateSpace { // A regular SemanticsObject uses the same coordinate space as its parent. - return self.parent.coordinateSpace ?: self.bridge->view(); + return self.parent.coordinateSpace + ?: ([self isAccessibilityBridgeAlive] ? self.bridge->view() : nil); } @end diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObjectTest.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObjectTest.mm index 6750878f41be5..fee32ce72f6be 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObjectTest.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SemanticsObjectTest.mm @@ -41,6 +41,22 @@ - (void)testCreate { XCTAssertNotNil(object); } +- (void)testUIFocusSystemMethodsDoNotCrashWhenBridgeIsDead { + fml::WeakPtr bridge; + SemanticsObject* object; + { + auto mock_bridge = std::make_unique(); + fml::WeakPtrFactory factory(mock_bridge.get()); + bridge = factory.GetWeakPtr(); + object = [[SemanticsObject alloc] initWithBridge:bridge uid:0]; + } + // Now bridge is nullptr. + + XCTAssertNil([object parentFocusEnvironment]); + XCTAssertNil([object coordinateSpace]); + XCTAssertTrue(CGRectEqualToRect([object frame], CGRectZero)); +} + - (void)testSetChildren { fml::WeakPtrFactory factory( new flutter::testing::MockAccessibilityBridge()); diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/TaskRunnerTests.swift b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/TaskRunnerTests.swift new file mode 100644 index 0000000000000..9c9b915df0a70 --- /dev/null +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/TaskRunnerTests.swift @@ -0,0 +1,40 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import InternalFlutterSwift +import XCTest + +class TaskRunnerTests: XCTestCase { + + func testPostTask() { + let taskRunner = FlutterFMLTaskRunnerTestHelper.makeCurrentThreadTaskRunner() + + let expectation = self.expectation(description: "Task should be executed") + taskRunner.postTask { + expectation.fulfill() + } + + waitForExpectations(timeout: 5.0, handler: nil) + } +func testPostDelayedTask() { + let taskRunner = FlutterFMLTaskRunnerTestHelper.makeCurrentThreadTaskRunner() + + let expectation = self.expectation(description: "Delayed task should be executed") + let startTime = CACurrentMediaTime() + taskRunner.postTask(delay: 0.1) { + let endTime = CACurrentMediaTime() + XCTAssertGreaterThanOrEqual(endTime - startTime, 0.1) + expectation.fulfill() + } + + waitForExpectations(timeout: 5.0, handler: nil) +} + + + func testRunsTasksOnCurrentThread() { + let taskRunner = FlutterFMLTaskRunnerTestHelper.makeCurrentThreadTaskRunner() + + XCTAssertTrue(taskRunner.runsTasksOnCurrentThread()) + } +} diff --git a/engine/src/flutter/shell/platform/fuchsia/dart_runner/meta/common.shard.cml b/engine/src/flutter/shell/platform/fuchsia/dart_runner/meta/common.shard.cml index 6137189aaa91b..bb473bfa635f4 100644 --- a/engine/src/flutter/shell/platform/fuchsia/dart_runner/meta/common.shard.cml +++ b/engine/src/flutter/shell/platform/fuchsia/dart_runner/meta/common.shard.cml @@ -30,7 +30,6 @@ "fuchsia.device.NameProvider", // For fdio uname() "fuchsia.feedback.CrashReporter", "fuchsia.intl.PropertyProvider", // For dartVM timezone support - "fuchsia.kernel.VmexResource", // Also used in AOT for FFI callback thunks. "fuchsia.net.name.Lookup", // For fdio sockets "fuchsia.posix.socket.Provider", // For fdio sockets ], diff --git a/engine/src/flutter/shell/platform/fuchsia/dart_runner/meta/dart_jit_product_runner.cml b/engine/src/flutter/shell/platform/fuchsia/dart_runner/meta/dart_jit_product_runner.cml index ca5accde6167f..bf11fc3396103 100644 --- a/engine/src/flutter/shell/platform/fuchsia/dart_runner/meta/dart_jit_product_runner.cml +++ b/engine/src/flutter/shell/platform/fuchsia/dart_runner/meta/dart_jit_product_runner.cml @@ -21,4 +21,11 @@ from: "self", }, ], + use: [ + { + protocol: [ + "fuchsia.kernel.VmexResource", + ], + }, + ], } diff --git a/engine/src/flutter/shell/platform/fuchsia/dart_runner/meta/dart_jit_runner.cml b/engine/src/flutter/shell/platform/fuchsia/dart_runner/meta/dart_jit_runner.cml index 07310633d61a6..e28cecee6cf95 100644 --- a/engine/src/flutter/shell/platform/fuchsia/dart_runner/meta/dart_jit_runner.cml +++ b/engine/src/flutter/shell/platform/fuchsia/dart_runner/meta/dart_jit_runner.cml @@ -21,4 +21,11 @@ from: "self", }, ], + use: [ + { + protocol: [ + "fuchsia.kernel.VmexResource", + ], + }, + ], } diff --git a/engine/src/flutter/shell/platform/fuchsia/flutter/meta/common.shard.cml b/engine/src/flutter/shell/platform/fuchsia/flutter/meta/common.shard.cml index 5b30ab448fdee..ff2a2b6e4b423 100644 --- a/engine/src/flutter/shell/platform/fuchsia/flutter/meta/common.shard.cml +++ b/engine/src/flutter/shell/platform/fuchsia/flutter/meta/common.shard.cml @@ -35,7 +35,6 @@ "fuchsia.feedback.CrashReporter", "fuchsia.fonts.Provider", "fuchsia.intl.PropertyProvider", - "fuchsia.kernel.VmexResource", // Also used in AOT for FFI callback thunks. "fuchsia.media.ProfileProvider", "fuchsia.memorypressure.Provider", "fuchsia.scheduler.RoleManager", diff --git a/engine/src/flutter/shell/platform/fuchsia/flutter/meta/flutter_jit_product_runner.cml b/engine/src/flutter/shell/platform/fuchsia/flutter/meta/flutter_jit_product_runner.cml index 86939283a3652..fbad35fddcb9c 100644 --- a/engine/src/flutter/shell/platform/fuchsia/flutter/meta/flutter_jit_product_runner.cml +++ b/engine/src/flutter/shell/platform/fuchsia/flutter/meta/flutter_jit_product_runner.cml @@ -21,4 +21,11 @@ from: "self", }, ], + use: [ + { + protocol: [ + "fuchsia.kernel.VmexResource", + ], + }, + ], } diff --git a/engine/src/flutter/shell/platform/fuchsia/flutter/meta/flutter_jit_runner.cml b/engine/src/flutter/shell/platform/fuchsia/flutter/meta/flutter_jit_runner.cml index 3378b0756cbc4..c4e5fcd99ee15 100644 --- a/engine/src/flutter/shell/platform/fuchsia/flutter/meta/flutter_jit_runner.cml +++ b/engine/src/flutter/shell/platform/fuchsia/flutter/meta/flutter_jit_runner.cml @@ -21,4 +21,11 @@ from: "self", }, ], + use: [ + { + protocol: [ + "fuchsia.kernel.VmexResource", + ], + }, + ], } diff --git a/engine/src/flutter/shell/platform/windows/client_wrapper/include/flutter/plugin_registrar_windows.h b/engine/src/flutter/shell/platform/windows/client_wrapper/include/flutter/plugin_registrar_windows.h index 7655b31ff04a8..f5b3fd5b521dc 100644 --- a/engine/src/flutter/shell/platform/windows/client_wrapper/include/flutter/plugin_registrar_windows.h +++ b/engine/src/flutter/shell/platform/windows/client_wrapper/include/flutter/plugin_registrar_windows.h @@ -104,6 +104,15 @@ class PluginRegistrarWindows : public PluginRegistrar { } } + // Retrieves the DXGI adapter used for rendering. Returns true if the adapter + // was successfully retrieved, or false if an error occured. + // The caller must provide a valid pointer to an IDXGIAdapter* and is + // responsible for releasing the adapter. + bool GetGraphicsAdapter(IDXGIAdapter** adapter_out) { + return FlutterDesktopPluginRegistrarGetGraphicsAdapter(registrar(), + adapter_out); + } + private: // A FlutterDesktopWindowProcCallback implementation that forwards back to // a PluginRegistarWindows instance provided as |user_data|. diff --git a/engine/src/flutter/shell/platform/windows/client_wrapper/plugin_registrar_windows_unittests.cc b/engine/src/flutter/shell/platform/windows/client_wrapper/plugin_registrar_windows_unittests.cc index f1d08c5fb960e..b6ee16e49d895 100644 --- a/engine/src/flutter/shell/platform/windows/client_wrapper/plugin_registrar_windows_unittests.cc +++ b/engine/src/flutter/shell/platform/windows/client_wrapper/plugin_registrar_windows_unittests.cc @@ -46,6 +46,11 @@ class TestWindowsApi : public testing::StubFlutterWindowsApi { void* last_registered_user_data() { return last_registered_user_data_; } + bool PluginRegistrarGetGraphicsAdapter(IDXGIAdapter** adapter_out) override { + *adapter_out = reinterpret_cast(10); + return true; + } + private: int registered_delegate_count_ = 0; FlutterDesktopWindowProcCallback last_registered_delegate_ = nullptr; @@ -105,6 +110,19 @@ TEST(PluginRegistrarWindowsTest, GetViewById) { EXPECT_EQ(registrar.GetViewById(456).get(), nullptr); } +TEST(PluginRegistrarWindowsTest, GetGraphicsAdapter) { + auto windows_api = std::make_unique(); + EXPECT_CALL(*windows_api, PluginRegistrarGetView) + .WillRepeatedly(Return(nullptr)); + testing::ScopedStubFlutterWindowsApi scoped_api_stub(std::move(windows_api)); + auto test_api = static_cast(scoped_api_stub.stub()); + PluginRegistrarWindows registrar( + reinterpret_cast(1)); + IDXGIAdapter* adapter = nullptr; + EXPECT_TRUE(registrar.GetGraphicsAdapter(&adapter)); + EXPECT_EQ(adapter, reinterpret_cast(10)); +} + // Tests that the registrar runs plugin destructors before its own teardown. TEST(PluginRegistrarWindowsTest, PluginDestroyedBeforeRegistrar) { auto windows_api = std::make_unique(); diff --git a/engine/src/flutter/shell/platform/windows/client_wrapper/testing/stub_flutter_windows_api.cc b/engine/src/flutter/shell/platform/windows/client_wrapper/testing/stub_flutter_windows_api.cc index 7f5c2bc722502..8ed301946e5ff 100644 --- a/engine/src/flutter/shell/platform/windows/client_wrapper/testing/stub_flutter_windows_api.cc +++ b/engine/src/flutter/shell/platform/windows/client_wrapper/testing/stub_flutter_windows_api.cc @@ -217,6 +217,16 @@ FlutterDesktopViewRef FlutterDesktopPluginRegistrarGetViewById( return nullptr; } +bool FlutterDesktopPluginRegistrarGetGraphicsAdapter( + FlutterDesktopPluginRegistrarRef registrar, + IDXGIAdapter** adapter_out) { + if (s_stub_implementation) { + return s_stub_implementation->PluginRegistrarGetGraphicsAdapter( + adapter_out); + } + return false; +} + void FlutterDesktopPluginRegistrarRegisterTopLevelWindowProcDelegate( FlutterDesktopPluginRegistrarRef registrar, FlutterDesktopWindowProcCallback delegate, diff --git a/engine/src/flutter/shell/platform/windows/client_wrapper/testing/stub_flutter_windows_api.h b/engine/src/flutter/shell/platform/windows/client_wrapper/testing/stub_flutter_windows_api.h index 21dc0409b95de..bf0be80784c81 100644 --- a/engine/src/flutter/shell/platform/windows/client_wrapper/testing/stub_flutter_windows_api.h +++ b/engine/src/flutter/shell/platform/windows/client_wrapper/testing/stub_flutter_windows_api.h @@ -111,6 +111,11 @@ class StubFlutterWindowsApi { virtual void PluginRegistrarUnregisterTopLevelWindowProcDelegate( FlutterDesktopWindowProcCallback delegate) {} + // Called for FlutterDesktopPluginRegistrarGetGraphicsAdapter. + virtual bool PluginRegistrarGetGraphicsAdapter(IDXGIAdapter** adapter_out) { + return false; + } + // Called for FlutterDesktopEngineProcessExternalWindowMessage. virtual bool EngineProcessExternalWindowMessage( FlutterDesktopEngineRef engine, diff --git a/engine/src/flutter/shell/platform/windows/fixtures/main.dart b/engine/src/flutter/shell/platform/windows/fixtures/main.dart index 9fda73707ee72..85f4bf0c07d46 100644 --- a/engine/src/flutter/shell/platform/windows/fixtures/main.dart +++ b/engine/src/flutter/shell/platform/windows/fixtures/main.dart @@ -421,11 +421,10 @@ Future sendSemanticsTreeInfo() async { final Iterable views = ui.PlatformDispatcher.instance.views; final ui.FlutterView view1 = views.firstWhere( - (final ui.FlutterView view) => view != ui.PlatformDispatcher.instance.implicitView, + (ui.FlutterView view) => view != ui.PlatformDispatcher.instance.implicitView, ); final ui.FlutterView view2 = views.firstWhere( - (final ui.FlutterView view) => - view != view1 && view != ui.PlatformDispatcher.instance.implicitView, + (ui.FlutterView view) => view != view1 && view != ui.PlatformDispatcher.instance.implicitView, ); ui.SemanticsUpdate createSemanticsUpdate(int nodeId) { diff --git a/engine/src/flutter/shell/platform/windows/flutter_windows.cc b/engine/src/flutter/shell/platform/windows/flutter_windows.cc index 2e0a0c3c9cfa7..ca37c6a22ad87 100644 --- a/engine/src/flutter/shell/platform/windows/flutter_windows.cc +++ b/engine/src/flutter/shell/platform/windows/flutter_windows.cc @@ -330,6 +330,13 @@ void FlutterDesktopPluginRegistrarUnregisterTopLevelWindowProcDelegate( ->UnregisterTopLevelWindowProcDelegate(delegate); } +bool FlutterDesktopPluginRegistrarGetGraphicsAdapter( + FlutterDesktopPluginRegistrarRef registrar, + IDXGIAdapter** adapter_out) { + return FlutterDesktopEngineGetGraphicsAdapter( + HandleForEngine(registrar->engine), adapter_out); +} + UINT FlutterDesktopGetDpiForHWND(HWND hwnd) { return flutter::GetDpiForHWND(hwnd); } diff --git a/engine/src/flutter/shell/platform/windows/public/flutter_windows.h b/engine/src/flutter/shell/platform/windows/public/flutter_windows.h index 527566b4ec5b5..886b33c11f07f 100644 --- a/engine/src/flutter/shell/platform/windows/public/flutter_windows.h +++ b/engine/src/flutter/shell/platform/windows/public/flutter_windows.h @@ -331,6 +331,14 @@ FlutterDesktopPluginRegistrarUnregisterTopLevelWindowProcDelegate( FlutterDesktopPluginRegistrarRef registrar, FlutterDesktopWindowProcCallback delegate); +// Retrieves the DXGI adapter used for rendering. Returns true if the adapter +// was successfully retrieved, or false if an error occured. +// The caller must provide a valid pointer to an IDXGIAdapter* and is +// responsible for releasing the adapter. +FLUTTER_EXPORT bool FlutterDesktopPluginRegistrarGetGraphicsAdapter( + FlutterDesktopPluginRegistrarRef registrar, + IDXGIAdapter** adapter_out); + // ========== Freestanding Utilities ========== // Gets the DPI for a given |hwnd|, depending on the supported APIs per diff --git a/engine/src/flutter/skwasm/fonts.cc b/engine/src/flutter/skwasm/fonts.cc index decd150c02efa..e3d96d86734c0 100644 --- a/engine/src/flutter/skwasm/fonts.cc +++ b/engine/src/flutter/skwasm/fonts.cc @@ -46,41 +46,6 @@ SKWASM_EXPORT void typeface_dispose(SkTypeface* typeface) { typeface->unref(); } -// Calculates the code points that are not covered by the specified typefaces. -// This function mutates the `code_points` buffer in place and returns the count -// of code points that are not covered by the fonts. -SKWASM_EXPORT int typefaces_filterCoveredCodePoints(SkTypeface** typefaces, - int typeface_count, - SkUnichar* code_points, - int code_point_count) { - std::unique_ptr glyph_buffer = - std::make_unique(code_point_count); - SkGlyphID* glyph_pointer = glyph_buffer.get(); - int remaining_code_point_count = code_point_count; - for (int typeface_index = 0; typeface_index < typeface_count; - typeface_index++) { - typefaces[typeface_index]->unicharsToGlyphs( - {code_points, remaining_code_point_count}, - {glyph_pointer, remaining_code_point_count}); - int output_index = 0; - for (int input_index = 0; input_index < remaining_code_point_count; - input_index++) { - if (glyph_pointer[input_index] == 0) { - if (output_index != input_index) { - code_points[output_index] = code_points[input_index]; - } - output_index++; - } - } - if (output_index == 0) { - return 0; - } else { - remaining_code_point_count = output_index; - } - } - return remaining_code_point_count; -} - SKWASM_EXPORT void fontCollection_registerTypeface( Skwasm::FlutterFontCollection* collection, SkTypeface* typeface, diff --git a/examples/image_list/lib/main.dart b/examples/image_list/lib/main.dart index 390f0a8c10be4..14b64e71a6391 100644 --- a/examples/image_list/lib/main.dart +++ b/examples/image_list/lib/main.dart @@ -154,7 +154,7 @@ class _MyHomePageState extends State with TickerProviderStateMixin { }); } - Widget createImage(final int index, final Completer completer) { + Widget createImage(int index, Completer completer) { return Image.network( 'https://localhost:${widget.port}/${_counter * images + index}', frameBuilder: (BuildContext context, Widget child, int? frame, bool wasSynchronouslyLoaded) { diff --git a/examples/multiple_windows/lib/app/popup_button.dart b/examples/multiple_windows/lib/app/popup_button.dart index 8fb2bf62aca9e..3f18cec5da5ad 100644 --- a/examples/multiple_windows/lib/app/popup_button.dart +++ b/examples/multiple_windows/lib/app/popup_button.dart @@ -32,7 +32,7 @@ class _PopupButtonState extends State { super.dispose(); } - void _onPressed(final WindowRegistry windowRegistry, final WindowSettings windowSettings) { + void _onPressed(WindowRegistry windowRegistry, WindowSettings windowSettings) { // Toggle popup visibility. if (_popupWindowEntry != null) { _popupWindowEntry!.controller.destroy(); diff --git a/examples/multiple_windows/lib/app/tooltip_button.dart b/examples/multiple_windows/lib/app/tooltip_button.dart index 5c8bda52baac2..d2e5fa3674472 100644 --- a/examples/multiple_windows/lib/app/tooltip_button.dart +++ b/examples/multiple_windows/lib/app/tooltip_button.dart @@ -32,7 +32,7 @@ class _TooltipButtonState extends State { super.dispose(); } - void _onPressed(final WindowRegistry windowRegistry, final WindowSettings windowSettings) { + void _onPressed(WindowRegistry windowRegistry, WindowSettings windowSettings) { // Toggle tooltip visibility. if (_tooltipEntry != null) { _tooltipEntry!.controller.destroy(); diff --git a/packages/flutter/lib/src/animation/animation_style.dart b/packages/flutter/lib/src/animation/animation_style.dart index b27f58744d81e..9a882a0febb68 100644 --- a/packages/flutter/lib/src/animation/animation_style.dart +++ b/packages/flutter/lib/src/animation/animation_style.dart @@ -51,10 +51,10 @@ class AnimationStyle with Diagnosticable { /// Creates a new [AnimationStyle] based on the current selection, with the /// provided parameters overridden. AnimationStyle copyWith({ - final Curve? curve, - final Duration? duration, - final Curve? reverseCurve, - final Duration? reverseDuration, + Curve? curve, + Duration? duration, + Curve? reverseCurve, + Duration? reverseDuration, }) { return AnimationStyle( curve: curve ?? this.curve, diff --git a/packages/flutter/lib/src/material/about.dart b/packages/flutter/lib/src/material/about.dart index 54353db1af67f..ad1d1d30c2758 100644 --- a/packages/flutter/lib/src/material/about.dart +++ b/packages/flutter/lib/src/material/about.dart @@ -684,7 +684,7 @@ class _LicensePageState extends State { ); } - Widget _packagesView(final BuildContext _, final bool isLateral) { + Widget _packagesView(BuildContext _, bool isLateral) { final Widget about = _AboutProgram( name: widget.applicationName ?? _defaultApplicationName(context), icon: widget.applicationIcon ?? _defaultApplicationIcon(context), @@ -835,10 +835,10 @@ class _PackagesViewState extends State<_PackagesView> { } Widget _packagesList( - final BuildContext context, - final int? selectedId, - final _LicenseData data, - final bool drawSelection, + BuildContext context, + int? selectedId, + _LicenseData data, + bool drawSelection, ) { final EdgeInsets safeAreaPadding = MediaQuery.paddingOf(context); final padding = EdgeInsets.only( @@ -971,7 +971,7 @@ class _DetailArguments { final List licenseEntries; @override - bool operator ==(final Object other) { + bool operator ==(Object other) { if (other is _DetailArguments) { return other.packageName == packageName; } diff --git a/packages/flutter/lib/src/material/date_picker.dart b/packages/flutter/lib/src/material/date_picker.dart index dcb8ae5405aac..819184f3b598e 100644 --- a/packages/flutter/lib/src/material/date_picker.dart +++ b/packages/flutter/lib/src/material/date_picker.dart @@ -219,10 +219,10 @@ Future showDatePicker({ String? fieldLabelText, TextInputType? keyboardType, Offset? anchorPoint, - final ValueChanged? onDatePickerModeChange, - final Icon? switchToInputEntryModeIcon, - final Icon? switchToCalendarEntryModeIcon, - final CalendarDelegate calendarDelegate = const GregorianCalendarDelegate(), + ValueChanged? onDatePickerModeChange, + Icon? switchToInputEntryModeIcon, + Icon? switchToCalendarEntryModeIcon, + CalendarDelegate calendarDelegate = const GregorianCalendarDelegate(), }) async { initialDate = initialDate == null ? null : calendarDelegate.dateOnly(initialDate); firstDate = calendarDelegate.dateOnly(firstDate); @@ -1190,8 +1190,8 @@ Future showDateRangePicker({ TransitionBuilder? builder, Offset? anchorPoint, TextInputType keyboardType = TextInputType.datetime, - final Icon? switchToInputEntryModeIcon, - final Icon? switchToCalendarEntryModeIcon, + Icon? switchToInputEntryModeIcon, + Icon? switchToCalendarEntryModeIcon, SelectableDayForRangePredicate? selectableDayPredicate, CalendarDelegate calendarDelegate = const GregorianCalendarDelegate(), }) async { diff --git a/packages/flutter/lib/src/material/scaffold.dart b/packages/flutter/lib/src/material/scaffold.dart index 6f4fc67ff845a..efe8ff9d1a32c 100644 --- a/packages/flutter/lib/src/material/scaffold.dart +++ b/packages/flutter/lib/src/material/scaffold.dart @@ -2715,7 +2715,7 @@ class ScaffoldState extends State } // Moves the Floating Action Button to the new Floating Action Button Location. - void _moveFloatingActionButton(final FloatingActionButtonLocation newLocation) { + void _moveFloatingActionButton(FloatingActionButtonLocation newLocation) { FloatingActionButtonLocation? previousLocation = _floatingActionButtonLocation; var restartAnimationFrom = 0.0; // If the Floating Action Button is moving right now, we need to start from a snapshot of the current transition. diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 8b9ff73ffd3c2..e50178755864e 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -2140,15 +2140,15 @@ class EditableText extends StatefulWidget { /// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the button /// Widgets for the current platform given [ContextMenuButtonItem]s. static List getEditableButtonItems({ - required final ClipboardStatus? clipboardStatus, - required final VoidCallback? onCopy, - required final VoidCallback? onCut, - required final VoidCallback? onPaste, - required final VoidCallback? onSelectAll, - required final VoidCallback? onLookUp, - required final VoidCallback? onSearchWeb, - required final VoidCallback? onShare, - required final VoidCallback? onLiveTextInput, + required ClipboardStatus? clipboardStatus, + required VoidCallback? onCopy, + required VoidCallback? onCut, + required VoidCallback? onPaste, + required VoidCallback? onSelectAll, + required VoidCallback? onLookUp, + required VoidCallback? onSearchWeb, + required VoidCallback? onShare, + required VoidCallback? onLiveTextInput, }) { final resultButtonItem = []; @@ -4574,7 +4574,7 @@ class EditableTextState extends State _lastBottomViewInset = view.viewInsets.bottom; } - Future _performSpellCheck(final String text) async { + Future _performSpellCheck(String text) async { try { final Locale? localeForSpellChecking = widget.locale ?? Localizations.maybeLocaleOf(context); diff --git a/packages/flutter/lib/src/widgets/selectable_region.dart b/packages/flutter/lib/src/widgets/selectable_region.dart index dd36c9075a6c9..eb3d556616d79 100644 --- a/packages/flutter/lib/src/widgets/selectable_region.dart +++ b/packages/flutter/lib/src/widgets/selectable_region.dart @@ -296,10 +296,10 @@ class SelectableRegion extends StatefulWidget { /// * [AdaptiveTextSelectionToolbar.getAdaptiveButtons], which builds the button /// Widgets for the current platform given [ContextMenuButtonItem]s. static List getSelectableButtonItems({ - required final SelectionGeometry selectionGeometry, - required final VoidCallback onCopy, - required final VoidCallback onSelectAll, - required final VoidCallback? onShare, + required SelectionGeometry selectionGeometry, + required VoidCallback onCopy, + required VoidCallback onSelectAll, + required VoidCallback? onShare, }) { final canCopy = selectionGeometry.status == SelectionStatus.uncollapsed; final bool canSelectAll = selectionGeometry.hasContent; diff --git a/packages/flutter/test/foundation/diagnostics_test.dart b/packages/flutter/test/foundation/diagnostics_test.dart index b5e6ffca9aa60..57e35421c0cc8 100644 --- a/packages/flutter/test/foundation/diagnostics_test.dart +++ b/packages/flutter/test/foundation/diagnostics_test.dart @@ -149,7 +149,7 @@ void validateIterablePropertyJsonSerialization(IterableProperty property } void validatePropertyJsonSerializationHelper( - final Map json, + Map json, DiagnosticsProperty property, ) { if (property.defaultValue != kNoDefaultValue) { diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 472a38cbf5c9c..b4038e96286a5 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -5221,7 +5221,7 @@ void main() { testWidgets('Multiline hint text will wrap up to maxLines', (WidgetTester tester) async { final Key textFieldKey = UniqueKey(); - Widget builder(int? maxLines, final String hintMsg) { + Widget builder(int? maxLines, String hintMsg) { return boilerplate( child: TextField( key: textFieldKey, diff --git a/packages/flutter/test/material/theme_data_test.dart b/packages/flutter/test/material/theme_data_test.dart index baae3ae80c5e7..efd98dcfd67eb 100644 --- a/packages/flutter/test/material/theme_data_test.dart +++ b/packages/flutter/test/material/theme_data_test.dart @@ -1849,7 +1849,7 @@ void main() { final properties = DiagnosticPropertiesBuilder(); ThemeData().debugFillProperties(properties); final List propertyNameList = properties.properties - .map((final DiagnosticsNode node) => node.name) + .map((DiagnosticsNode node) => node.name) .whereType() .toList(); final Set propertyNames = propertyNameList.toSet(); diff --git a/packages/flutter/test/rendering/semantics_and_children_test.dart b/packages/flutter/test/rendering/semantics_and_children_test.dart index 7df87ed1052e7..0d5e563793fc8 100644 --- a/packages/flutter/test/rendering/semantics_and_children_test.dart +++ b/packages/flutter/test/rendering/semantics_and_children_test.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter/material.dart'; +import 'package:flutter/animation.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; diff --git a/packages/flutter/test/widgets/backdrop_filter_test.dart b/packages/flutter/test/widgets/backdrop_filter_test.dart index 8a9b8e9e3e9cf..480ae06f2cdcc 100644 --- a/packages/flutter/test/widgets/backdrop_filter_test.dart +++ b/packages/flutter/test/widgets/backdrop_filter_test.dart @@ -110,4 +110,16 @@ void main() { expect(layers.length, 2); expect(layers[0].backdropKey, layers[1].backdropKey); }); + + testWidgets('BackdropFilter does not crash at zero area', (WidgetTester tester) async { + tester.view.physicalSize = Size.zero; + addTearDown(tester.view.reset); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center(child: BackdropFilter(filter: ImageFilter.blur(sigmaX: 40, sigmaY: 40))), + ), + ); + expect(tester.getSize(find.byType(BackdropFilter)), Size.zero); + }); } diff --git a/packages/flutter/test/widgets/clip_test.dart b/packages/flutter/test/widgets/clip_test.dart index dd6da1942c39b..9241b9190d4d1 100644 --- a/packages/flutter/test/widgets/clip_test.dart +++ b/packages/flutter/test/widgets/clip_test.dart @@ -913,4 +913,75 @@ void main() { await tester.pump(); expect(renderClip.textDirection, TextDirection.rtl); }); + + testWidgets('ClipRect does not crash at zero area', (WidgetTester tester) async { + tester.view.physicalSize = Size.zero; + final clip = ValueNotifier(const Rect.fromLTWH(50.0, 50.0, 100.0, 100.0)); + addTearDown(tester.view.reset); + addTearDown(clip.dispose); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: ClipRect(clipper: NotifyClipper(clip: clip)), + ), + ), + ); + expect(tester.getSize(find.byType(ClipRect)), Size.zero); + }); + + testWidgets('ClipRRect does not crash at zero area', (WidgetTester tester) async { + tester.view.physicalSize = Size.zero; + addTearDown(tester.view.reset); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: ClipRRect(borderRadius: BorderRadius.circular(8), child: const Placeholder()), + ), + ), + ); + expect(tester.getSize(find.byType(ClipRRect)), Size.zero); + }); + + testWidgets('ClipRSuperellipse does not crash at zero area', (WidgetTester tester) async { + tester.view.physicalSize = Size.zero; + addTearDown(tester.view.reset); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: ClipRSuperellipse( + borderRadius: BorderRadius.circular(8), + child: const Placeholder(), + ), + ), + ), + ); + expect(tester.getSize(find.byType(ClipRSuperellipse)), Size.zero); + }); + + testWidgets('ClipOval does not crash at zero area', (WidgetTester tester) async { + tester.view.physicalSize = Size.zero; + addTearDown(tester.view.reset); + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center(child: ClipOval(child: Placeholder())), + ), + ); + expect(tester.getSize(find.byType(ClipOval)), Size.zero); + }); + + testWidgets('ClipPath does not crash at zero area', (WidgetTester tester) async { + tester.view.physicalSize = Size.zero; + addTearDown(tester.view.reset); + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center(child: ClipPath(child: Placeholder())), + ), + ); + expect(tester.getSize(find.byType(ClipPath)), Size.zero); + }); } diff --git a/packages/flutter/test/widgets/custom_paint_test.dart b/packages/flutter/test/widgets/custom_paint_test.dart index 8ef7457dfb27c..aa8fa9b240d2b 100644 --- a/packages/flutter/test/widgets/custom_paint_test.dart +++ b/packages/flutter/test/widgets/custom_paint_test.dart @@ -210,4 +210,16 @@ void main() { expect(inner.getMinIntrinsicHeight(double.infinity), 0); expect(inner.getMaxIntrinsicHeight(double.infinity), 0); }); + + testWidgets('CustomPaint does not crash at zero area', (WidgetTester tester) async { + tester.view.physicalSize = Size.zero; + addTearDown(tester.view.reset); + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center(child: CustomPaint()), + ), + ); + expect(tester.getSize(find.byType(CustomPaint)), Size.zero); + }); } diff --git a/packages/flutter/test/widgets/fade_transition_test.dart b/packages/flutter/test/widgets/fade_transition_test.dart index 070d22be093b0..4350345483d7b 100644 --- a/packages/flutter/test/widgets/fade_transition_test.dart +++ b/packages/flutter/test/widgets/fade_transition_test.dart @@ -54,4 +54,24 @@ void main() { await tester.pump(); expect(tester.takeException(), isNull); }); + + testWidgets('FadeTransition does not crash at zero area', (WidgetTester tester) async { + tester.view.physicalSize = Size.zero; + final controller = AnimationController( + vsync: const TestVSync(), + value: 1, + duration: const Duration(seconds: 2), + ); + addTearDown(tester.view.reset); + addTearDown(controller.dispose); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: FadeTransition(opacity: controller, child: const Placeholder()), + ), + ), + ); + expect(tester.getSize(find.byType(FadeTransition)), Size.zero); + }); } diff --git a/packages/flutter/test/widgets/physical_model_test.dart b/packages/flutter/test/widgets/physical_model_test.dart index d5aee47b70fcb..4b061f395329a 100644 --- a/packages/flutter/test/widgets/physical_model_test.dart +++ b/packages/flutter/test/widgets/physical_model_test.dart @@ -117,4 +117,33 @@ void main() { expect(exception.diagnostics.first.toString(), startsWith('A RenderFlex overflowed by ')); await expectLater(find.byKey(key), matchesGoldenFile('physical_model_overflow.png')); }); + + testWidgets('PhysicalModel does not crash at zero area', (WidgetTester tester) async { + tester.view.physicalSize = Size.zero; + addTearDown(tester.view.reset); + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center(child: PhysicalModel(color: Color(0xAABBCC00))), + ), + ); + expect(tester.getSize(find.byType(PhysicalModel)), Size.zero); + }); + + testWidgets('PhysicalShape does not crash at zero area', (WidgetTester tester) async { + tester.view.physicalSize = Size.zero; + addTearDown(tester.view.reset); + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: PhysicalShape( + color: Color(0xAABBCC00), + clipper: ShapeBorderClipper(shape: CircleBorder()), + ), + ), + ), + ); + expect(tester.getSize(find.byType(PhysicalShape)), Size.zero); + }); } diff --git a/packages/flutter/test/widgets/restoration_scopes_moving_test.dart b/packages/flutter/test/widgets/restoration_scopes_moving_test.dart index 2ec87df95bdd9..517ff3d7a1cc9 100644 --- a/packages/flutter/test/widgets/restoration_scopes_moving_test.dart +++ b/packages/flutter/test/widgets/restoration_scopes_moving_test.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -207,8 +207,8 @@ class CounterState extends State with RestorationMixin { @override Widget build(BuildContext context) { - return OutlinedButton( - onPressed: () { + return GestureDetector( + onTap: () { setState(() { count.value++; }); diff --git a/packages/flutter/test/widgets/sliver_cross_axis_group_test.dart b/packages/flutter/test/widgets/sliver_cross_axis_group_test.dart index 59118ee2e1fac..0a60afb408542 100644 --- a/packages/flutter/test/widgets/sliver_cross_axis_group_test.dart +++ b/packages/flutter/test/widgets/sliver_cross_axis_group_test.dart @@ -8,7 +8,7 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart'; -import '../rendering/sliver_utils.dart'; +import 'sliver_utils.dart'; import 'widgets_app_tester.dart'; const double VIEWPORT_HEIGHT = 600; diff --git a/packages/flutter/test/widgets/sliver_main_axis_group_test.dart b/packages/flutter/test/widgets/sliver_main_axis_group_test.dart index 0cbaf88f79d23..44e45765eff6f 100644 --- a/packages/flutter/test/widgets/sliver_main_axis_group_test.dart +++ b/packages/flutter/test/widgets/sliver_main_axis_group_test.dart @@ -6,9 +6,9 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; -import '../rendering/sliver_utils.dart'; import 'list_tile_tester.dart'; import 'semantics_tester.dart'; +import 'sliver_utils.dart'; const double VIEWPORT_HEIGHT = 600; const double VIEWPORT_WIDTH = 300; diff --git a/packages/flutter/test/rendering/sliver_utils.dart b/packages/flutter/test/widgets/sliver_utils.dart similarity index 96% rename from packages/flutter/test/rendering/sliver_utils.dart rename to packages/flutter/test/widgets/sliver_utils.dart index 1fbae8239ae14..a20c933eb04c5 100644 --- a/packages/flutter/test/rendering/sliver_utils.dart +++ b/packages/flutter/test/widgets/sliver_utils.dart @@ -4,8 +4,8 @@ // Test sliver which always attempts to paint itself whether it is visible or not. // Use for checking if slivers which take sliver children paints optimally. -import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; class RenderMockSliverToBoxAdapter extends RenderSliverToBoxAdapter { RenderMockSliverToBoxAdapter({super.child, required this.incrementCounter}); diff --git a/packages/flutter/test/widgets/text_test.dart b/packages/flutter/test/widgets/text_test.dart index 5583100616b03..be8b12e83670e 100644 --- a/packages/flutter/test/widgets/text_test.dart +++ b/packages/flutter/test/widgets/text_test.dart @@ -1823,6 +1823,16 @@ void main() { semantics.dispose(); }); + + testWidgets('Text does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Center(child: SizedBox.shrink(child: Text('X'))), + ), + ); + expect(tester.getSize(find.byType(Text)), Size.zero); + }); } Future _pumpTextWidget({ diff --git a/packages/flutter/test/widgets/title_test.dart b/packages/flutter/test/widgets/title_test.dart index b9bbf9d55890c..dd3718324dac9 100644 --- a/packages/flutter/test/widgets/title_test.dart +++ b/packages/flutter/test/widgets/title_test.dart @@ -138,4 +138,18 @@ void main() { ); }, ); + + testWidgets('Title does not crash at zero area', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox.shrink( + child: Title(color: const Color(0xFFFFFFFF), child: const Placeholder()), + ), + ), + ), + ); + expect(tester.getSize(find.byType(Title)), Size.zero); + }); } diff --git a/packages/flutter/test/widgets/transform_test.dart b/packages/flutter/test/widgets/transform_test.dart index f3ad33e343a5a..5b1dcb813d830 100644 --- a/packages/flutter/test/widgets/transform_test.dart +++ b/packages/flutter/test/widgets/transform_test.dart @@ -954,6 +954,18 @@ void main() { expect(tappedRed, isTrue, reason: 'Transform.flip cannot flipX and flipY together'); }); + + testWidgets('Transform does not crash at zero area', (WidgetTester tester) async { + tester.view.physicalSize = Size.zero; + addTearDown(tester.view.reset); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center(child: Transform.flip(flipY: true, child: const Placeholder())), + ), + ); + expect(tester.getSize(find.byType(Transform)), Size.zero); + }); } class TestRectPainter extends CustomPainter { diff --git a/packages/flutter/test/widgets/transitions_test.dart b/packages/flutter/test/widgets/transitions_test.dart index 0c098c6215121..e98a3aa554daa 100644 --- a/packages/flutter/test/widgets/transitions_test.dart +++ b/packages/flutter/test/widgets/transitions_test.dart @@ -130,31 +130,109 @@ void main() { // Scaling a shadow doesn't change the color. expect(actualDecoration.boxShadow![0].color, const Color(0x66000000)); }); + + testWidgets('does not crash at zero area', (WidgetTester tester) async { + tester.view.physicalSize = Size.zero; + final controller = AnimationController( + vsync: const TestVSync(), + value: 1, + duration: const Duration(seconds: 2), + ); + addTearDown(tester.view.reset); + addTearDown(controller.dispose); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: DecoratedBoxTransition( + decoration: DecorationTween( + begin: const BoxDecoration(color: Color(0xFF000000)), + end: const BoxDecoration(color: Color(0xFFFFFFFF)), + ).animate(controller), + child: const Placeholder(), + ), + ), + ), + ); + expect(tester.getSize(find.byType(DecoratedBoxTransition)), Size.zero); + }); }); - testWidgets('AlignTransition animates', (WidgetTester tester) async { - final controller = AnimationController(vsync: const TestVSync()); - addTearDown(controller.dispose); - final Animation alignmentTween = AlignmentTween( - begin: Alignment.centerLeft, - end: Alignment.bottomRight, - ).animate(controller); - final Widget widget = AlignTransition( - alignment: alignmentTween, - child: const Text('Ready', textDirection: TextDirection.ltr), - ); + group('AlignTransition', () { + testWidgets('animates', (WidgetTester tester) async { + final controller = AnimationController(vsync: const TestVSync()); + addTearDown(controller.dispose); + final Animation alignmentTween = AlignmentTween( + begin: Alignment.centerLeft, + end: Alignment.bottomRight, + ).animate(controller); + final Widget widget = AlignTransition( + alignment: alignmentTween, + child: const Text('Ready', textDirection: TextDirection.ltr), + ); - await tester.pumpWidget(widget); + await tester.pumpWidget(widget); - final RenderPositionedBox actualPositionedBox = tester.renderObject(find.byType(Align)); + final RenderPositionedBox actualPositionedBox = tester.renderObject(find.byType(Align)); - var actualAlignment = actualPositionedBox.alignment as Alignment; - expect(actualAlignment, Alignment.centerLeft); + var actualAlignment = actualPositionedBox.alignment as Alignment; + expect(actualAlignment, Alignment.centerLeft); - controller.value = 0.5; - await tester.pump(); - actualAlignment = actualPositionedBox.alignment as Alignment; - expect(actualAlignment, const Alignment(0.0, 0.5)); + controller.value = 0.5; + await tester.pump(); + actualAlignment = actualPositionedBox.alignment as Alignment; + expect(actualAlignment, const Alignment(0.0, 0.5)); + }); + + testWidgets('keeps width and height factors', (WidgetTester tester) async { + final controller = AnimationController(vsync: const TestVSync()); + addTearDown(controller.dispose); + final Animation alignmentTween = AlignmentTween( + begin: Alignment.centerLeft, + end: Alignment.bottomRight, + ).animate(controller); + final Widget widget = AlignTransition( + alignment: alignmentTween, + widthFactor: 0.3, + heightFactor: 0.4, + child: const Text('Ready', textDirection: TextDirection.ltr), + ); + + await tester.pumpWidget(widget); + + final Align actualAlign = tester.widget(find.byType(Align)); + + expect(actualAlign.widthFactor, 0.3); + expect(actualAlign.heightFactor, 0.4); + }); + + testWidgets('does not crash at zero area', (WidgetTester tester) async { + tester.view.physicalSize = Size.zero; + final controller = AnimationController( + vsync: const TestVSync(), + value: 1, + duration: const Duration(seconds: 2), + ); + final curvedAnimation = CurvedAnimation(parent: controller, curve: Curves.fastOutSlowIn); + addTearDown(tester.view.reset); + addTearDown(controller.dispose); + addTearDown(curvedAnimation.dispose); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: AlignTransition( + alignment: Tween( + begin: Alignment.bottomCenter, + end: Alignment.bottomRight, + ).animate(curvedAnimation), + child: const Placeholder(), + ), + ), + ), + ); + expect(tester.getSize(find.byType(AlignTransition)), Size.zero); + }); }); testWidgets('RelativePositionedTransition animates', (WidgetTester tester) async { @@ -204,125 +282,18 @@ void main() { expect(renderBox.size, equals(const Size(665, 420))); }); - testWidgets('AlignTransition keeps width and height factors', (WidgetTester tester) async { - final controller = AnimationController(vsync: const TestVSync()); - addTearDown(controller.dispose); - final Animation alignmentTween = AlignmentTween( - begin: Alignment.centerLeft, - end: Alignment.bottomRight, - ).animate(controller); - final Widget widget = AlignTransition( - alignment: alignmentTween, - widthFactor: 0.3, - heightFactor: 0.4, - child: const Text('Ready', textDirection: TextDirection.ltr), - ); - - await tester.pumpWidget(widget); - - final Align actualAlign = tester.widget(find.byType(Align)); - - expect(actualAlign.widthFactor, 0.3); - expect(actualAlign.heightFactor, 0.4); - }); - - testWidgets('SizeTransition clamps negative size factors - vertical axis', ( - WidgetTester tester, - ) async { - final controller = AnimationController(vsync: const TestVSync()); - addTearDown(controller.dispose); - final Animation animation = Tween(begin: -1.0, end: 1.0).animate(controller); - - final Widget widget = Directionality( - textDirection: TextDirection.ltr, - child: SizeTransition( - sizeFactor: animation, - fixedCrossAxisSizeFactor: 2.0, - child: const Text('Ready'), - ), - ); - - await tester.pumpWidget(widget); - - final RenderPositionedBox actualPositionedBox = tester.renderObject(find.byType(Align)); - expect(actualPositionedBox.heightFactor, 0.0); - expect(actualPositionedBox.widthFactor, 2.0); - - controller.value = 0.0; - await tester.pump(); - expect(actualPositionedBox.heightFactor, 0.0); - expect(actualPositionedBox.widthFactor, 2.0); - - controller.value = 0.75; - await tester.pump(); - expect(actualPositionedBox.heightFactor, 0.5); - expect(actualPositionedBox.widthFactor, 2.0); - - controller.value = 1.0; - await tester.pump(); - expect(actualPositionedBox.heightFactor, 1.0); - expect(actualPositionedBox.widthFactor, 2.0); - }); - - testWidgets('SizeTransition clamps negative size factors - horizontal axis', ( - WidgetTester tester, - ) async { - final controller = AnimationController(vsync: const TestVSync()); - addTearDown(controller.dispose); - final Animation animation = Tween(begin: -1.0, end: 1.0).animate(controller); - - final Widget widget = Directionality( - textDirection: TextDirection.ltr, - child: SizeTransition( - axis: Axis.horizontal, - sizeFactor: animation, - fixedCrossAxisSizeFactor: 1.0, - child: const Text('Ready'), - ), - ); - - await tester.pumpWidget(widget); - - final RenderPositionedBox actualPositionedBox = tester.renderObject(find.byType(Align)); - expect(actualPositionedBox.widthFactor, 0.0); - expect(actualPositionedBox.heightFactor, 1.0); - - controller.value = 0.0; - await tester.pump(); - expect(actualPositionedBox.widthFactor, 0.0); - expect(actualPositionedBox.heightFactor, 1.0); - - controller.value = 0.75; - await tester.pump(); - expect(actualPositionedBox.widthFactor, 0.5); - expect(actualPositionedBox.heightFactor, 1.0); - - controller.value = 1.0; - await tester.pump(); - expect(actualPositionedBox.widthFactor, 1.0); - expect(actualPositionedBox.heightFactor, 1.0); - }); - - testWidgets( - 'SizeTransition with fixedCrossAxisSizeFactor should size its cross axis from its children - vertical axis', - (WidgetTester tester) async { + group('SizeTransition', () { + testWidgets('clamps negative size factors - vertical axis', (WidgetTester tester) async { final controller = AnimationController(vsync: const TestVSync()); addTearDown(controller.dispose); - final Animation animation = Tween(begin: 0, end: 1.0).animate(controller); - - const key = Key('key'); + final Animation animation = Tween(begin: -1.0, end: 1.0).animate(controller); final Widget widget = Directionality( textDirection: TextDirection.ltr, - child: Center( - child: SizedBox( - key: key, - child: SizeTransition( - sizeFactor: animation, - fixedCrossAxisSizeFactor: 1.0, - child: const SizedBox.square(dimension: 100), - ), - ), + child: SizeTransition( + sizeFactor: animation, + fixedCrossAxisSizeFactor: 2.0, + child: const Text('Ready'), ), ); @@ -330,132 +301,240 @@ void main() { final RenderPositionedBox actualPositionedBox = tester.renderObject(find.byType(Align)); expect(actualPositionedBox.heightFactor, 0.0); - expect(actualPositionedBox.widthFactor, 1.0); - expect(tester.getSize(find.byKey(key)), const Size(100, 0)); + expect(actualPositionedBox.widthFactor, 2.0); controller.value = 0.0; await tester.pump(); expect(actualPositionedBox.heightFactor, 0.0); - expect(actualPositionedBox.widthFactor, 1.0); - expect(tester.getSize(find.byKey(key)), const Size(100, 0)); + expect(actualPositionedBox.widthFactor, 2.0); - controller.value = 0.5; + controller.value = 0.75; await tester.pump(); expect(actualPositionedBox.heightFactor, 0.5); - expect(actualPositionedBox.widthFactor, 1.0); - expect(tester.getSize(find.byKey(key)), const Size(100, 50)); + expect(actualPositionedBox.widthFactor, 2.0); controller.value = 1.0; await tester.pump(); expect(actualPositionedBox.heightFactor, 1.0); - expect(actualPositionedBox.widthFactor, 1.0); - expect(tester.getSize(find.byKey(key)), const Size.square(100)); - - controller.value = 0.5; - await tester.pump(); - expect(actualPositionedBox.heightFactor, 0.5); - expect(actualPositionedBox.widthFactor, 1.0); - expect(tester.getSize(find.byKey(key)), const Size(100, 50)); - - controller.value = 0.0; - await tester.pump(); - expect(actualPositionedBox.heightFactor, 0.0); - expect(actualPositionedBox.widthFactor, 1.0); - expect(tester.getSize(find.byKey(key)), const Size(100, 0)); - }, - ); + expect(actualPositionedBox.widthFactor, 2.0); + }); - testWidgets( - 'SizeTransition with fixedCrossAxisSizeFactor should size its cross axis from its children - horizontal axis', - (WidgetTester tester) async { + testWidgets('clamps negative size factors - horizontal axis', (WidgetTester tester) async { final controller = AnimationController(vsync: const TestVSync()); addTearDown(controller.dispose); - final Animation animation = Tween(begin: 0.0, end: 1.0).animate(controller); - - const key = Key('key'); + final Animation animation = Tween(begin: -1.0, end: 1.0).animate(controller); final Widget widget = Directionality( textDirection: TextDirection.ltr, - child: Center( - child: SizedBox( - key: key, - child: SizeTransition( - axis: Axis.horizontal, - sizeFactor: animation, - fixedCrossAxisSizeFactor: 1.0, - child: const SizedBox.square(dimension: 100), - ), - ), + child: SizeTransition( + axis: Axis.horizontal, + sizeFactor: animation, + fixedCrossAxisSizeFactor: 1.0, + child: const Text('Ready'), ), ); await tester.pumpWidget(widget); final RenderPositionedBox actualPositionedBox = tester.renderObject(find.byType(Align)); - expect(actualPositionedBox.heightFactor, 1.0); expect(actualPositionedBox.widthFactor, 0.0); - expect(tester.getSize(find.byKey(key)), const Size(0, 100)); + expect(actualPositionedBox.heightFactor, 1.0); controller.value = 0.0; await tester.pump(); - expect(actualPositionedBox.heightFactor, 1.0); expect(actualPositionedBox.widthFactor, 0.0); - expect(tester.getSize(find.byKey(key)), const Size(0, 100)); + expect(actualPositionedBox.heightFactor, 1.0); - controller.value = 0.5; + controller.value = 0.75; await tester.pump(); - expect(actualPositionedBox.heightFactor, 1.0); expect(actualPositionedBox.widthFactor, 0.5); - expect(tester.getSize(find.byKey(key)), const Size(50, 100)); + expect(actualPositionedBox.heightFactor, 1.0); controller.value = 1.0; await tester.pump(); - expect(actualPositionedBox.heightFactor, 1.0); expect(actualPositionedBox.widthFactor, 1.0); - expect(tester.getSize(find.byKey(key)), const Size.square(100)); - - controller.value = 0.5; - await tester.pump(); expect(actualPositionedBox.heightFactor, 1.0); - expect(actualPositionedBox.widthFactor, 0.5); - expect(tester.getSize(find.byKey(key)), const Size(50, 100)); + }); - controller.value = 0.0; - await tester.pump(); - expect(actualPositionedBox.heightFactor, 1.0); - expect(actualPositionedBox.widthFactor, 0.0); - expect(tester.getSize(find.byKey(key)), const Size(0, 100)); - }, - ); + testWidgets( + 'with fixedCrossAxisSizeFactor should size its cross axis from its children - vertical axis', + (WidgetTester tester) async { + final controller = AnimationController(vsync: const TestVSync()); + addTearDown(controller.dispose); + final Animation animation = Tween(begin: 0, end: 1.0).animate(controller); - testWidgets('SizeTransition maintains chosen alignment during animation', ( - WidgetTester tester, - ) async { - final controller = AnimationController(vsync: const TestVSync()); - addTearDown(controller.dispose); - final Animation animation = Tween(begin: 0.0, end: 1.0).animate(controller); + const key = Key('key'); - final Widget widget = SizeTransition( - sizeFactor: animation, - alignment: Alignment.topLeft, - child: const SizedBox.shrink(), + final Widget widget = Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + key: key, + child: SizeTransition( + sizeFactor: animation, + fixedCrossAxisSizeFactor: 1.0, + child: const SizedBox.square(dimension: 100), + ), + ), + ), + ); + + await tester.pumpWidget(widget); + + final RenderPositionedBox actualPositionedBox = tester.renderObject(find.byType(Align)); + expect(actualPositionedBox.heightFactor, 0.0); + expect(actualPositionedBox.widthFactor, 1.0); + expect(tester.getSize(find.byKey(key)), const Size(100, 0)); + + controller.value = 0.0; + await tester.pump(); + expect(actualPositionedBox.heightFactor, 0.0); + expect(actualPositionedBox.widthFactor, 1.0); + expect(tester.getSize(find.byKey(key)), const Size(100, 0)); + + controller.value = 0.5; + await tester.pump(); + expect(actualPositionedBox.heightFactor, 0.5); + expect(actualPositionedBox.widthFactor, 1.0); + expect(tester.getSize(find.byKey(key)), const Size(100, 50)); + + controller.value = 1.0; + await tester.pump(); + expect(actualPositionedBox.heightFactor, 1.0); + expect(actualPositionedBox.widthFactor, 1.0); + expect(tester.getSize(find.byKey(key)), const Size.square(100)); + + controller.value = 0.5; + await tester.pump(); + expect(actualPositionedBox.heightFactor, 0.5); + expect(actualPositionedBox.widthFactor, 1.0); + expect(tester.getSize(find.byKey(key)), const Size(100, 50)); + + controller.value = 0.0; + await tester.pump(); + expect(actualPositionedBox.heightFactor, 0.0); + expect(actualPositionedBox.widthFactor, 1.0); + expect(tester.getSize(find.byKey(key)), const Size(100, 0)); + }, ); - await tester.pumpWidget(widget); + testWidgets( + 'with fixedCrossAxisSizeFactor should size its cross axis from its children - horizontal axis', + (WidgetTester tester) async { + final controller = AnimationController(vsync: const TestVSync()); + addTearDown(controller.dispose); + final Animation animation = Tween(begin: 0.0, end: 1.0).animate(controller); - final RenderPositionedBox actualPositionedBox = tester.renderObject(find.byType(Align)); - var actualAlignment = actualPositionedBox.alignment as Alignment; - expect(actualAlignment, Alignment.topLeft); + const key = Key('key'); - controller.value = 0.0; - await tester.pump(); - actualAlignment = actualPositionedBox.alignment as Alignment; - expect(actualAlignment, Alignment.topLeft); + final Widget widget = Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizedBox( + key: key, + child: SizeTransition( + axis: Axis.horizontal, + sizeFactor: animation, + fixedCrossAxisSizeFactor: 1.0, + child: const SizedBox.square(dimension: 100), + ), + ), + ), + ); + + await tester.pumpWidget(widget); + + final RenderPositionedBox actualPositionedBox = tester.renderObject(find.byType(Align)); + expect(actualPositionedBox.heightFactor, 1.0); + expect(actualPositionedBox.widthFactor, 0.0); + expect(tester.getSize(find.byKey(key)), const Size(0, 100)); + + controller.value = 0.0; + await tester.pump(); + expect(actualPositionedBox.heightFactor, 1.0); + expect(actualPositionedBox.widthFactor, 0.0); + expect(tester.getSize(find.byKey(key)), const Size(0, 100)); + + controller.value = 0.5; + await tester.pump(); + expect(actualPositionedBox.heightFactor, 1.0); + expect(actualPositionedBox.widthFactor, 0.5); + expect(tester.getSize(find.byKey(key)), const Size(50, 100)); + + controller.value = 1.0; + await tester.pump(); + expect(actualPositionedBox.heightFactor, 1.0); + expect(actualPositionedBox.widthFactor, 1.0); + expect(tester.getSize(find.byKey(key)), const Size.square(100)); + + controller.value = 0.5; + await tester.pump(); + expect(actualPositionedBox.heightFactor, 1.0); + expect(actualPositionedBox.widthFactor, 0.5); + expect(tester.getSize(find.byKey(key)), const Size(50, 100)); + + controller.value = 0.0; + await tester.pump(); + expect(actualPositionedBox.heightFactor, 1.0); + expect(actualPositionedBox.widthFactor, 0.0); + expect(tester.getSize(find.byKey(key)), const Size(0, 100)); + }, + ); - controller.value = 1.0; - await tester.pump(); - actualAlignment = actualPositionedBox.alignment as Alignment; - expect(actualAlignment, Alignment.topLeft); + testWidgets('maintains chosen alignment during animation', (WidgetTester tester) async { + final controller = AnimationController(vsync: const TestVSync()); + addTearDown(controller.dispose); + final Animation animation = Tween(begin: 0.0, end: 1.0).animate(controller); + + final Widget widget = SizeTransition( + sizeFactor: animation, + alignment: Alignment.topLeft, + child: const SizedBox.shrink(), + ); + + await tester.pumpWidget(widget); + + final RenderPositionedBox actualPositionedBox = tester.renderObject(find.byType(Align)); + var actualAlignment = actualPositionedBox.alignment as Alignment; + expect(actualAlignment, Alignment.topLeft); + + controller.value = 0.0; + await tester.pump(); + actualAlignment = actualPositionedBox.alignment as Alignment; + expect(actualAlignment, Alignment.topLeft); + + controller.value = 1.0; + await tester.pump(); + actualAlignment = actualPositionedBox.alignment as Alignment; + expect(actualAlignment, Alignment.topLeft); + }); + + testWidgets('does not crash at zero area', (WidgetTester tester) async { + tester.view.physicalSize = Size.zero; + final controller = AnimationController( + vsync: const TestVSync(), + value: 1, + duration: const Duration(seconds: 2), + ); + final curvedAnimation = CurvedAnimation(parent: controller, curve: Curves.fastOutSlowIn); + addTearDown(tester.view.reset); + addTearDown(controller.dispose); + addTearDown(curvedAnimation.dispose); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: SizeTransition( + sizeFactor: curvedAnimation, + axis: Axis.horizontal, + alignment: Alignment.topLeft, + child: const Placeholder(), + ), + ), + ), + ); + expect(tester.getSize(find.byType(SizeTransition)), Size.zero); + }); }); testWidgets('MatrixTransition animates', (WidgetTester tester) async { @@ -550,98 +629,120 @@ void main() { expect(actualAlignment, Alignment.topRight); }); - testWidgets('RotationTransition animates', (WidgetTester tester) async { - final controller = AnimationController(vsync: const TestVSync()); - addTearDown(controller.dispose); - final Widget widget = RotationTransition( - alignment: Alignment.topRight, - turns: controller, - child: const Text('Rotation', textDirection: TextDirection.ltr), - ); + group('RotationTransition', () { + testWidgets('animates', (WidgetTester tester) async { + final controller = AnimationController(vsync: const TestVSync()); + addTearDown(controller.dispose); + final Widget widget = RotationTransition( + alignment: Alignment.topRight, + turns: controller, + child: const Text('Rotation', textDirection: TextDirection.ltr), + ); - await tester.pumpWidget(widget); - Transform actualRotatedBox = tester.widget(find.byType(Transform)); - Matrix4 actualTurns = actualRotatedBox.transform; - expect(actualTurns, equals(Matrix4.rotationZ(0.0))); + await tester.pumpWidget(widget); + Transform actualRotatedBox = tester.widget(find.byType(Transform)); + Matrix4 actualTurns = actualRotatedBox.transform; + expect(actualTurns, equals(Matrix4.rotationZ(0.0))); - controller.value = 0.5; - await tester.pump(); - actualRotatedBox = tester.widget(find.byType(Transform)); - actualTurns = actualRotatedBox.transform; - expect( - actualTurns, - matrixMoreOrLessEquals( - Matrix4.fromList([ - -1.0, - 0.0, - 0.0, - 0.0, - 0.0, - -1.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - ])..transpose(), - ), - ); + controller.value = 0.5; + await tester.pump(); + actualRotatedBox = tester.widget(find.byType(Transform)); + actualTurns = actualRotatedBox.transform; + expect( + actualTurns, + matrixMoreOrLessEquals( + Matrix4.fromList([ + -1.0, + 0.0, + 0.0, + 0.0, + 0.0, + -1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + ])..transpose(), + ), + ); - controller.value = 0.75; - await tester.pump(); - actualRotatedBox = tester.widget(find.byType(Transform)); - actualTurns = actualRotatedBox.transform; - expect( - actualTurns, - matrixMoreOrLessEquals( - Matrix4.fromList([ - 0.0, - 1.0, - 0.0, - 0.0, - -1.0, - 0.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - 0.0, - 0.0, - 0.0, - 0.0, - 1.0, - ])..transpose(), - ), - ); - }); + controller.value = 0.75; + await tester.pump(); + actualRotatedBox = tester.widget(find.byType(Transform)); + actualTurns = actualRotatedBox.transform; + expect( + actualTurns, + matrixMoreOrLessEquals( + Matrix4.fromList([ + 0.0, + 1.0, + 0.0, + 0.0, + -1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 1.0, + ])..transpose(), + ), + ); + }); - testWidgets('RotationTransition maintains chosen alignment during animation', ( - WidgetTester tester, - ) async { - final controller = AnimationController(vsync: const TestVSync()); - addTearDown(controller.dispose); - final Widget widget = RotationTransition( - alignment: Alignment.topRight, - turns: controller, - child: const Text('Rotation', textDirection: TextDirection.ltr), - ); + testWidgets('maintains chosen alignment during animation', (WidgetTester tester) async { + final controller = AnimationController(vsync: const TestVSync()); + addTearDown(controller.dispose); + final Widget widget = RotationTransition( + alignment: Alignment.topRight, + turns: controller, + child: const Text('Rotation', textDirection: TextDirection.ltr), + ); - await tester.pumpWidget(widget); - RotationTransition actualRotatedBox = tester.widget(find.byType(RotationTransition)); - Alignment actualAlignment = actualRotatedBox.alignment; - expect(actualAlignment, Alignment.topRight); + await tester.pumpWidget(widget); + RotationTransition actualRotatedBox = tester.widget(find.byType(RotationTransition)); + Alignment actualAlignment = actualRotatedBox.alignment; + expect(actualAlignment, Alignment.topRight); - controller.value = 0.5; - await tester.pump(); - actualRotatedBox = tester.widget(find.byType(RotationTransition)); - actualAlignment = actualRotatedBox.alignment; - expect(actualAlignment, Alignment.topRight); + controller.value = 0.5; + await tester.pump(); + actualRotatedBox = tester.widget(find.byType(RotationTransition)); + actualAlignment = actualRotatedBox.alignment; + expect(actualAlignment, Alignment.topRight); + }); + + testWidgets('does not crash at zero area', (WidgetTester tester) async { + tester.view.physicalSize = Size.zero; + final controller = AnimationController( + vsync: const TestVSync(), + value: 1, + duration: const Duration(seconds: 2), + ); + final curvedAnimation = CurvedAnimation(parent: controller, curve: Curves.elasticOut); + addTearDown(tester.view.reset); + addTearDown(controller.dispose); + addTearDown(curvedAnimation.dispose); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: RotationTransition(turns: curvedAnimation, child: const Placeholder()), + ), + ), + ); + expect(tester.getSize(find.byType(RotationTransition)), Size.zero); + }); }); group('FadeTransition', () { @@ -951,137 +1052,212 @@ void main() { }); group('Builders', () { - testWidgets('AnimatedBuilder rebuilds when changed', (WidgetTester tester) async { - final redrawKey = GlobalKey(); - final notifier = ChangeNotifier(); - addTearDown(notifier.dispose); - - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: AnimatedBuilder( - animation: notifier, - builder: (BuildContext context, Widget? child) { - return RedrawCounter(key: redrawKey, child: child); - }, + group('AnimatedBuilder', () { + testWidgets('rebuilds when changed', (WidgetTester tester) async { + final redrawKey = GlobalKey(); + final notifier = ChangeNotifier(); + addTearDown(notifier.dispose); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: AnimatedBuilder( + animation: notifier, + builder: (BuildContext context, Widget? child) { + return RedrawCounter(key: redrawKey, child: child); + }, + ), ), - ), - ); - - expect(redrawKey.currentState!.redraws, equals(1)); - await tester.pump(); - expect(redrawKey.currentState!.redraws, equals(1)); - notifier.notifyListeners(); - await tester.pump(); - expect(redrawKey.currentState!.redraws, equals(2)); - - // Pump a few more times to make sure that we don't rebuild unnecessarily. - await tester.pump(); - await tester.pump(); - expect(redrawKey.currentState!.redraws, equals(2)); - }); - - testWidgets("AnimatedBuilder doesn't rebuild the child", (WidgetTester tester) async { - final redrawKey = GlobalKey(); - final redrawKeyChild = GlobalKey(); - final notifier = ChangeNotifier(); - addTearDown(notifier.dispose); - - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: AnimatedBuilder( - animation: notifier, - builder: (BuildContext context, Widget? child) { - return RedrawCounter(key: redrawKey, child: child); - }, - child: RedrawCounter(key: redrawKeyChild), + ); + + expect(redrawKey.currentState!.redraws, equals(1)); + await tester.pump(); + expect(redrawKey.currentState!.redraws, equals(1)); + notifier.notifyListeners(); + await tester.pump(); + expect(redrawKey.currentState!.redraws, equals(2)); + + // Pump a few more times to make sure that we don't rebuild unnecessarily. + await tester.pump(); + await tester.pump(); + expect(redrawKey.currentState!.redraws, equals(2)); + }); + + testWidgets("doesn't rebuild the child", (WidgetTester tester) async { + final redrawKey = GlobalKey(); + final redrawKeyChild = GlobalKey(); + final notifier = ChangeNotifier(); + addTearDown(notifier.dispose); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: AnimatedBuilder( + animation: notifier, + builder: (BuildContext context, Widget? child) { + return RedrawCounter(key: redrawKey, child: child); + }, + child: RedrawCounter(key: redrawKeyChild), + ), ), - ), - ); - - expect(redrawKey.currentState!.redraws, equals(1)); - expect(redrawKeyChild.currentState!.redraws, equals(1)); - await tester.pump(); - expect(redrawKey.currentState!.redraws, equals(1)); - expect(redrawKeyChild.currentState!.redraws, equals(1)); - notifier.notifyListeners(); - await tester.pump(); - expect(redrawKey.currentState!.redraws, equals(2)); - expect(redrawKeyChild.currentState!.redraws, equals(1)); - - // Pump a few more times to make sure that we don't rebuild unnecessarily. - await tester.pump(); - await tester.pump(); - expect(redrawKey.currentState!.redraws, equals(2)); - expect(redrawKeyChild.currentState!.redraws, equals(1)); + ); + + expect(redrawKey.currentState!.redraws, equals(1)); + expect(redrawKeyChild.currentState!.redraws, equals(1)); + await tester.pump(); + expect(redrawKey.currentState!.redraws, equals(1)); + expect(redrawKeyChild.currentState!.redraws, equals(1)); + notifier.notifyListeners(); + await tester.pump(); + expect(redrawKey.currentState!.redraws, equals(2)); + expect(redrawKeyChild.currentState!.redraws, equals(1)); + + // Pump a few more times to make sure that we don't rebuild unnecessarily. + await tester.pump(); + await tester.pump(); + expect(redrawKey.currentState!.redraws, equals(2)); + expect(redrawKeyChild.currentState!.redraws, equals(1)); + }); + + testWidgets('does not crash at zero area', (WidgetTester tester) async { + tester.view.physicalSize = Size.zero; + final controller = AnimationController( + vsync: const TestVSync(), + value: 1, + duration: const Duration(seconds: 2), + ); + addTearDown(tester.view.reset); + addTearDown(controller.dispose); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: AnimatedBuilder( + animation: controller, + builder: (context, child) => const Text('X'), + child: const Placeholder(), + ), + ), + ), + ); + expect(tester.getSize(find.byType(AnimatedBuilder)), Size.zero); + }); }); - testWidgets('ListenableBuilder rebuilds when changed', (WidgetTester tester) async { - final redrawKey = GlobalKey(); - final notifier = ChangeNotifier(); - addTearDown(notifier.dispose); - - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: ListenableBuilder( - listenable: notifier, - builder: (BuildContext context, Widget? child) { - return RedrawCounter(key: redrawKey, child: child); - }, + group('ListenableBuilder', () { + testWidgets(' rebuilds when changed', (WidgetTester tester) async { + final redrawKey = GlobalKey(); + final notifier = ChangeNotifier(); + addTearDown(notifier.dispose); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: ListenableBuilder( + listenable: notifier, + builder: (BuildContext context, Widget? child) { + return RedrawCounter(key: redrawKey, child: child); + }, + ), ), - ), - ); - - expect(redrawKey.currentState!.redraws, equals(1)); - await tester.pump(); - expect(redrawKey.currentState!.redraws, equals(1)); - notifier.notifyListeners(); - await tester.pump(); - expect(redrawKey.currentState!.redraws, equals(2)); - - // Pump a few more times to make sure that we don't rebuild unnecessarily. - await tester.pump(); - await tester.pump(); - expect(redrawKey.currentState!.redraws, equals(2)); + ); + + expect(redrawKey.currentState!.redraws, equals(1)); + await tester.pump(); + expect(redrawKey.currentState!.redraws, equals(1)); + notifier.notifyListeners(); + await tester.pump(); + expect(redrawKey.currentState!.redraws, equals(2)); + + // Pump a few more times to make sure that we don't rebuild unnecessarily. + await tester.pump(); + await tester.pump(); + expect(redrawKey.currentState!.redraws, equals(2)); + }); + + testWidgets("doesn't rebuild the child", (WidgetTester tester) async { + final redrawKey = GlobalKey(); + final redrawKeyChild = GlobalKey(); + final notifier = ChangeNotifier(); + addTearDown(notifier.dispose); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: ListenableBuilder( + listenable: notifier, + builder: (BuildContext context, Widget? child) { + return RedrawCounter(key: redrawKey, child: child); + }, + child: RedrawCounter(key: redrawKeyChild), + ), + ), + ); + + expect(redrawKey.currentState!.redraws, equals(1)); + expect(redrawKeyChild.currentState!.redraws, equals(1)); + await tester.pump(); + expect(redrawKey.currentState!.redraws, equals(1)); + expect(redrawKeyChild.currentState!.redraws, equals(1)); + notifier.notifyListeners(); + await tester.pump(); + expect(redrawKey.currentState!.redraws, equals(2)); + expect(redrawKeyChild.currentState!.redraws, equals(1)); + + // Pump a few more times to make sure that we don't rebuild unnecessarily. + await tester.pump(); + await tester.pump(); + expect(redrawKey.currentState!.redraws, equals(2)); + expect(redrawKeyChild.currentState!.redraws, equals(1)); + }); + + testWidgets('does not crash at zero area', (WidgetTester tester) async { + tester.view.physicalSize = Size.zero; + final focusNode = FocusNode(); + addTearDown(tester.view.reset); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: ListenableBuilder( + listenable: focusNode, + builder: (_, _) => const Placeholder(), + ), + ), + ), + ); + expect(tester.getSize(find.byType(ListenableBuilder)), Size.zero); + }); }); + }); - testWidgets("ListenableBuilder doesn't rebuild the child", (WidgetTester tester) async { - final redrawKey = GlobalKey(); - final redrawKeyChild = GlobalKey(); - final notifier = ChangeNotifier(); - addTearDown(notifier.dispose); - - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: ListenableBuilder( - listenable: notifier, - builder: (BuildContext context, Widget? child) { - return RedrawCounter(key: redrawKey, child: child); - }, - child: RedrawCounter(key: redrawKeyChild), + testWidgets('DefaultTextStyleTransition does not crash at zero area', ( + WidgetTester tester, + ) async { + tester.view.physicalSize = Size.zero; + final controller = AnimationController( + vsync: const TestVSync(), + value: 1, + duration: const Duration(seconds: 2), + ); + addTearDown(tester.view.reset); + addTearDown(controller.dispose); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: DefaultTextStyleTransition( + style: TextStyleTween( + begin: const TextStyle(fontSize: 20), + end: const TextStyle(fontSize: 30), + ).animate(controller), + child: const Placeholder(), ), ), - ); - - expect(redrawKey.currentState!.redraws, equals(1)); - expect(redrawKeyChild.currentState!.redraws, equals(1)); - await tester.pump(); - expect(redrawKey.currentState!.redraws, equals(1)); - expect(redrawKeyChild.currentState!.redraws, equals(1)); - notifier.notifyListeners(); - await tester.pump(); - expect(redrawKey.currentState!.redraws, equals(2)); - expect(redrawKeyChild.currentState!.redraws, equals(1)); - - // Pump a few more times to make sure that we don't rebuild unnecessarily. - await tester.pump(); - await tester.pump(); - expect(redrawKey.currentState!.redraws, equals(2)); - expect(redrawKeyChild.currentState!.redraws, equals(1)); - }); + ), + ); + expect(tester.getSize(find.byType(DefaultTextStyleTransition)), Size.zero); }); } diff --git a/packages/flutter_goldens/lib/flutter_goldens.dart b/packages/flutter_goldens/lib/flutter_goldens.dart index f7d750f6e2f22..f0ea836714d7c 100644 --- a/packages/flutter_goldens/lib/flutter_goldens.dart +++ b/packages/flutter_goldens/lib/flutter_goldens.dart @@ -2,6 +2,9 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +// We use `print` for logging here. +// ignore_for_file: avoid_print + /// @docImport 'dart:io'; library; diff --git a/packages/flutter_test/pubspec.yaml b/packages/flutter_test/pubspec.yaml index 0fd2483dbf662..eeb3961519311 100644 --- a/packages/flutter_test/pubspec.yaml +++ b/packages/flutter_test/pubspec.yaml @@ -18,8 +18,8 @@ dependencies: # 'test' package, which change between versions, so when upgrading # this, make sure the tests are still running correctly. # See https://github.com/flutter/flutter/issues/185016 - test_api: 0.7.11 - matcher: 0.12.19 + test_api: 0.7.12 + matcher: 0.12.20 # Used by golden file comparator path: ^1.9.1 @@ -48,4 +48,4 @@ dev_dependencies: flutter_driver: sdk: flutter -# PUBSPEC CHECKSUM: c747iv +# PUBSPEC CHECKSUM: jrspqq diff --git a/packages/flutter_tools/lib/src/android/android_device.dart b/packages/flutter_tools/lib/src/android/android_device.dart index 7907ef29f7f6b..181c994155553 100644 --- a/packages/flutter_tools/lib/src/android/android_device.dart +++ b/packages/flutter_tools/lib/src/android/android_device.dart @@ -671,6 +671,11 @@ class AndroidDevice extends Device { 'enable-vulkan-validation', 'true', ], + if (debuggingOptions.enableHcpp) ...[ + '--ez', + 'enable-hcpp-and-surface-control', + 'true', + ], if (debuggingOptions.debuggingEnabled) ...[ if (debuggingOptions.buildInfo.isDebug) ...[ ...['--ez', 'enable-checked-mode', 'true'], @@ -687,11 +692,6 @@ class AndroidDevice extends Device { 'dart-flags', debuggingOptions.dartFlags, ], - if (debuggingOptions.enableHcpp) ...[ - '--ez', - 'enable-hcpp-and-surface-control', - 'true', - ], if (debuggingOptions.useTestFonts) ...['--ez', 'use-test-fonts', 'true'], if (debuggingOptions.verboseSystemLogs) ...['--ez', 'verbose-logging', 'true'], if (debuggingOptions.testFlag) ...['--ez', 'test-flag', 'true'], diff --git a/packages/flutter_tools/lib/src/android/migrations/disable_built_in_kotlin_migration.dart b/packages/flutter_tools/lib/src/android/migrations/disable_built_in_kotlin_migration.dart index 409b992bf1915..a337fbb588253 100644 --- a/packages/flutter_tools/lib/src/android/migrations/disable_built_in_kotlin_migration.dart +++ b/packages/flutter_tools/lib/src/android/migrations/disable_built_in_kotlin_migration.dart @@ -55,12 +55,7 @@ class DisableBuiltInKotlinMigration extends ProjectMigrator { return; } - // TODO(jesswon): Remove once try/catch is added to the write processFile: https://github.com/flutter/flutter/issues/184595 - try { - processFileLines(_gradlePropertiesFile); - } on FileSystemException catch (e) { - logger.printError('Failed to process/migrate the gradle.properties during migration: $e'); - } + processFileLines(_gradlePropertiesFile); } @override diff --git a/packages/flutter_tools/lib/src/android/migrations/disable_new_dsl_migration.dart b/packages/flutter_tools/lib/src/android/migrations/disable_new_dsl_migration.dart index 17dc00520e277..e63f2f29f491b 100644 --- a/packages/flutter_tools/lib/src/android/migrations/disable_new_dsl_migration.dart +++ b/packages/flutter_tools/lib/src/android/migrations/disable_new_dsl_migration.dart @@ -52,12 +52,7 @@ class DisableNewDslMigration extends ProjectMigrator { return; } - // TODO(jesswon): Remove once try/catch is added to the write processFile: https://github.com/flutter/flutter/issues/184595 - try { - processFileLines(_gradlePropertiesFile); - } on FileSystemException catch (e) { - logger.printError('Failed to process/migrate the gradle.properties during migration: $e'); - } + processFileLines(_gradlePropertiesFile); } @override diff --git a/packages/flutter_tools/lib/src/android/migrations/min_sdk_version_migration.dart b/packages/flutter_tools/lib/src/android/migrations/min_sdk_version_migration.dart index 1039eb1ed7859..faaaa1cfac729 100644 --- a/packages/flutter_tools/lib/src/android/migrations/min_sdk_version_migration.dart +++ b/packages/flutter_tools/lib/src/android/migrations/min_sdk_version_migration.dart @@ -4,7 +4,6 @@ import 'package:meta/meta.dart'; -import '../../base/file_system.dart'; import '../../base/project_migrator.dart'; import '../../project.dart'; import '../gradle_utils.dart'; @@ -36,12 +35,12 @@ class MinSdkVersionMigration extends ProjectMigrator { if (_project.isModule) { return; } - try { - processFileLines(_project.appGradleFile); - } on FileSystemException { + if (!_project.appGradleFile.existsSync()) { // Skip if we cannot find the app level build.gradle file. logger.printTrace(appGradleNotFoundWarning); + return; } + processFileLines(_project.appGradleFile); } @override diff --git a/packages/flutter_tools/lib/src/artifacts.dart b/packages/flutter_tools/lib/src/artifacts.dart index 6d3f133c3edf6..e965790aa724f 100644 --- a/packages/flutter_tools/lib/src/artifacts.dart +++ b/packages/flutter_tools/lib/src/artifacts.dart @@ -600,10 +600,9 @@ class CachedArtifacts implements Artifacts { case Artifact.genSnapshotX64: assert(mode != BuildMode.debug, 'Artifact $artifact only available in non-debug mode.'); - // TODO(cbracken): Build Android gen_snapshot as Arm64 binary to run - // natively on Apple Silicon. See: - // https://github.com/flutter/flutter/issues/152281 - HostPlatform hostPlatform = getCurrentHostPlatform(); + // macOS gen_snapshot ships as a universal binary under the darwin-x64 + // directory name, so remap darwin-arm64 to darwin-x64 for path resolution. + HostPlatform hostPlatform = _operatingSystemUtils.hostPlatform; if (hostPlatform == HostPlatform.darwin_arm64) { hostPlatform = HostPlatform.darwin_x64; } diff --git a/packages/flutter_tools/lib/src/base/project_migrator.dart b/packages/flutter_tools/lib/src/base/project_migrator.dart index 042578b359022..21550a04c005d 100644 --- a/packages/flutter_tools/lib/src/base/project_migrator.dart +++ b/packages/flutter_tools/lib/src/base/project_migrator.dart @@ -37,10 +37,16 @@ abstract class ProjectMigrator { /// Calls [migrateLine] per line, then [migrateFileContents] /// including the line migrations. void processFileLines(File file) { - final List lines = file.readAsLinesSync(); + final String basename = file.basename; + List lines; + try { + lines = file.readAsLinesSync(); + } on FileSystemException catch (e) { + logger.printError('Failed to read $basename during migration: $e'); + return; + } final newProjectContents = StringBuffer(); - final String basename = file.basename; for (final line in lines) { final String? newProjectLine = migrateLine(line); @@ -71,7 +77,11 @@ abstract class ProjectMigrator { if (migrationRequired) { logger.printStatus('Upgrading $basename'); - file.writeAsStringSync(projectContentsWithMigratedContents); + try { + file.writeAsStringSync(projectContentsWithMigratedContents); + } on FileSystemException catch (e) { + logger.printError('Failed to process/migrate $basename during migration: $e'); + } } } } diff --git a/packages/flutter_tools/lib/src/build_system/depfile.dart b/packages/flutter_tools/lib/src/build_system/depfile.dart index 3b2e24598bc00..8cdbe4eafb9bf 100644 --- a/packages/flutter_tools/lib/src/build_system/depfile.dart +++ b/packages/flutter_tools/lib/src/build_system/depfile.dart @@ -24,13 +24,31 @@ class DepfileService { /// This can be overridden with the [writeEmpty] parameter when /// both static and runtime dependencies exist and it is not desired /// to force a rerun due to no depfile. - void writeToFile(Depfile depfile, File output, {bool writeEmpty = false}) { + /// + /// If [filterOutputs] is true, files that are listed as both inputs and + /// outputs in the depfile are filtered out from the outputs list before + /// writing to avoid circular dependency cycles in external systems like Xcode. + void writeToFile( + Depfile depfile, + File output, { + bool writeEmpty = false, + bool filterOutputs = false, + }) { if (depfile.inputs.isEmpty && depfile.outputs.isEmpty && !writeEmpty) { ErrorHandlingFileSystem.deleteIfExists(output); return; } final buffer = StringBuffer(); - _writeFilesToBuffer(depfile.outputs, buffer); + final List outputsToWrite; + if (filterOutputs) { + final Set inputPaths = depfile.inputs.map((File f) => f.absolute.path).toSet(); + outputsToWrite = depfile.outputs + .where((File file) => !inputPaths.contains(file.absolute.path)) + .toList(); + } else { + outputsToWrite = depfile.outputs; + } + _writeFilesToBuffer(outputsToWrite, buffer); buffer.write(': '); _writeFilesToBuffer(depfile.inputs, buffer); output.writeAsStringSync(buffer.toString()); diff --git a/packages/flutter_tools/lib/src/build_system/targets/native_assets.dart b/packages/flutter_tools/lib/src/build_system/targets/native_assets.dart index c32098784297c..40aacae6c3973 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/native_assets.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/native_assets.dart @@ -84,7 +84,7 @@ class BuildHooks extends Target { if (!outputDepfile.parent.existsSync()) { outputDepfile.parent.createSync(recursive: true); } - environment.depFileService.writeToFile(depfile, outputDepfile); + environment.depFileService.writeToFile(depfile, outputDepfile, filterOutputs: true); } @override @@ -236,7 +236,7 @@ class LinkHooks extends Target { if (!outputDepfile.parent.existsSync()) { outputDepfile.parent.createSync(recursive: true); } - environment.depFileService.writeToFile(depfile, outputDepfile); + environment.depFileService.writeToFile(depfile, outputDepfile, filterOutputs: true); } @override diff --git a/packages/flutter_tools/lib/src/commands/custom_devices.dart b/packages/flutter_tools/lib/src/commands/custom_devices.dart index 3ee4bdfab90c3..30a76453b5bdf 100644 --- a/packages/flutter_tools/lib/src/commands/custom_devices.dart +++ b/packages/flutter_tools/lib/src/commands/custom_devices.dart @@ -372,7 +372,7 @@ class CustomDevicesAddCommand extends CustomDevicesCommandBase { /// Check this config by executing some of the commands, see if they run /// fine. - Future _checkConfigWithLogging(final CustomDeviceConfig config) async { + Future _checkConfigWithLogging(CustomDeviceConfig config) async { final device = CustomDevice(config: config, logger: logger, processManager: _processManager); var result = true; diff --git a/packages/flutter_tools/lib/src/commands/upgrade.dart b/packages/flutter_tools/lib/src/commands/upgrade.dart index 7a6aebc8491c3..02f7a521250ec 100644 --- a/packages/flutter_tools/lib/src/commands/upgrade.dart +++ b/packages/flutter_tools/lib/src/commands/upgrade.dart @@ -224,7 +224,7 @@ class UpgradeCommandRunner { } // If there are uncommitted changes we might be on the right commit but // we should still warn. - if (!force && await hasUncommittedChanges()) { + if (!force && await hasUncommittedChanges(flutterVersion)) { throwToolExit( 'Your flutter checkout has local changes that would be erased by ' 'upgrading. If you want to keep these changes, it is recommended that ' @@ -306,14 +306,40 @@ class UpgradeCommandRunner { } @protected - Future hasUncommittedChanges() async { + @visibleForTesting + Future hasUncommittedChanges(FlutterVersion version) async { try { final RunResult result = await globals.git.run( ['status', '-s'], throwOnError: true, workingDirectory: workingDirectory, ); - return result.stdout.trim().isNotEmpty; + final String output = result.stdout.trim(); + if (output.isEmpty) { + return false; + } + + // On non-stable channels, we ignore changes to pubspec.lock files. + if (version.channel != 'stable') { + final List lines = output.split('\n'); + var hasOtherChanges = false; + for (final line in lines) { + final String trimmed = line.trim(); + if (trimmed.isEmpty) { + continue; + } + // Check if the file is pubspec.lock. We check for a leading space or + // directory separator to avoid matching files like 'another_pubspec.lock'. + if (trimmed.endsWith(' pubspec.lock') || trimmed.endsWith('/pubspec.lock')) { + continue; + } + hasOtherChanges = true; + break; + } + return hasOtherChanges; + } + + return true; } on ProcessException catch (error) { throwToolExit( 'The tool could not verify the status of the current flutter checkout. ' diff --git a/packages/flutter_tools/lib/src/commands/validate_project.dart b/packages/flutter_tools/lib/src/commands/validate_project.dart index f6aad536b1d03..37500539ec197 100644 --- a/packages/flutter_tools/lib/src/commands/validate_project.dart +++ b/packages/flutter_tools/lib/src/commands/validate_project.dart @@ -91,9 +91,9 @@ class ValidateProject { } void addResultString( - final String title, - final List? results, - final List resultsString, + String title, + List? results, + List resultsString, ) { if (results != null) { for (final ProjectValidatorResult result in results) { diff --git a/packages/flutter_tools/lib/src/flutter_cache.dart b/packages/flutter_tools/lib/src/flutter_cache.dart index c2228fc79b42f..f2a2d41ea8404 100644 --- a/packages/flutter_tools/lib/src/flutter_cache.dart +++ b/packages/flutter_tools/lib/src/flutter_cache.dart @@ -386,10 +386,11 @@ class AndroidGenSnapshotArtifacts extends EngineCachedArtifact { @override List> getBinaryDirs() { + final String linuxArch = cache.getHostPlatformArchName(); return >[ if (cache.includeAllPlatforms) ...>[ ..._osxBinaryDirs, - ..._linuxBinaryDirs, + ..._linuxBinaryDirs(linuxArch), ..._windowsBinaryDirs, ..._dartSdks, ] else if (_platform.isWindows) @@ -397,7 +398,7 @@ class AndroidGenSnapshotArtifacts extends EngineCachedArtifact { else if (_platform.isMacOS) ..._osxBinaryDirs else if (_platform.isLinux) - ..._linuxBinaryDirs, + ..._linuxBinaryDirs(linuxArch), ]; } @@ -902,13 +903,13 @@ const _osxBinaryDirs = >[ ['android-x64-release/darwin-x64', 'android-x64-release/darwin-x64.zip'], ]; -const _linuxBinaryDirs = >[ - ['android-arm-profile/linux-x64', 'android-arm-profile/linux-x64.zip'], - ['android-arm-release/linux-x64', 'android-arm-release/linux-x64.zip'], - ['android-arm64-profile/linux-x64', 'android-arm64-profile/linux-x64.zip'], - ['android-arm64-release/linux-x64', 'android-arm64-release/linux-x64.zip'], - ['android-x64-profile/linux-x64', 'android-x64-profile/linux-x64.zip'], - ['android-x64-release/linux-x64', 'android-x64-release/linux-x64.zip'], +List> _linuxBinaryDirs(String arch) => >[ + ['android-arm-profile/linux-$arch', 'android-arm-profile/linux-$arch.zip'], + ['android-arm-release/linux-$arch', 'android-arm-release/linux-$arch.zip'], + ['android-arm64-profile/linux-$arch', 'android-arm64-profile/linux-$arch.zip'], + ['android-arm64-release/linux-$arch', 'android-arm64-release/linux-$arch.zip'], + ['android-x64-profile/linux-$arch', 'android-x64-profile/linux-$arch.zip'], + ['android-x64-release/linux-$arch', 'android-x64-release/linux-$arch.zip'], ]; const _windowsBinaryDirs = >[ diff --git a/packages/flutter_tools/lib/src/isolated/native_assets/macos/native_assets_host.dart b/packages/flutter_tools/lib/src/isolated/native_assets/macos/native_assets_host.dart index 45c4acb5a3c4b..d307818d47742 100644 --- a/packages/flutter_tools/lib/src/isolated/native_assets/macos/native_assets_host.dart +++ b/packages/flutter_tools/lib/src/isolated/native_assets/macos/native_assets_host.dart @@ -351,13 +351,17 @@ Map> fatAssetTargetLocations( KernelAsset Function(FlutterCodeAsset asset, Set alreadyTakenNames) targetLocationCallback, ) { - final alreadyTakenNames = {}; + final alreadyTakenNamesPerTarget = >{}; final result = >{}; final idToPath = {}; for (final asset in nativeAssets) { // Use same target path for all assets with the same id. final String assetId = asset.codeAsset.id; final KernelAssetPath? existingPath = idToPath[assetId]; + final Set alreadyTakenNames = alreadyTakenNamesPerTarget.putIfAbsent( + asset.target, + () => {}, + ); final KernelAssetPath currentPath = targetLocationCallback(asset, alreadyTakenNames).path; if (existingPath != null && existingPath != currentPath) { diff --git a/packages/flutter_tools/lib/src/isolated/native_assets/native_assets.dart b/packages/flutter_tools/lib/src/isolated/native_assets/native_assets.dart index 457ac6eb234c0..ab02077c618f9 100644 --- a/packages/flutter_tools/lib/src/isolated/native_assets/native_assets.dart +++ b/packages/flutter_tools/lib/src/isolated/native_assets/native_assets.dart @@ -330,6 +330,7 @@ Future runFlutterSpecificLinkHooks({ ); } dataAssets.addAll(_filterDataAssets(buildResult.encodedAssets)); + dependencies.addAll(buildResult.dependencies); } } diff --git a/packages/flutter_tools/lib/src/localizations/gen_l10n.dart b/packages/flutter_tools/lib/src/localizations/gen_l10n.dart index b2fb62ff41ec1..a250e32d176cd 100644 --- a/packages/flutter_tools/lib/src/localizations/gen_l10n.dart +++ b/packages/flutter_tools/lib/src/localizations/gen_l10n.dart @@ -1028,7 +1028,7 @@ class LocalizationsGenerator { String className, String fileName, String header, - final LocaleInfo locale, + LocaleInfo locale, ) { final Iterable methods = _allMessages.map((Message message) { var localeWithFallback = locale; diff --git a/packages/flutter_tools/lib/src/test/test_time_recorder.dart b/packages/flutter_tools/lib/src/test/test_time_recorder.dart index 5279e69a8eea0..0141d9261f1d8 100644 --- a/packages/flutter_tools/lib/src/test/test_time_recorder.dart +++ b/packages/flutter_tools/lib/src/test/test_time_recorder.dart @@ -38,11 +38,11 @@ class TestTimeRecorder { } @visibleForTesting - Stopwatch getPhaseWallClockStopwatchForTesting(final TestTimePhases phase) { + Stopwatch getPhaseWallClockStopwatchForTesting(TestTimePhases phase) { return _phaseRecords[phase.index]._wallClockRuntime; } - String _getPrintStringForPhase(final TestTimePhases phase) { + String _getPrintStringForPhase(TestTimePhases phase) { assert(_phaseRecords[phase.index].isDone()); return 'Runtime for phase ${phase.name}: ${_phaseRecords[phase.index]}'; } diff --git a/packages/flutter_tools/pubspec.yaml b/packages/flutter_tools/pubspec.yaml index 8e98bfbea9c61..e4c16da62e4ed 100644 --- a/packages/flutter_tools/pubspec.yaml +++ b/packages/flutter_tools/pubspec.yaml @@ -66,8 +66,8 @@ dependencies: # We depend on very specific internal implementation details of the # 'test' package, which change between versions, so when upgrading # this, make sure the tests are still running correctly. - test_api: 0.7.11 - test_core: 0.6.17 + test_api: 0.7.12 + test_core: 0.6.18 vm_service: 15.1.0 @@ -95,7 +95,7 @@ dependencies: http_parser: 4.1.2 io: 1.0.5 json_rpc_2: 4.1.0 - matcher: 0.12.19 + matcher: 0.12.20 petitparser: 7.0.2 platform: 3.1.6 shelf_packages_handler: 3.0.2 @@ -122,10 +122,10 @@ dev_dependencies: js: 0.7.2 json_annotation: 4.11.0 node_preamble: 2.0.2 - test: 1.31.0 + test: 1.31.1 dartdoc: # Exclude this package from the hosted API docs. nodoc: true -# PUBSPEC CHECKSUM: 2bcedf +# PUBSPEC CHECKSUM: 3bm17u diff --git a/packages/flutter_tools/test/commands.shard/hermetic/upgrade_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/upgrade_test.dart index 0d848a1a02dce..5bcbe5e083628 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/upgrade_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/upgrade_test.dart @@ -7,6 +7,7 @@ import 'dart:async'; import 'package:args/command_runner.dart'; import 'package:file/file.dart'; import 'package:file/memory.dart'; +import 'package:flutter_tools/src/base/common.dart'; import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/base/time.dart'; import 'package:flutter_tools/src/cache.dart'; @@ -316,4 +317,103 @@ void main() { ProcessManager: () => processManager, }, ); + testUsingContext( + 'allows upgrading if the only local modifications are pubspec.lock files', + () async { + final reEntryCompleter = Completer(); + + Future reEnterTool(List args) async { + reEntryCompleter.complete(); + } + + processManager.addCommands([ + const FakeCommand( + command: ['git', 'tag', '--points-at', 'HEAD'], + stdout: startingTag, + ), + const FakeCommand(command: ['git', 'fetch', '--tags']), + const FakeCommand( + command: ['git', 'rev-parse', '--verify', '@{upstream}'], + stdout: upstreamHeadRevision, + ), + const FakeCommand( + command: ['git', 'tag', '--points-at', upstreamHeadRevision], + stdout: latestUpstreamTag, + ), + const FakeCommand( + command: ['git', 'status', '-s'], + stdout: ' M packages/flutter/pubspec.lock\n M packages/flutter_tools/pubspec.lock\n', + ), + const FakeCommand(command: ['git', 'reset', '--hard', upstreamHeadRevision]), + FakeCommand( + command: const [ + 'bin/flutter', + 'upgrade', + '--continue', + '--continue-started-at', + '2026-01-01T00:00:00.000Z', + '--no-version-check', + ], + onRun: reEnterTool, + completer: reEntryCompleter, + ), + ]); + + await runner.run(['upgrade']); + expect(processManager, hasNoRemainingExpectations); + }, + overrides: { + FileSystem: () => fileSystem, + FlutterVersion: () => + FakeFlutterVersion(frameworkVersion: startingTag, engineRevision: 'engine'), + Logger: () => logger, + ProcessManager: () => processManager, + }, + ); + + testUsingContext( + 'fails upgrading on stable if pubspec.lock files are modified', + () async { + processManager.addCommands([ + const FakeCommand( + command: ['git', 'tag', '--points-at', 'HEAD'], + stdout: startingTag, + ), + const FakeCommand(command: ['git', 'fetch', '--tags']), + const FakeCommand( + command: ['git', 'rev-parse', '--verify', '@{upstream}'], + stdout: upstreamHeadRevision, + ), + const FakeCommand( + command: ['git', 'tag', '--points-at', upstreamHeadRevision], + stdout: latestUpstreamTag, + ), + const FakeCommand( + command: ['git', 'status', '-s'], + stdout: ' M packages/flutter/pubspec.lock\n', + ), + ]); + + expect( + () => runner.run(['upgrade']), + throwsA( + isA().having( + (ToolExit e) => e.message, + 'message', + contains('Your flutter checkout has local changes'), + ), + ), + ); + }, + overrides: { + FileSystem: () => fileSystem, + FlutterVersion: () => FakeFlutterVersion( + branch: 'stable', + frameworkVersion: startingTag, + engineRevision: 'engine', + ), + Logger: () => logger, + ProcessManager: () => processManager, + }, + ); } diff --git a/packages/flutter_tools/test/commands.shard/permeable/upgrade_test.dart b/packages/flutter_tools/test/commands.shard/permeable/upgrade_test.dart index 399f17f33bb26..2f91b258b4a41 100644 --- a/packages/flutter_tools/test/commands.shard/permeable/upgrade_test.dart +++ b/packages/flutter_tools/test/commands.shard/permeable/upgrade_test.dart @@ -100,6 +100,69 @@ void main() { overrides: {Platform: () => fakePlatform}, ); + testUsingContext( + 'hasUncommittedChanges ignores pubspec.lock on non-stable channel', + () async { + final flutterVersion = FakeFlutterVersion(); + + processManager.addCommand( + const FakeCommand( + command: ['git', 'status', '-s'], + stdout: ' M pubspec.lock\n M packages/flutter_tools/pubspec.lock', + ), + ); + + final bool result = await realCommandRunner.hasUncommittedChanges(flutterVersion); + expect(result, false); + expect(processManager, hasNoRemainingExpectations); + }, + overrides: { + ProcessManager: () => processManager, + Platform: () => fakePlatform, + }, + ); + + testUsingContext( + 'hasUncommittedChanges does not ignore pubspec.lock on stable channel', + () async { + final flutterVersion = FakeFlutterVersion(branch: 'stable'); + + processManager.addCommand( + const FakeCommand(command: ['git', 'status', '-s'], stdout: ' M pubspec.lock'), + ); + + final bool result = await realCommandRunner.hasUncommittedChanges(flutterVersion); + expect(result, true); + expect(processManager, hasNoRemainingExpectations); + }, + overrides: { + ProcessManager: () => processManager, + Platform: () => fakePlatform, + }, + ); + + testUsingContext( + 'hasUncommittedChanges detects other changes on non-stable channel', + () async { + final flutterVersion = FakeFlutterVersion(); + + processManager.addCommand( + const FakeCommand( + command: ['git', 'status', '-s'], + stdout: ' M pubspec.lock\n M lib/src/commands/upgrade.dart', + ), + ); + + final bool result = await realCommandRunner.hasUncommittedChanges(flutterVersion); + expect(result, true); + expect(processManager, hasNoRemainingExpectations); + }, + overrides: { + ProcessManager: () => processManager, + Platform: () => fakePlatform, + }, + ); + testUsingContext( "Doesn't continue on known tag, beta branch, no force, already up-to-date", () async { @@ -830,7 +893,7 @@ class FakeUpgradeCommandRunner extends UpgradeCommandRunner { Future fetchLatestVersion({FlutterVersion? localVersion}) async => remoteVersion; @override - Future hasUncommittedChanges() async => willHaveUncommittedChanges; + Future hasUncommittedChanges(FlutterVersion version) async => willHaveUncommittedChanges; @override Future attemptReset(String newRevision) async {} diff --git a/packages/flutter_tools/test/general.shard/android/android_device_start_test.dart b/packages/flutter_tools/test/general.shard/android/android_device_start_test.dart index 41b2f88264d0f..d80705a3899a8 100644 --- a/packages/flutter_tools/test/general.shard/android/android_device_start_test.dart +++ b/packages/flutter_tools/test/general.shard/android/android_device_start_test.dart @@ -64,7 +64,7 @@ void main() { platform: FakePlatform(), androidSdk: androidSdk, ); - final File apkFile = fileSystem.file('app-debug.apk')..createSync(); + final File apkFile = fileSystem.file('app-release.apk')..createSync(); final apk = AndroidApk( id: 'FlutterApp', applicationPackage: apkFile, @@ -89,7 +89,7 @@ void main() { ); processManager.addCommand( const FakeCommand( - command: ['adb', '-s', '1234', 'install', '-t', '-r', 'app-debug.apk'], + command: ['adb', '-s', '1234', 'install', '-t', '-r', 'app-release.apk'], ), ); processManager.addCommand(kShaCommand); @@ -108,9 +108,6 @@ void main() { 'android.intent.category.LAUNCHER', '-f', '0x20000000', - '--ez', - 'enable-dart-profiling', - 'true', 'FlutterActivity', ], ), @@ -119,7 +116,7 @@ void main() { final LaunchResult launchResult = await device.startApp( apk, prebuiltApplication: true, - debuggingOptions: DebuggingOptions.disabled(BuildInfo.release), + debuggingOptions: DebuggingOptions.disabled(BuildInfo.release, enableDartProfiling: false), platformArgs: {}, ); @@ -128,6 +125,88 @@ void main() { }); } + testWithoutContext( + 'AndroidDevice.startApp forwards Impeller and HCPP flags in release mode', + () async { + final device = AndroidDevice( + '1234', + modelID: 'TestModel', + fileSystem: fileSystem, + processManager: processManager, + logger: BufferLogger.test(), + platform: FakePlatform(), + androidSdk: androidSdk, + ); + final File apkFile = fileSystem.file('app-release.apk')..createSync(); + final apk = AndroidApk( + id: 'FlutterApp', + applicationPackage: apkFile, + launchActivity: 'FlutterActivity', + versionCode: 1, + ); + + processManager.addCommand(kAdbVersionCommand); + processManager.addCommand(kStartServer); + processManager.addCommand( + const FakeCommand( + command: ['adb', '-s', '1234', 'shell', 'getprop'], + stdout: '[ro.product.cpu.abi]: [arm64-v8a]', + ), + ); + processManager.addCommand( + const FakeCommand( + command: ['adb', '-s', '1234', 'shell', 'am', 'force-stop', 'FlutterApp'], + ), + ); + processManager.addCommand( + const FakeCommand( + command: ['adb', '-s', '1234', 'install', '-t', '-r', 'app-release.apk'], + ), + ); + processManager.addCommand(kShaCommand); + processManager.addCommand( + const FakeCommand( + command: [ + 'adb', + '-s', + '1234', + 'shell', + 'am', + 'start', + '-a', + 'android.intent.action.MAIN', + '-c', + 'android.intent.category.LAUNCHER', + '-f', + '0x20000000', + '--ez', + 'enable-impeller', + 'true', + '--ez', + 'enable-hcpp-and-surface-control', + 'true', + 'FlutterActivity', + ], + ), + ); + + final LaunchResult launchResult = await device.startApp( + apk, + prebuiltApplication: true, + debuggingOptions: DebuggingOptions.disabled( + BuildInfo.release, + enableImpeller: ImpellerStatus.enabled, + enableHcpp: true, + enableDartProfiling: false, + ), + platformArgs: {}, + ); + + expect(launchResult.started, true); + expect(processManager, hasNoRemainingExpectations); + }, + ); + testWithoutContext('AndroidDevice.startApp forwards all supported debugging options', () async { final device = AndroidDevice( '1234', @@ -224,6 +303,7 @@ void main() { '--ez', 'purge-persistent-cache', 'true', '--ez', 'enable-impeller', 'true', '--ez', 'enable-flutter-gpu', 'true', + '--ez', 'enable-hcpp-and-surface-control', 'true', '--ez', 'enable-checked-mode', 'true', '--ez', 'verify-entry-points', 'true', '--ez', 'start-paused', 'true', @@ -260,6 +340,7 @@ void main() { enableImpeller: ImpellerStatus.enabled, enableFlutterGpu: true, profileStartup: true, + enableHcpp: true, ), platformArgs: {}, userIdentifier: '10', diff --git a/packages/flutter_tools/test/general.shard/android/android_project_migration_test.dart b/packages/flutter_tools/test/general.shard/android/android_project_migration_test.dart index c450d2355f5ab..ecfacbeb62530 100644 --- a/packages/flutter_tools/test/general.shard/android/android_project_migration_test.dart +++ b/packages/flutter_tools/test/general.shard/android/android_project_migration_test.dart @@ -488,7 +488,7 @@ android.builtInKotlin false expect( bufferLogger.errorText, - contains('Failed to process/migrate the gradle.properties during migration:'), + contains('Failed to process/migrate gradle.properties during migration:'), ); }, overrides: { @@ -676,7 +676,7 @@ android.newDsl : false expect( bufferLogger.errorText, - contains('Failed to process/migrate the gradle.properties during migration:'), + contains('Failed to process/migrate gradle.properties during migration:'), ); }, overrides: { diff --git a/packages/flutter_tools/test/general.shard/artifacts_test.dart b/packages/flutter_tools/test/general.shard/artifacts_test.dart index 46cacd14758d2..ea13b6ff135ea 100644 --- a/packages/flutter_tools/test/general.shard/artifacts_test.dart +++ b/packages/flutter_tools/test/general.shard/artifacts_test.dart @@ -6,6 +6,7 @@ import 'package:file/memory.dart'; import 'package:flutter_tools/src/artifacts.dart'; import 'package:flutter_tools/src/base/file_system.dart'; import 'package:flutter_tools/src/base/logger.dart'; +import 'package:flutter_tools/src/base/os.dart'; import 'package:flutter_tools/src/base/platform.dart'; import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/cache.dart'; @@ -165,6 +166,55 @@ void main() { ); }); + testWithoutContext('getArtifactPath resolves gen_snapshot to linux-x64 on x64 Linux host', () { + expect( + artifacts.getArtifactPath( + Artifact.genSnapshot, + platform: TargetPlatform.android_arm64, + mode: BuildMode.release, + ), + fileSystem.path.join( + 'root', + 'bin', + 'cache', + 'artifacts', + 'engine', + 'android-arm64-release', + 'linux-x64', + 'gen_snapshot', + ), + ); + }); + + testWithoutContext( + 'getArtifactPath resolves gen_snapshot to linux-arm64 on arm64 Linux host', + () { + final arm64Artifacts = CachedArtifacts( + fileSystem: fileSystem, + cache: cache, + platform: platform, + operatingSystemUtils: FakeOperatingSystemUtils(hostPlatform: HostPlatform.linux_arm64), + ); + expect( + arm64Artifacts.getArtifactPath( + Artifact.genSnapshot, + platform: TargetPlatform.android_arm64, + mode: BuildMode.release, + ), + fileSystem.path.join( + 'root', + 'bin', + 'cache', + 'artifacts', + 'engine', + 'android-arm64-release', + 'linux-arm64', + 'gen_snapshot', + ), + ); + }, + ); + testWithoutContext( 'getArtifactPath for FlutterMacOS.framework and FlutterMacOS.xcframework', () { diff --git a/packages/flutter_tools/test/general.shard/cache_test.dart b/packages/flutter_tools/test/general.shard/cache_test.dart index d4804bef6e47b..e4c2c18de1220 100644 --- a/packages/flutter_tools/test/general.shard/cache_test.dart +++ b/packages/flutter_tools/test/general.shard/cache_test.dart @@ -1279,6 +1279,44 @@ void main() { ]); }); + testWithoutContext( + 'Android gen_snapshot artifacts on x64 linux host include linux-x64 archives', + () { + fakeProcessManager.addCommand(unameCommandForX64); + + final Cache cache = createCache(FakePlatform()); + final artifacts = AndroidGenSnapshotArtifacts(cache, platform: FakePlatform()); + + expect(artifacts.getBinaryDirs(), >[ + ['android-arm-profile/linux-x64', 'android-arm-profile/linux-x64.zip'], + ['android-arm-release/linux-x64', 'android-arm-release/linux-x64.zip'], + ['android-arm64-profile/linux-x64', 'android-arm64-profile/linux-x64.zip'], + ['android-arm64-release/linux-x64', 'android-arm64-release/linux-x64.zip'], + ['android-x64-profile/linux-x64', 'android-x64-profile/linux-x64.zip'], + ['android-x64-release/linux-x64', 'android-x64-release/linux-x64.zip'], + ]); + }, + ); + + testWithoutContext( + 'Android gen_snapshot artifacts on arm64 linux host include linux-arm64 archives', + () { + fakeProcessManager.addCommand(unameCommandForArm64); + + final Cache cache = createCache(FakePlatform()); + final artifacts = AndroidGenSnapshotArtifacts(cache, platform: FakePlatform()); + + expect(artifacts.getBinaryDirs(), >[ + ['android-arm-profile/linux-arm64', 'android-arm-profile/linux-arm64.zip'], + ['android-arm-release/linux-arm64', 'android-arm-release/linux-arm64.zip'], + ['android-arm64-profile/linux-arm64', 'android-arm64-profile/linux-arm64.zip'], + ['android-arm64-release/linux-arm64', 'android-arm64-release/linux-arm64.zip'], + ['android-x64-profile/linux-arm64', 'android-x64-profile/linux-arm64.zip'], + ['android-x64-release/linux-arm64', 'android-x64-release/linux-arm64.zip'], + ]); + }, + ); + testWithoutContext('Cache can delete stampfiles of artifacts', () { final FileSystem fileSystem = MemoryFileSystem.test(); final artifactSet = FakeIosUsbArtifacts(); diff --git a/packages/flutter_tools/test/general.shard/isolated/build_system/targets/native_assets_test.dart b/packages/flutter_tools/test/general.shard/isolated/build_system/targets/native_assets_test.dart index 26a2f3ec8afbf..b73a64350246a 100644 --- a/packages/flutter_tools/test/general.shard/isolated/build_system/targets/native_assets_test.dart +++ b/packages/flutter_tools/test/general.shard/isolated/build_system/targets/native_assets_test.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:data_assets/data_assets.dart'; import 'package:file/memory.dart'; import 'package:file_testing/file_testing.dart'; import 'package:flutter_tools/src/artifacts.dart'; @@ -140,6 +141,74 @@ void main() { ); } + bool nativeAssetsLinkingEnabled(BuildMode buildMode) { + switch (buildMode) { + case BuildMode.debug: + return false; + case BuildMode.jitRelease: + case BuildMode.profile: + case BuildMode.release: + return true; + } + } + + for (final buildMode in [BuildMode.profile, BuildMode.debug]) { + final bool linkingEnabled = nativeAssetsLinkingEnabled(buildMode); + final testName = linkingEnabled ? 'enabled' : 'disabled'; + testUsingContext( + 'NativeAssets depfile filtering avoids circular cycles in Xcode when link hooks are $testName', + overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => processManager, + FeatureFlags: () => + TestFeatureFlags(isNativeAssetsEnabled: true, isDartDataAssetsEnabled: true), + }, + () async { + writePackageConfigFiles(directory: iosEnvironment.projectDir, mainLibName: 'my_app'); + + // Force environment to use specified build mode! + iosEnvironment.defines[kBuildMode] = buildMode.cliName; + + final String sourceAssetPath = iosEnvironment.fileSystem + .file('assets/translations/en.json') + .path; + iosEnvironment.fileSystem.file(sourceAssetPath).createSync(recursive: true); + + final FlutterNativeAssetsBuildRunner buildRunner = FakeFlutterNativeAssetsBuildRunner( + packagesWithNativeAssetsResult: ['foo'], + buildResult: FakeFlutterNativeAssetsBuilderResult.fromAssets( + dependencies: [Uri.file(sourceAssetPath)], + dataAssets: [ + DataAsset(file: Uri.file(sourceAssetPath), name: 'en.json', package: 'my_app'), + ], + ), + linkResult: linkingEnabled + ? FakeFlutterNativeAssetsBuilderResult.fromAssets( + dependencies: [Uri.file(sourceAssetPath)], + ) + : const FakeFlutterNativeAssetsBuilderResult(), + ); + + final dartBuildForNative = BuildHooks(buildRunner: buildRunner); + await dartBuildForNative.build(iosEnvironment); + + final dartLinkForNative = LinkHooks(buildRunner: buildRunner); + await dartLinkForNative.build(iosEnvironment); + + final File depfileFile = iosEnvironment.buildDir.childFile(LinkHooks.depFilename); + expect(depfileFile, exists); + + final String contents = depfileFile.readAsStringSync(); + final List colonSeparated = contents.split(': '); + expect(colonSeparated.length, 2); + + final List linkOutputs = _resolvedOutputs(dartLinkForNative, iosEnvironment); + // Verify that full source path resolved resolves to empty list after fix! + expect(linkOutputs, isNot(contains(sourceAssetPath))); + }, + ); + } + testUsingContext( 'NativeAssets with an asset', overrides: { diff --git a/packages/flutter_tools/test/general.shard/isolated/macos/native_assets_host_test.dart b/packages/flutter_tools/test/general.shard/isolated/macos/native_assets_host_test.dart index 9ae773ffc1bb7..90ae6c12be930 100644 --- a/packages/flutter_tools/test/general.shard/isolated/macos/native_assets_host_test.dart +++ b/packages/flutter_tools/test/general.shard/isolated/macos/native_assets_host_test.dart @@ -4,6 +4,8 @@ import 'package:code_assets/code_assets.dart'; import 'package:flutter_tools/src/isolated/native_assets/macos/native_assets_host.dart'; +import 'package:flutter_tools/src/isolated/native_assets/native_assets.dart'; +import 'package:hooks_runner/hooks_runner.dart'; import '../../../src/common.dart'; @@ -115,4 +117,112 @@ void main() { ); }); }); + + test('fatAssetTargetLocations ignores cross-architecture conflicts', () { + final asset1 = FlutterCodeAsset( + codeAsset: CodeAsset( + package: 'my_package', + name: 'my_asset', + linkMode: DynamicLoadingBundled(), + file: Uri.file('libmy_asset.dylib'), + ), + target: Target.fromString('macos_arm64'), + ); + final asset2 = FlutterCodeAsset( + codeAsset: CodeAsset( + package: 'my_package', + name: 'my_asset', + linkMode: DynamicLoadingBundled(), + file: Uri.file('libmy_asset.dylib'), + ), + target: Target.fromString('macos_x64'), + ); + + final Map> result = fatAssetTargetLocations( + [asset1, asset2], + (FlutterCodeAsset asset, Set alreadyTakenNames) { + final String fileName = asset.codeAsset.file!.pathSegments.last; + final Uri uri = frameworkUri(fileName, alreadyTakenNames); + return KernelAsset( + id: asset.codeAsset.id, + target: asset.target, + path: KernelAssetAbsolutePath(uri), + ); + }, + ); + + expect(result.length, equals(1)); + final KernelAssetPath path = result.keys.single; + expect((path as KernelAssetAbsolutePath).uri.path, equals('my_asset.framework/my_asset')); + expect(result[path]!.length, equals(2)); + }); + + test('fatAssetTargetLocations handles conflicts between different assets', () { + final assetA1 = FlutterCodeAsset( + codeAsset: CodeAsset( + package: 'package_a', + name: 'my_asset', + linkMode: DynamicLoadingBundled(), + file: Uri.file('libfoo.dylib'), + ), + target: Target.fromString('macos_arm64'), + ); + final assetB1 = FlutterCodeAsset( + codeAsset: CodeAsset( + package: 'package_b', + name: 'my_asset', + linkMode: DynamicLoadingBundled(), + file: Uri.file('libfoo.dylib'), + ), + target: Target.fromString('macos_arm64'), + ); + final assetA2 = FlutterCodeAsset( + codeAsset: CodeAsset( + package: 'package_a', + name: 'my_asset', + linkMode: DynamicLoadingBundled(), + file: Uri.file('libfoo.dylib'), + ), + target: Target.fromString('macos_x64'), + ); + final assetB2 = FlutterCodeAsset( + codeAsset: CodeAsset( + package: 'package_b', + name: 'my_asset', + linkMode: DynamicLoadingBundled(), + file: Uri.file('libfoo.dylib'), + ), + target: Target.fromString('macos_x64'), + ); + + final Map> result = fatAssetTargetLocations( + [assetA1, assetB1, assetA2, assetB2], + (FlutterCodeAsset asset, Set alreadyTakenNames) { + final String fileName = asset.codeAsset.file!.pathSegments.last; + final Uri uri = frameworkUri(fileName, alreadyTakenNames); + return KernelAsset( + id: asset.codeAsset.id, + target: asset.target, + path: KernelAssetAbsolutePath(uri), + ); + }, + ); + + expect(result.length, equals(2)); + + final KernelAssetPath pathA = result.keys.firstWhere( + (k) => (k as KernelAssetAbsolutePath).uri.path == 'foo.framework/foo', + ); + final KernelAssetPath pathB = result.keys.firstWhere( + (k) => (k as KernelAssetAbsolutePath).uri.path == 'foo1.framework/foo1', + ); + + expect(result[pathA]!.length, equals(2)); + expect(result[pathA]!.contains(assetA1), isTrue); + expect(result[pathA]!.contains(assetA2), isTrue); + + expect(result[pathB]!.length, equals(2)); + expect(result[pathB]!.contains(assetB1), isTrue); + expect(result[pathB]!.contains(assetB2), isTrue); + }); } diff --git a/pubspec.lock b/pubspec.lock index 9feb6c5b5a540..19c3e349e9071 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -654,10 +654,10 @@ packages: dependency: "direct main" description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: "direct main" description: @@ -1075,26 +1075,26 @@ packages: dependency: "direct main" description: name: test - sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20" + sha256: ca578dc12bb8b2f40b67b7d3bd2fac4f31c01a6ff7130a14e2597b919934507f url: "https://pub.dev" source: hosted - version: "1.31.0" + version: "1.31.1" test_api: dependency: "direct main" description: name: test_api - sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.11" + version: "0.7.12" test_core: dependency: "direct main" description: name: test_core - sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34" + sha256: d2e98ec12998368dc59ddd47ab709f2cd55acd6b66dc7db764455a44082f4bc5 url: "https://pub.dev" source: hosted - version: "0.6.17" + version: "0.6.18" typed_data: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 289a32cbcc134..e01508029d254 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -137,7 +137,7 @@ dependencies: leak_tracker_flutter_testing: ^3.0.10 leak_tracker_testing: 3.0.2 logging: 1.3.0 - matcher: 0.12.19 + matcher: 0.12.20 material_color_utilities: 0.13.0 meta: ^1.18.2 metrics_center: 1.0.15 @@ -179,9 +179,9 @@ dependencies: string_scanner: 1.4.1 sync_http: 0.3.1 term_glyph: 1.2.2 - test: 1.31.0 - test_api: 0.7.11 - test_core: 0.6.17 + test: 1.31.1 + test_api: 0.7.12 + test_core: 0.6.18 typed_data: 1.4.0 url_launcher: 6.3.2 url_launcher_android: 6.3.29 @@ -224,4 +224,4 @@ dependencies: dev_dependencies: ffigen: 20.1.1 -# PUBSPEC CHECKSUM: 1ctl5b +# PUBSPEC CHECKSUM: 84gp9h