From 0ee1eea9f133e26674ee38dfc7ced0b65f44aae9 Mon Sep 17 00:00:00 2001 From: Matej Knopp Date: Sat, 17 Oct 2026 15:13:37 +0200 Subject: [PATCH 1/5] Update flutter tools. --- packages/flutter_tools/analysis_options.yaml | 9 + .../flutter_tools/bin/fuchsia_tester.dart | 36 +- packages/flutter_tools/bin/podhelper.rb | 25 +- packages/flutter_tools/bin/tool_backend.dart | 4 + packages/flutter_tools/dart_test.yaml | 5 - packages/flutter_tools/gradle/AGENTS.md | 40 + packages/flutter_tools/gradle/README.md | 2 +- .../flutter_tools/gradle/build.gradle.kts | 21 +- .../main/kotlin/AgpCommonExtensionWrapper.kt | 30 + .../src/main/kotlin/CompileSdkVersion.kt | 48 + .../main/kotlin/DependencyVersionChecker.kt | 49 +- .../gradle/src/main/kotlin/DslVersionCodes.kt | 42 + .../src/main/kotlin/FlutterCompileOptions.kt | 99 + .../gradle/src/main/kotlin/FlutterPlugin.kt | 695 ++-- .../src/main/kotlin/FlutterPluginUtils.kt | 196 +- .../gradle/src/main/kotlin/VersionFetcher.kt | 20 +- .../src/main/kotlin/plugins/PluginHandler.kt | 80 +- .../main/kotlin/tasks/CopyFlutterApksTask.kt | 147 + .../kotlin/tasks/CopyFlutterAssetsTask.kt | 62 + .../kotlin/tasks/CopyFlutterJniLibsTask.kt | 32 +- .../tasks/DeepLinkJsonFromManifestTask.kt | 10 +- .../tasks/GenerateEngineFlagsManifestTask.kt | 52 + .../kotlin/AgpCommonExtensionWrapperTest.kt | 213 +- .../kotlin/BaseApplicationNameHandlerTest.kt | 41 +- .../src/test/kotlin/CompileSdkVersionTest.kt | 82 + .../gradle/src/test/kotlin/DeeplinkTest.kt | 4 +- .../kotlin/DependencyVersionCheckerTest.kt | 117 +- .../src/test/kotlin/DslVersionCodesTest.kt | 85 + .../test/kotlin/FlutterCompileOptionsTest.kt | 110 + .../src/test/kotlin/FlutterPluginTest.kt | 1017 +++-- .../src/test/kotlin/FlutterPluginUtilsTest.kt | 719 ++-- .../src/test/kotlin/VersionFetcherTest.kt | 25 +- .../test/kotlin/plugins/PluginHandlerTest.kt | 459 ++- .../kotlin/tasks/CopyFlutterApksTaskTest.kt | 200 + .../kotlin/tasks/CopyFlutterAssetsTaskTest.kt | 93 + .../GenerateEngineFlagsManifestTaskTest.kt | 182 + .../kotlin/testing/AndroidExtensionMocks.kt | 211 + .../testing/FilterConfigurationMocks.kt | 20 + .../testing/VersionFetcherTestHelper.kt | 22 - .../validation/BytecodeValidatorTest.kt | 104 + .../validation/InternalAgpApiImportTest.kt | 53 + .../src/test/kotlin/validation/README.md | 3 + packages/flutter_tools/lib/executable.dart | 384 +- packages/flutter_tools/lib/runner.dart | 255 +- .../flutter_tools/lib/src/android/README.md | 4 + .../lib/src/android/android_console.dart | 9 + .../lib/src/android/android_device.dart | 202 +- .../src/android/android_device_discovery.dart | 22 +- .../lib/src/android/android_emulator.dart | 24 +- .../src/android/android_engine_cli_flags.dart | 69 + .../lib/src/android/android_sdk.dart | 320 +- .../lib/src/android/android_studio.dart | 319 +- .../lib/src/android/android_workflow.dart | 87 +- .../lib/src/android/application_package.dart | 16 +- .../lib/src/android/build_validation.dart | 1 - ...red_components_gen_snapshot_validator.dart | 8 +- ...eferred_components_prebuild_validator.dart | 60 +- .../deferred_components_validator.dart | 7 +- .../flutter_tools/lib/src/android/gradle.dart | 109 +- .../lib/src/android/gradle_errors.dart | 670 ++-- .../lib/src/android/gradle_utils.dart | 111 +- .../flutter_tools/lib/src/android/java.dart | 42 +- ...studio_java_gradle_conflict_migration.dart | 12 +- packages/flutter_tools/lib/src/artifacts.dart | 306 +- packages/flutter_tools/lib/src/asset.dart | 93 +- .../lib/src/base/analyze_size.dart | 13 +- .../lib/src/base/bot_detector.dart | 30 +- .../flutter_tools/lib/src/base/build.dart | 95 +- .../lib/src/base/command_help.dart | 26 +- .../flutter_tools/lib/src/base/common.dart | 44 + packages/flutter_tools/lib/src/base/dds.dart | 35 +- .../lib/src/base/error_handling_io.dart | 93 +- .../lib/src/base/file_system.dart | 4 +- .../lib/src/base/fingerprint.dart | 8 +- packages/flutter_tools/lib/src/base/io.dart | 36 +- .../flutter_tools/lib/src/base/logger.dart | 62 +- .../lib/src/base/multi_root_file_system.dart | 3 +- packages/flutter_tools/lib/src/base/net.dart | 6 +- packages/flutter_tools/lib/src/base/os.dart | 15 +- .../flutter_tools/lib/src/base/process.dart | 12 +- .../lib/src/base/task_queue.dart | 97 - .../flutter_tools/lib/src/base/terminal.dart | 8 +- packages/flutter_tools/lib/src/base/time.dart | 14 - .../lib/src/base/user_messages.dart | 4 +- .../flutter_tools/lib/src/base/utils.dart | 56 + .../flutter_tools/lib/src/build_info.dart | 453 +-- .../lib/src/build_system/build_system.dart | 109 +- .../lib/src/build_system/build_targets.dart | 2 + .../lib/src/build_system/depfile.dart | 8 +- .../lib/src/build_system/file_store.dart | 10 +- .../lib/src/build_system/source.dart | 37 +- .../lib/src/build_system/targets/android.dart | 20 +- .../lib/src/build_system/targets/assets.dart | 6 +- .../lib/src/build_system/targets/common.dart | 97 +- .../targets/dart_plugin_registrant.dart | 7 +- .../lib/src/build_system/targets/darwin.dart | 22 + .../targets/deferred_components.dart | 7 +- .../targets/icon_tree_shaker.dart | 66 +- .../lib/src/build_system/targets/ios.dart | 10 +- .../lib/src/build_system/targets/linux.dart | 8 +- .../build_system/targets/localizations.dart | 6 +- .../lib/src/build_system/targets/macos.dart | 2 +- .../build_system/targets/native_assets.dart | 34 +- .../lib/src/build_system/targets/web.dart | 1133 ++++-- .../lib/src/build_system/targets/windows.dart | 9 +- .../build_system/tools/asset_transformer.dart | 23 +- .../build_system/tools/shader_compiler.dart | 59 +- packages/flutter_tools/lib/src/bundle.dart | 7 - .../flutter_tools/lib/src/bundle_builder.dart | 6 +- packages/flutter_tools/lib/src/cache.dart | 158 +- .../lib/src/commands/analyze.dart | 97 +- .../lib/src/commands/analyze_base.dart | 55 +- .../src/commands/analyze_continuously.dart | 13 +- .../lib/src/commands/analyze_once.dart | 55 +- .../lib/src/commands/assemble.dart | 259 +- .../lib/src/commands/attach.dart | 314 +- .../flutter_tools/lib/src/commands/build.dart | 307 +- .../lib/src/commands/build_aar.dart | 154 +- .../lib/src/commands/build_apk.dart | 161 +- .../lib/src/commands/build_appbundle.dart | 186 +- .../lib/src/commands/build_bundle.dart | 70 +- .../lib/src/commands/build_ios.dart | 382 +- .../lib/src/commands/build_ios_framework.dart | 437 ++- .../lib/src/commands/build_linux.dart | 112 +- .../lib/src/commands/build_macos.dart | 63 +- .../src/commands/build_macos_framework.dart | 174 +- .../lib/src/commands/build_swift_package.dart | 292 +- .../lib/src/commands/build_web.dart | 353 +- .../lib/src/commands/build_windows.dart | 60 +- .../lib/src/commands/channel.dart | 100 +- .../flutter_tools/lib/src/commands/clean.dart | 91 +- .../lib/src/commands/config.dart | 285 +- .../lib/src/commands/create.dart | 585 ++- .../lib/src/commands/create_base.dart | 200 +- .../lib/src/commands/custom_devices.dart | 196 +- .../lib/src/commands/daemon.dart | 532 ++- .../lib/src/commands/darwin_add_to_app.dart | 52 +- .../lib/src/commands/debug_adapter.dart | 22 +- .../lib/src/commands/devices.dart | 124 +- .../lib/src/commands/doctor.dart | 51 +- .../lib/src/commands/downgrade.dart | 63 +- .../flutter_tools/lib/src/commands/drive.dart | 466 ++- .../lib/src/commands/emulators.dart | 66 +- .../lib/src/commands/generate.dart | 12 +- .../src/commands/generate_localizations.dart | 57 +- .../lib/src/commands/ide_config.dart | 125 +- .../lib/src/commands/install.dart | 37 +- .../flutter_tools/lib/src/commands/logs.dart | 45 +- .../lib/src/commands/packages.dart | 183 +- .../lib/src/commands/precache.dart | 13 +- .../flutter_tools/lib/src/commands/run.dart | 821 ++-- .../lib/src/commands/screenshot.dart | 41 +- .../lib/src/commands/shell_completion.dart | 15 +- .../lib/src/commands/symbolize.dart | 39 +- .../flutter_tools/lib/src/commands/test.dart | 902 +++-- .../lib/src/commands/update_packages.dart | 184 +- .../lib/src/commands/upgrade.dart | 215 +- .../lib/src/commands/validate_project.dart | 18 +- .../lib/src/commands/widget_preview.dart | 520 +-- packages/flutter_tools/lib/src/compile.dart | 106 +- .../lib/src/context/android_context.dart | 35 + .../lib/src/context/apple_context.dart | 50 + .../lib/src/context/tool_context.dart | 161 + .../lib/src/context/tool_dependencies.dart | 674 ++++ .../flutter_tools/lib/src/context_runner.dart | 163 +- packages/flutter_tools/lib/src/convert.dart | 3 +- .../lib/src/custom_devices/custom_device.dart | 58 +- .../custom_device_workflow.dart | 2 +- .../custom_devices/custom_devices_config.dart | 3 +- packages/flutter_tools/lib/src/daemon.dart | 49 +- .../flutter_tools/lib/src/dart/analysis.dart | 116 +- .../lib/src/dart/language_version.dart | 3 - .../lib/src/dart/package_map.dart | 47 +- packages/flutter_tools/lib/src/dart/pub.dart | 150 +- .../flutter_tools/lib/src/darwin/darwin.dart | 4 +- .../src/debug_adapters/error_formatter.dart | 13 +- .../src/debug_adapters/flutter_adapter.dart | 80 +- .../debug_adapters/flutter_adapter_args.dart | 2 +- .../debug_adapters/flutter_base_adapter.dart | 26 +- .../debug_adapters/flutter_test_adapter.dart | 21 +- .../lib/src/debug_adapters/server.dart | 2 +- .../flutter_tools/lib/src/desktop_device.dart | 11 +- packages/flutter_tools/lib/src/devfs.dart | 128 +- packages/flutter_tools/lib/src/device.dart | 384 +- ...evice_vm_service_discovery_for_attach.dart | 28 +- .../lib/src/devtools_launcher.dart | 13 +- packages/flutter_tools/lib/src/doctor.dart | 184 +- .../lib/src/doctor_validator.dart | 139 +- .../lib/src/drive/drive_service.dart | 153 +- .../lib/src/drive/import_validator.dart | 156 + .../lib/src/drive/web_driver_service.dart | 156 +- packages/flutter_tools/lib/src/emulator.dart | 23 +- .../lib/src/experimental/config.dart | 197 + .../lib/src/experimental/diagnostics.dart | 135 + .../experimental/extension_arg_parser.dart | 152 + .../experimental/extension_build_manager.dart | 155 + .../extension_device_manager.dart | 236 ++ .../src/experimental/extension_discovery.dart | 187 + .../src/experimental/extension_manager.dart | 157 + .../lib/src/experimental/templates.dart | 174 + packages/flutter_tools/lib/src/features.dart | 60 +- .../lib/src/flutter_application_package.dart | 40 +- .../flutter_tools/lib/src/flutter_cache.dart | 86 +- .../lib/src/flutter_device_manager.dart | 10 +- .../lib/src/flutter_features.dart | 16 +- .../lib/src/flutter_features_config.dart | 14 +- .../lib/src/flutter_manifest.dart | 5 +- .../lib/src/flutter_plugins.dart | 154 +- .../lib/src/flutter_project_metadata.dart | 108 +- packages/flutter_tools/lib/src/globals.dart | 5 - .../lib/src/http_host_validator.dart | 13 +- .../lib/src/intellij/intellij.dart | 148 - .../lib/src/ios/code_signing.dart | 65 +- .../lib/src/ios/core_devices.dart | 37 +- .../lib/src/ios/device_support.dart | 177 +- .../flutter_tools/lib/src/ios/devices.dart | 132 +- .../flutter_tools/lib/src/ios/ios_deploy.dart | 31 +- .../lib/src/ios/ios_workflow.dart | 8 +- .../flutter_tools/lib/src/ios/iproxy.dart | 28 +- packages/flutter_tools/lib/src/ios/lldb.dart | 212 +- packages/flutter_tools/lib/src/ios/mac.dart | 48 +- .../lib/src/ios/plist_parser.dart | 5 +- .../flutter_tools/lib/src/ios/simulators.dart | 66 +- .../lib/src/ios/xcode_build_settings.dart | 9 - .../lib/src/ios/xcode_debug.dart | 8 +- .../flutter_tools/lib/src/ios/xcodeproj.dart | 157 +- .../lib/src/isolated/devfs_web.dart | 109 +- .../native_assets/android/native_assets.dart | 51 +- .../native_assets/dart_hook_result.dart | 23 +- .../native_assets/ios/native_assets.dart | 59 +- .../native_assets/linux/native_assets.dart | 9 +- .../native_assets/macos/native_assets.dart | 74 +- .../macos/native_assets_host.dart | 94 +- .../isolated/native_assets/native_assets.dart | 217 +- .../native_assets/native_assets_manifest.dart | 223 ++ .../src/isolated/native_assets/targets.dart | 4 +- .../native_assets/test/native_assets.dart | 27 +- .../src/isolated/release_asset_server.dart | 43 +- .../lib/src/isolated/resident_web_runner.dart | 596 +-- .../lib/src/isolated/web_asset_server.dart | 282 +- .../src/isolated/web_expression_compiler.dart | 3 +- .../src/isolated/web_server_utilities.dart | 13 - .../lib/src/license_collector.dart | 2 +- .../lib/src/linux/application_package.dart | 3 +- .../lib/src/linux/linux_device.dart | 12 +- .../lib/src/linux/linux_doctor.dart | 19 +- .../lib/src/linux/linux_workflow.dart | 4 +- .../lib/src/localizations/gen_l10n.dart | 59 +- .../lib/src/localizations/gen_l10n_types.dart | 53 +- .../localizations/localizations_utils.dart | 7 +- .../lib/src/macos/application_package.dart | 5 +- .../lib/src/macos/build_macos.dart | 27 +- .../lib/src/macos/cocoapod_utils.dart | 2 + .../lib/src/macos/cocoapods.dart | 14 +- .../lib/src/macos/cocoapods_validator.dart | 2 + .../macos/darwin_dependency_management.dart | 35 +- .../lib/src/macos/macos_device.dart | 20 +- .../lib/src/macos/macos_ipad_device.dart | 25 +- .../lib/src/macos/macos_workflow.dart | 4 +- .../lib/src/macos/swift_package_manager.dart | 182 +- .../lib/src/macos/swift_packages.dart | 28 +- .../flutter_tools/lib/src/macos/xcdevice.dart | 13 +- .../flutter_tools/lib/src/macos/xcode.dart | 33 +- .../lib/src/macos/xcode_validator.dart | 18 +- .../flutter_tools/lib/src/mdns_discovery.dart | 130 +- .../analysis_options_migration.dart | 179 +- .../cmake_native_assets_migration.dart | 4 +- .../migrations/cocoapods_script_symlink.dart | 11 +- ...coapods_toolchain_directory_migration.dart | 11 +- .../src/migrations/lldb_init_migration.dart | 11 +- ...package_manager_integration_migration.dart | 309 +- .../lib/src/migrations/uiscene_migration.dart | 8 +- .../flutter_tools/lib/src/native_assets.dart | 9 + .../lib/src/platform_plugins.dart | 51 +- packages/flutter_tools/lib/src/plugins.dart | 57 +- packages/flutter_tools/lib/src/project.dart | 219 +- .../lib/src/protocol_discovery.dart | 11 +- .../lib/src/proxied_devices/devices.dart | 108 +- .../lib/src/proxy_validator.dart | 2 + .../lib/src/reporting/crash_reporting.dart | 30 +- .../lib/src/reporting/github_template.dart | 18 +- .../lib/src/reporting/unified_analytics.dart | 10 +- .../lib/src/resident_runner.dart | 705 ++-- packages/flutter_tools/lib/src/run_cold.dart | 46 +- packages/flutter_tools/lib/src/run_hot.dart | 309 +- .../lib/src/runner/flutter_command.dart | 1408 +++---- .../src/runner/flutter_command_runner.dart | 349 +- .../lib/src/runner/local_engine.dart | 16 +- .../src/runner/options/common_options.dart | 1285 +++++++ .../lib/src/runner/options/option_bundle.dart | 43 + .../src/runner/options/option_descriptor.dart | 657 ++++ .../src/runner/options/safe_arg_results.dart | 30 + .../lib/src/runner/target_devices.dart | 121 +- packages/flutter_tools/lib/src/template.dart | 28 +- .../lib/src/test/coverage_collector.dart | 51 +- .../lib/src/test/event_printer.dart | 2 +- .../lib/src/test/flutter_platform.dart | 281 +- .../lib/src/test/flutter_tester_device.dart | 3 +- .../lib/src/test/flutter_web_platform.dart | 543 ++- .../flutter_tools/lib/src/test/runner.dart | 483 ++- .../lib/src/test/test_compiler.dart | 98 +- .../lib/src/test/test_golden_comparator.dart | 52 +- .../lib/src/test/web_test_compiler.dart | 107 +- .../lib/src/tester/flutter_tester.dart | 27 +- packages/flutter_tools/lib/src/tracing.dart | 2 +- .../lib/src/update_packages_pins.dart | 9 - packages/flutter_tools/lib/src/version.dart | 160 +- packages/flutter_tools/lib/src/vmservice.dart | 58 +- .../flutter_tools/lib/src/web/bootstrap.dart | 307 +- .../flutter_tools/lib/src/web/chrome.dart | 162 +- .../lib/src/web/chrome_constants.dart | 13 + .../flutter_tools/lib/src/web/compile.dart | 101 +- .../lib/src/web/compiler_config.dart | 40 +- .../lib/src/web/content_hash.dart | 554 +++ .../lib/src/web/devfs_config.dart | 12 +- .../lib/src/web/devfs_proxy.dart | 11 +- .../flutter_tools/lib/src/web/memory_fs.dart | 76 +- .../flutter_tools/lib/src/web/web_device.dart | 49 +- .../lib/src/web/web_options.dart | 404 ++ .../flutter_tools/lib/src/web/web_runner.dart | 27 +- .../lib/src/web/web_validator.dart | 25 +- .../flutter_tools/lib/src/web/workflow.dart | 4 +- .../flutter_tools/lib/src/web_template.dart | 10 +- .../lib/src/widget_preview/dtd_services.dart | 170 +- .../widget_preview/lsp_preview_detector.dart | 75 +- .../preview_code_generator.dart | 50 +- .../src/widget_preview/preview_details.dart | 2 - .../src/widget_preview/preview_detector.dart | 34 +- .../preview_pubspec_builder.dart | 7 +- .../lib/src/widget_preview/utils.dart | 12 - .../lib/src/windows/application_package.dart | 3 +- .../lib/src/windows/visual_studio.dart | 13 +- .../src/windows/visual_studio_validator.dart | 8 +- .../lib/src/windows/windows_device.dart | 17 +- .../windows/windows_version_validator.dart | 16 +- .../lib/src/windows/windows_workflow.dart | 4 +- .../flutter_tools/lib/src/xcode_project.dart | 10 +- .../lib/flutter_tools_core.dart | 15 + .../flutter_tools_core/lib/src/build.dart | 187 + .../flutter_tools_core/lib/src/config.dart | 115 + .../flutter_tools_core/lib/src/device.dart | 160 + .../lib/src/diagnostics.dart | 189 + .../flutter_tools_core/lib/src/templates.dart | 157 + .../packages/flutter_tools_core/pubspec.yaml | 14 + .../flutter_tools_core/test/build_test.dart | 135 + .../flutter_tools_core/test/config_test.dart | 44 + .../flutter_tools_core/test/device_test.dart | 92 + .../test/diagnostics_test.dart | 57 + .../test/templates_test.dart | 82 + .../lib/flutter_tools_extension.dart | 18 + .../lib/src/build.dart | 107 + .../lib/src/config.dart | 58 + .../lib/src/device.dart | 57 + .../lib/src/diagnostics.dart | 43 + .../lib/src/protocol_base/provider.dart | 80 + .../lib/src/protocol_base/service.dart | 65 + .../lib/src/templates.dart | 88 + .../flutter_tools_extension/pubspec.yaml | 15 + ...utter_tools_extension_linux_prototype.dart | 35 + .../lib/src/build.dart | 35 + .../lib/src/config.dart | 39 + .../lib/src/device.dart | 33 + .../lib/src/diagnostics.dart | 30 + .../lib/src/template.dart | 57 + .../.custom_device_extension_info.copy.tmpl | 1 + .../custom-linux-app/lib/main.dart.tmpl | 24 + .../custom-linux-app/pubspec.yaml.tmpl | 19 + .../pubspec.yaml | 18 + .../test/linux_build_test.dart | 158 + .../test/linux_config_test.dart | 29 + .../test/linux_device_test.dart | 57 + .../test/linux_diagnostics_test.dart | 21 + .../test/linux_template_test.dart | 42 + packages/flutter_tools/pubspec.yaml | 88 +- packages/flutter_tools/static/index.html | 1 + .../Scripts/flutter_integration.sh.tmpl | 4 +- .../templates/app/analysis_options.yaml.tmpl | 12 + .../androidIdentifier/Application.kt.tmpl | 47 - .../androidIdentifier/MainActivity.kt.tmpl | 4 +- .../app/src/main/AndroidManifest.xml.tmpl | 2 +- .../app/ios.tmpl/Runner/AppDelegate.swift | 11 +- .../RunnerTests/RunnerTests.swift.tmpl | 22 +- .../templates/app/lib/main.dart.tmpl | 272 ++ .../app/linux.tmpl/CMakeLists.txt.tmpl | 7 +- .../app/linux.tmpl/flutter/CMakeLists.txt | 2 + .../app/linux.tmpl/runner/CMakeLists.txt | 2 +- .../linux.tmpl/runner/my_application.cc.tmpl | 54 +- .../app/linux.tmpl/runner/my_application.h | 4 +- .../Runner.xcodeproj/project.pbxproj.tmpl | 6 +- .../app/macos.tmpl/Runner/AppDelegate.swift | 8 - .../macos.tmpl/Runner/Base.lproj/MainMenu.xib | 16 +- .../macos.tmpl/Runner/MainFlutterWindow.swift | 15 + .../RunnerTests/RunnerTests.swift.tmpl | 18 +- .../templates/app/pubspec.yaml.tmpl | 114 + .../templates/app/web/index.html.tmpl | 12 + .../app/windows.tmpl/CMakeLists.txt.tmpl | 4 +- .../app/windows.tmpl/flutter/CMakeLists.txt | 2 + .../app/windows.tmpl/runner/CMakeLists.txt | 2 + .../windows.tmpl/runner/flutter_window.cpp | 71 + .../app/windows.tmpl/runner/flutter_window.h | 33 + .../app/windows.tmpl/runner/main.cpp.tmpl | 11 +- .../app/windows.tmpl/runner/win32_window.cpp | 288 ++ .../app/windows.tmpl/runner/win32_window.h | 102 + .../test/widget_test.dart.tmpl | 2 +- .../module/common/lib/main.dart.tmpl | 2 +- .../templates/module/common/pubspec.yaml.tmpl | 1 + .../module/common/test/widget_test.dart.tmpl | 2 +- .../templates/package_ffi/pubspec.yaml.tmpl | 6 +- .../templates/plugin/README.md.tmpl | 3 +- .../android-java.tmpl/build.gradle.kts.tmpl | 1 - .../java/androidIdentifier/Messages.java.tmpl | 269 ++ .../androidIdentifier/pluginClass.java.tmpl | 26 +- .../pluginClassTest.java.tmpl | 13 +- .../android-kotlin.tmpl/build.gradle.kts.tmpl | 1 - .../androidIdentifier/Messages.g.kt.tmpl | 90 + .../androidIdentifier/pluginClass.kt.tmpl | 28 +- .../androidIdentifier/pluginClassTest.kt.tmpl | 12 +- .../lib/projectName_method_channel.dart.tmpl | 19 - .../plugin/lib/projectName_pigeon.dart.tmpl | 19 + .../projectName_platform_interface.dart.tmpl | 8 +- .../plugin/lib/src/messages.g.dart.tmpl | 97 + .../plugin/linux.tmpl/CMakeLists.txt.tmpl | 7 +- .../plugin/linux.tmpl/messages.g.cc.tmpl | 433 +++ .../plugin/linux.tmpl/messages.g.h.tmpl | 72 + .../linux.tmpl/pluginClassSnakeCase.cc.tmpl | 53 +- .../pluginClassSnakeCase_private.h.tmpl | 7 +- .../test/pluginClassSnakeCase_test.cc.tmpl | 15 +- .../plugin/pigeon/messages.dart.tmpl | 77 + .../projectName_method_channel_test.dart.tmpl | 26 - .../test/projectName_pigeon_test.dart.tmpl | 35 + .../plugin/test/projectName_test.dart.tmpl | 6 +- .../plugin/windows.tmpl/CMakeLists.txt.tmpl | 2 + .../plugin/windows.tmpl/messages.g.cpp.tmpl | 393 ++ .../plugin/windows.tmpl/messages.g.h.tmpl | 103 + .../pluginClassSnakeCase.cpp.tmpl | 40 +- .../windows.tmpl/pluginClassSnakeCase.h.tmpl | 9 +- .../test/pluginClassSnakeCase_test.cpp.tmpl | 32 +- .../ios.tmpl/Classes/Messages.g.swift.tmpl | 125 + .../ios.tmpl/Classes/pluginClass.swift.tmpl | 14 +- .../macos.tmpl/Classes/Messages.g.swift.tmpl | 125 + .../macos.tmpl/Classes/pluginClass.swift.tmpl | 14 +- .../darwin.tmpl/Classes/Messages.g.swift.tmpl | 125 + .../Classes/pluginClass.swift.tmpl | 34 +- .../projectName.tmpl/Messages.g.swift.tmpl | 125 + .../projectName.tmpl/pluginClass.swift.tmpl | 32 +- .../templates/plugin_shared/pubspec.yaml.tmpl | 6 + .../projectName.tmpl/Messages.g.swift.tmpl | 125 + .../projectName.tmpl/pluginClass.swift.tmpl | 14 +- .../projectName.tmpl/Messages.g.swift.tmpl | 125 + .../projectName.tmpl/pluginClass.swift.tmpl | 14 +- .../templates/template_manifest.json | 19 +- .../lib/src/controls.dart.tmpl | 29 +- .../lib/src/split.dart.tmpl | 2 +- .../lib/src/theme/theme.dart.tmpl | 3 +- .../lib/src/utils.dart.tmpl | 2 +- .../src/widget_preview_rendering.dart.tmpl | 611 ++- .../widget_preview_scaffold/pubspec.yaml.tmpl | 1 + ...roid_dependency_version_checking_test.dart | 24 +- .../hermetic/analysis_server_mock.dart | 210 + .../hermetic/analyze_continuously_test.dart | 478 ++- .../hermetic/analyze_suggestion_test.dart | 105 +- .../commands.shard/hermetic/analyze_test.dart | 362 +- .../hermetic/android_analyze_test.dart | 38 +- .../hermetic/assemble_test.dart | 708 ++-- .../commands.shard/hermetic/attach_test.dart | 534 +-- .../hermetic/build_aar_test.dart | 184 +- .../hermetic/build_apk_test.dart | 226 ++ .../hermetic/build_appbundle_test.dart | 254 ++ .../hermetic/build_bundle_test.dart | 529 +++ .../hermetic/build_darwin_framework_test.dart | 1533 ++++---- .../hermetic/build_ios_test.dart | 853 +---- .../hermetic/build_ipa_export_plist_test.dart | 36 +- .../hermetic/build_ipa_test.dart | 1022 +---- .../hermetic/build_linux_test.dart | 642 +--- .../hermetic/build_macos_test.dart | 290 +- .../hermetic/build_swift_package_test.dart | 932 ++--- .../commands.shard/hermetic/build_test.dart | 246 +- .../hermetic/build_web_test.dart | 1774 +++++---- .../hermetic/build_windows_test.dart | 285 +- .../commands.shard/hermetic/clean_test.dart | 586 +-- .../commands.shard/hermetic/config_test.dart | 599 ++- .../hermetic/create_usage_test.dart | 41 +- .../hermetic/custom_devices_test.dart | 624 ++- .../commands.shard/hermetic/daemon_test.dart | 687 +++- .../hermetic/debug_adapter_test.dart | 55 + .../commands.shard/hermetic/devices_test.dart | 810 ++-- .../commands.shard/hermetic/doctor_test.dart | 839 ++-- .../hermetic/downgrade_test.dart | 249 +- .../commands.shard/hermetic/drive_test.dart | 519 ++- .../hermetic/emulators_test.dart | 383 ++ .../hermetic/flutter_web_platform_test.dart | 147 +- .../hermetic/generate_localizations_test.dart | 599 ++- .../hermetic/generate_test.dart | 35 + .../hermetic/http_host_validator_test.dart | 2 +- .../hermetic/ide_config_test.dart | 94 +- .../commands.shard/hermetic/install_test.dart | 256 +- .../hermetic/ios_analyze_test.dart | 17 +- .../commands.shard/hermetic/logs_test.dart | 196 +- .../hermetic/precache_test.dart | 50 +- .../hermetic/proxied_devices_test.dart | 66 +- .../hermetic/proxy_validator_test.dart | 2 +- .../commands.shard/hermetic/pub_test.dart | 338 +- .../commands.shard/hermetic/run_test.dart | 902 ++++- .../hermetic/screenshot_command_test.dart | 227 +- .../hermetic/shell_completion_test.dart | 141 +- .../hermetic/symbolize_test.dart | 879 ++--- .../commands.shard/hermetic/test_test.dart | 600 ++- .../hermetic/tool_extensions_build_test.dart | 501 +++ .../hermetic/tool_extensions_device_test.dart | 170 + .../tool_extensions_integration_test.dart | 162 + .../hermetic/update_packages_test.dart | 396 +- .../commands.shard/hermetic/upgrade_test.dart | 577 ++- .../widget_preview/dtd_services_test.dart | 311 ++ .../lsp_preview_detector_test.dart | 328 ++ .../preview_code_generator_test.dart | 123 +- ...eview_detector_regression_173895_test.dart | 2 + ...eview_detector_regression_178317_test.dart | 8 + .../preview_detector_test.dart | 11 +- .../preview_detector_workspace_test.dart | 45 +- .../preview_manifest_test.dart | 12 +- .../widget_preview/regress_176018_test.dart | 10 +- .../utils/preview_detector_test_utils.dart | 21 + .../permeable/build_aar_test.dart | 110 +- .../permeable/build_apk_test.dart | 79 +- .../permeable/build_appbundle_test.dart | 71 +- .../permeable/build_bundle_test.dart | 630 --- .../commands.shard/permeable/create_test.dart | 854 +++-- .../permeable/devices_test.dart | 27 +- .../permeable/packages_test.dart | 63 +- .../commands.shard/permeable/script_test.dart | 20 + .../permeable/upgrade_test.dart | 73 +- ...eview_detector_regression_178472_test.dart | 8 + .../widget_preview/widget_preview_test.dart | 407 +- .../android_device_discovery_test.dart | 62 +- .../android/android_device_start_test.dart | 797 +++- .../android/android_device_test.dart | 78 +- .../android/android_emulator_test.dart | 134 +- .../android/android_gradle_builder_test.dart | 345 +- .../android_project_migration_test.dart | 111 +- .../android/android_sdk_test.dart | 964 +++-- .../android/android_studio_test.dart | 2754 ++++++------- .../android/android_workflow_test.dart | 114 +- .../android/build_validation_test.dart | 6 +- ...ed_components_prebuild_validator_test.dart | 268 +- .../android/gradle_errors_test.dart | 1949 +++++----- .../android/gradle_find_bundle_test.dart | 277 +- .../general.shard/android/gradle_test.dart | 3 + .../android/gradle_utils_test.dart | 583 +-- .../test/general.shard/android/java_test.dart | 83 +- .../application_package_test.dart | 335 +- .../test/general.shard/args_test.dart | 188 +- .../general.shard/artifact_updater_test.dart | 3 +- .../test/general.shard/artifacts_test.dart | 81 +- .../asset_bundle_flavors_test.dart | 57 +- .../asset_bundle_package_test.dart | 69 +- .../test/general.shard/asset_bundle_test.dart | 138 +- .../asset_bundle_variant_test.dart | 75 +- .../test/general.shard/asset_test.dart | 156 +- .../general.shard/base/async_guard_test.dart | 9 +- .../general.shard/base/bot_detector_test.dart | 112 +- .../test/general.shard/base/build_test.dart | 154 +- .../general.shard/base/command_help_test.dart | 17 +- .../test/general.shard/base/common_test.dart | 83 + .../base/error_handling_io_test.dart | 238 +- .../test/general.shard/base/io_test.dart | 100 + .../test/general.shard/base/process_test.dart | 251 +- .../test/general.shard/base/signals_test.dart | 31 +- .../general.shard/base/task_queue_test.dart | 110 - .../test/general.shard/base_utils_test.dart | 39 + .../test/general.shard/build_info_test.dart | 184 +- .../build_system/build_system_test.dart | 84 + .../build_system/depfile_test.dart | 59 + .../build_system/targets/android_test.dart | 6 +- .../targets/asset_transformer_test.dart | 449 ++- .../build_system/targets/assets_test.dart | 263 +- .../build_system/targets/common_test.dart | 153 +- .../targets/icon_tree_shaker_test.dart | 114 + .../build_system/targets/ios_test.dart | 86 + .../build_system/targets/linux_test.dart | 12 +- .../build_system/targets/macos_test.dart | 29 + .../targets/shader_compiler_test.dart | 432 ++- .../targets/web_dry_run_test.dart | 243 +- .../build_system/targets/web_test.dart | 1715 ++++++++- .../build_system/targets/windows_test.dart | 17 +- .../general.shard/bundle_builder_test.dart | 43 + .../test/general.shard/cache_test.dart | 238 +- .../test/general.shard/channel_test.dart | 800 ++-- .../test/general.shard/cold_test.dart | 28 +- .../general.shard/commands/build_test.dart | 20 +- .../general.shard/commands/daemon_test.dart | 61 +- .../commands/darwin_add_to_app_test.dart | 13 + .../compile_expression_test.dart | 169 +- .../compile_incremental_test.dart | 161 +- .../test/general.shard/compile_test.dart | 218 +- .../context/dependency_injection_test.dart | 480 +++ .../coverage_collector_test.dart | 136 +- .../general.shard/crash_reporting_test.dart | 24 + .../custom_devices/custom_device_test.dart | 132 +- .../custom_device_workflow_test.dart | 21 +- .../custom_devices_config_test.dart | 43 +- .../test/general.shard/daemon_test.dart | 35 + .../dap/flutter_adapter_test.dart | 186 +- .../dap/flutter_test_adapter_test.dart | 36 + .../test/general.shard/dap/mocks.dart | 22 +- .../dart/language_version_test.dart | 2 +- .../general.shard/dart/package_map_test.dart | 183 +- .../test/general.shard/dart/pub_get_test.dart | 284 +- .../test/general.shard/dart_plugin_test.dart | 205 +- .../general.shard/desktop_device_test.dart | 53 +- .../test/general.shard/devfs_test.dart | 392 +- .../device_port_forwarder_test.dart | 1 + .../test/general.shard/device_test.dart | 616 ++- .../drive/drive_service_test.dart | 62 +- .../drive/import_validator_test.dart | 213 + .../drive/web_driver_service_test.dart | 138 +- .../test/general.shard/emulator_test.dart | 79 +- .../test/general.shard/executable_test.dart | 57 + .../config_service_test.dart | 234 ++ .../diagnostics_service_test.dart | 89 + .../extension_discovery_test.dart | 116 + .../extension_manager_test.dart | 57 + .../template_service_test.dart | 167 + .../test/general.shard/features_test.dart | 48 + .../general.shard/flutter_platform_test.dart | 408 +- .../flutter_project_metadata_test.dart | 89 +- .../general.shard/flutter_validator_test.dart | 74 +- .../generate_localizations_test.dart | 327 +- .../test/general.shard/hot_shared.dart | 20 +- .../test/general.shard/hot_test.dart | 71 +- .../integration_test_device_test.dart | 1 + .../general.shard/intellij/intellij_test.dart | 234 -- .../general.shard/ios/code_signing_test.dart | 215 +- .../general.shard/ios/core_devices_test.dart | 206 +- .../ios/device_support_test.dart | 609 ++- .../test/general.shard/ios/devices_test.dart | 119 +- .../general.shard/ios/ios_deploy_test.dart | 21 +- .../ios/ios_device_install_test.dart | 4 +- .../ios/ios_device_logger_test.dart | 209 +- .../ios/ios_device_project_test.dart | 1 + .../ios_device_start_nonprebuilt_test.dart | 126 +- .../ios/ios_device_start_prebuilt_test.dart | 280 +- .../test/general.shard/ios/iproxy_test.dart | 65 + .../test/general.shard/ios/lldb_test.dart | 662 +++- .../test/general.shard/ios/mac_test.dart | 283 +- .../general.shard/ios/simulators_test.dart | 198 +- .../general.shard/ios/xcode_debug_test.dart | 297 +- .../general.shard/ios/xcodeproj_test.dart | 247 +- .../test/general.shard/ios/xcresult_test.dart | 27 +- .../isolated/android/native_assets_test.dart | 6 +- .../targets/native_assets_test.dart | 63 + .../isolated/ios/native_assets_test.dart | 12 +- .../isolated/linux/native_assets_test.dart | 278 +- .../macos/native_assets_host_test.dart | 148 +- .../isolated/macos/native_assets_test.dart | 14 +- .../isolated/native_assets_manifest_test.dart | 74 + .../isolated/native_assets_test.dart | 75 + .../isolated/resident_runner_test.dart | 8 +- .../isolated/windows/native_assets_test.dart | 7 +- .../linux/build_linux_flavor_test.dart | 23 +- .../linux/linux_device_test.dart | 3 +- .../linux/linux_doctor_test.dart | 14 +- .../macos/cocoapod_utils_test.dart | 33 + .../general.shard/macos/cocoapods_test.dart | 97 +- .../macos/cocoapods_validator_test.dart | 2 +- .../darwin_dependency_management_test.dart | 407 +- .../macos/swift_package_manager_test.dart | 206 +- .../test/general.shard/macos/xcode_test.dart | 89 +- .../macos/xcode_validator_test.dart | 9 +- .../general.shard/mdns_discovery_test.dart | 206 +- .../general.shard/message_parser_test.dart | 1 + .../analysis_options_migration_test.dart | 374 +- .../cmake_project_migration_test.dart | 4 +- .../migrations/lldb_init_migration_test.dart | 65 +- ...ge_manager_integration_migration_test.dart | 1470 ++++++- .../test/general.shard/plugins_test.dart | 652 +++- .../project_file_invalidator_test.dart | 80 + .../test/general.shard/project_test.dart | 485 ++- .../proxied_devices/proxied_devices_test.dart | 87 + .../general.shard/pub_workspace_test.dart | 58 + .../resident_runner_helpers.dart | 160 +- .../general.shard/resident_runner_test.dart | 178 +- .../resident_web_runner_cold_test.dart | 78 +- .../resident_web_runner_test.dart | 986 +++-- .../test/general.shard/run_hot_test.dart | 20 + .../runner/flutter_command_runner_test.dart | 441 ++- .../runner/flutter_command_test.dart | 370 +- .../runner/local_engine_test.dart | 3 +- .../build_info_options_bundle_test.dart | 55 + .../options/option_descriptor_test.dart | 930 +++++ .../general.shard/runner/runner_test.dart | 285 +- .../runner/target_devices_test.dart | 371 +- .../test/general.shard/runner/utils.dart | 1 + .../test/general.shard/template_test.dart | 37 +- .../general.shard/terminal_handler_test.dart | 130 +- .../test/test_compiler_test.dart | 28 +- .../test/test_golden_comparator_test.dart | 187 +- .../test/web_test_compiler_test.dart | 47 +- .../tester/flutter_tester_test.dart | 2 + .../test/general.shard/time_test.dart | 18 - .../test/general.shard/version_test.dart | 374 +- .../test/general.shard/vmservice_test.dart | 280 +- .../general.shard/web/bootstrap_test.dart | 357 +- .../general.shard/web/compile_web_test.dart | 35 +- .../web/devfs_web_ddc_modules_test.dart | 1338 ------- .../general.shard/web/devfs_web_test.dart | 3412 +++++++---------- .../test/general.shard/web/devices_test.dart | 5 +- .../general.shard/web/https_config_test.dart | 3 +- .../general.shard/web/memory_fs_test.dart | 39 + ...crub_generated_plugin_registrant_test.dart | 126 +- .../test/general.shard/web/proxy_test.dart | 1 + .../web/web_asset_server_test.dart | 475 ++- .../general.shard/web/web_validator_test.dart | 2 +- .../windows/build_windows_flavor_test.dart | 65 +- .../windows/visual_studio_test.dart | 50 +- .../windows/visual_studio_validator_test.dart | 46 +- .../windows_version_validator_test.dart | 34 +- .../general.shard/xcode_project_test.dart | 32 - .../integration.shard/analyze_once_test.dart | 62 +- .../analyze_suggestions_integration_test.dart | 15 +- .../android_gradle_asset_merging_test.dart | 380 ++ ...tter_plugins_strings_in_comments_test.dart | 4 +- ...gradle_outputs_app_link_settings_test.dart | 195 +- ..._run_flutter_gradle_plugin_tests_test.dart | 50 +- .../build_linux_config_only_test.dart | 73 + .../command_output_test.dart | 68 +- .../debug_adapter/flutter_adapter_test.dart | 2 +- .../debug_adapter/test_adapter_test.dart | 17 +- .../debug_adapter/test_client.dart | 17 +- .../debug_adapter/test_server.dart | 2 +- .../debug_adapter/test_support.dart | 9 +- ...rred_components_assets_reproduce_test.dart | 67 +- .../integration.shard/dependencies_test.dart | 1 + .../disable_service_origin_check_test.dart | 10 +- .../flutter_build_apk_split_per_abi_test.dart | 67 +- .../flutter_plugins_dependencies_test.dart | 219 +- .../gradle_jni_packaging_test.dart | 14 +- .../gradle_libapp_so_packaging_test.dart | 66 +- .../hot_reload_non_ascii_path_test.dart | 39 + .../ios_content_validation_test.dart | 33 +- .../dart_data_asset_flutter_build_test.dart | 6 +- .../dart_data_asset_flutter_test_test.dart | 32 + .../native_assets_agp_version_test.dart | 2 +- .../native_assets_flutter_build_test.dart | 74 +- .../native_assets_flutter_run_test.dart | 110 +- .../isolated/native_assets_test_utils.dart | 200 +- .../record_use_flutter_build_test.dart | 6 +- .../overall_experience_test.dart | 10 +- .../integration.shard/plist_parser_test.dart | 20 +- .../shader_compiler_test.dart | 2 + .../swift_package_manager_add2app_test.dart | 27 +- .../swift_package_manager_utils.dart | 11 +- .../template_manifest_test.dart | 7 +- .../deferred_components_project.dart | 20 +- .../test_data/hot_reload_errors_common.dart | 39 +- .../test_data/listen_for_sigterm.dart | 1 + .../test_data/plugin_project.dart | 4 +- .../test/integration.shard/test_driver.dart | 16 +- .../test_golden_comparator_test.dart | 9 +- .../test/integration.shard/test_test.dart | 189 +- .../test/integration.shard/test_utils.dart | 5 +- .../integration.shard/tool_backend_test.dart | 3 +- .../tool_extensions_test.dart | 193 + .../variable_expansion_windows_test.dart | 11 +- .../web_plugin_registrant_test.dart | 18 +- .../widget_preview_test_helpers.dart | 51 +- .../workspace_pub_get_test.dart | 129 + .../integration.shard/xcode_backend_test.dart | 75 +- .../test/src/android_common.dart | 2 +- packages/flutter_tools/test/src/common.dart | 10 + packages/flutter_tools/test/src/context.dart | 37 +- .../test/src/fake_build_command.dart | 184 + .../flutter_tools/test/src/fake_devices.dart | 41 +- .../test/src/fake_process_manager.dart | 22 +- .../test/src/fake_vm_services.dart | 6 +- .../test/src/fake_web_devfs.dart | 174 + packages/flutter_tools/test/src/fakes.dart | 756 +++- .../flutter_tools/test/src/fs_safety.dart | 35 +- .../test/src/package_config.dart | 1 + .../test/src/test_flutter_command_runner.dart | 73 +- packages/flutter_tools/test/src/testbed.dart | 4 +- .../test/web.shard/chrome_test.dart | 221 +- .../web.shard/hot_restart_chrome_test.dart | 90 +- .../hot_restart_chrome_test_common.dart | 119 - .../test_data/web_server_test_common.dart | 1 + .../web.shard/web_driver_service_test.dart | 32 +- .../flutter_tools/tool/daemon_client.dart | 4 +- packages/flutter_tools/tool/global_count.dart | 1 + 787 files changed, 78903 insertions(+), 40204 deletions(-) create mode 100644 packages/flutter_tools/gradle/AGENTS.md create mode 100644 packages/flutter_tools/gradle/src/main/kotlin/CompileSdkVersion.kt create mode 100644 packages/flutter_tools/gradle/src/main/kotlin/DslVersionCodes.kt create mode 100644 packages/flutter_tools/gradle/src/main/kotlin/FlutterCompileOptions.kt create mode 100644 packages/flutter_tools/gradle/src/main/kotlin/tasks/CopyFlutterApksTask.kt create mode 100644 packages/flutter_tools/gradle/src/main/kotlin/tasks/CopyFlutterAssetsTask.kt create mode 100644 packages/flutter_tools/gradle/src/main/kotlin/tasks/GenerateEngineFlagsManifestTask.kt create mode 100644 packages/flutter_tools/gradle/src/test/kotlin/CompileSdkVersionTest.kt create mode 100644 packages/flutter_tools/gradle/src/test/kotlin/DslVersionCodesTest.kt create mode 100644 packages/flutter_tools/gradle/src/test/kotlin/FlutterCompileOptionsTest.kt create mode 100644 packages/flutter_tools/gradle/src/test/kotlin/tasks/CopyFlutterApksTaskTest.kt create mode 100644 packages/flutter_tools/gradle/src/test/kotlin/tasks/CopyFlutterAssetsTaskTest.kt create mode 100644 packages/flutter_tools/gradle/src/test/kotlin/tasks/GenerateEngineFlagsManifestTaskTest.kt create mode 100644 packages/flutter_tools/gradle/src/test/kotlin/testing/AndroidExtensionMocks.kt create mode 100644 packages/flutter_tools/gradle/src/test/kotlin/testing/FilterConfigurationMocks.kt delete mode 100644 packages/flutter_tools/gradle/src/test/kotlin/testing/VersionFetcherTestHelper.kt create mode 100644 packages/flutter_tools/gradle/src/test/kotlin/validation/BytecodeValidatorTest.kt create mode 100644 packages/flutter_tools/gradle/src/test/kotlin/validation/InternalAgpApiImportTest.kt create mode 100644 packages/flutter_tools/gradle/src/test/kotlin/validation/README.md create mode 100644 packages/flutter_tools/lib/src/android/android_engine_cli_flags.dart delete mode 100644 packages/flutter_tools/lib/src/base/task_queue.dart create mode 100644 packages/flutter_tools/lib/src/context/android_context.dart create mode 100644 packages/flutter_tools/lib/src/context/apple_context.dart create mode 100644 packages/flutter_tools/lib/src/context/tool_context.dart create mode 100644 packages/flutter_tools/lib/src/context/tool_dependencies.dart create mode 100644 packages/flutter_tools/lib/src/drive/import_validator.dart create mode 100644 packages/flutter_tools/lib/src/experimental/config.dart create mode 100644 packages/flutter_tools/lib/src/experimental/diagnostics.dart create mode 100644 packages/flutter_tools/lib/src/experimental/extension_arg_parser.dart create mode 100644 packages/flutter_tools/lib/src/experimental/extension_build_manager.dart create mode 100644 packages/flutter_tools/lib/src/experimental/extension_device_manager.dart create mode 100644 packages/flutter_tools/lib/src/experimental/extension_discovery.dart create mode 100644 packages/flutter_tools/lib/src/experimental/extension_manager.dart create mode 100644 packages/flutter_tools/lib/src/experimental/templates.dart delete mode 100644 packages/flutter_tools/lib/src/intellij/intellij.dart create mode 100644 packages/flutter_tools/lib/src/isolated/native_assets/native_assets_manifest.dart create mode 100644 packages/flutter_tools/lib/src/runner/options/common_options.dart create mode 100644 packages/flutter_tools/lib/src/runner/options/option_bundle.dart create mode 100644 packages/flutter_tools/lib/src/runner/options/option_descriptor.dart create mode 100644 packages/flutter_tools/lib/src/runner/options/safe_arg_results.dart create mode 100644 packages/flutter_tools/lib/src/web/chrome_constants.dart create mode 100644 packages/flutter_tools/lib/src/web/content_hash.dart create mode 100644 packages/flutter_tools/lib/src/web/web_options.dart create mode 100644 packages/flutter_tools/packages/flutter_tools_core/lib/flutter_tools_core.dart create mode 100644 packages/flutter_tools/packages/flutter_tools_core/lib/src/build.dart create mode 100644 packages/flutter_tools/packages/flutter_tools_core/lib/src/config.dart create mode 100644 packages/flutter_tools/packages/flutter_tools_core/lib/src/device.dart create mode 100644 packages/flutter_tools/packages/flutter_tools_core/lib/src/diagnostics.dart create mode 100644 packages/flutter_tools/packages/flutter_tools_core/lib/src/templates.dart create mode 100644 packages/flutter_tools/packages/flutter_tools_core/pubspec.yaml create mode 100644 packages/flutter_tools/packages/flutter_tools_core/test/build_test.dart create mode 100644 packages/flutter_tools/packages/flutter_tools_core/test/config_test.dart create mode 100644 packages/flutter_tools/packages/flutter_tools_core/test/device_test.dart create mode 100644 packages/flutter_tools/packages/flutter_tools_core/test/diagnostics_test.dart create mode 100644 packages/flutter_tools/packages/flutter_tools_core/test/templates_test.dart create mode 100644 packages/flutter_tools/packages/flutter_tools_extension/lib/flutter_tools_extension.dart create mode 100644 packages/flutter_tools/packages/flutter_tools_extension/lib/src/build.dart create mode 100644 packages/flutter_tools/packages/flutter_tools_extension/lib/src/config.dart create mode 100644 packages/flutter_tools/packages/flutter_tools_extension/lib/src/device.dart create mode 100644 packages/flutter_tools/packages/flutter_tools_extension/lib/src/diagnostics.dart create mode 100644 packages/flutter_tools/packages/flutter_tools_extension/lib/src/protocol_base/provider.dart create mode 100644 packages/flutter_tools/packages/flutter_tools_extension/lib/src/protocol_base/service.dart create mode 100644 packages/flutter_tools/packages/flutter_tools_extension/lib/src/templates.dart create mode 100644 packages/flutter_tools/packages/flutter_tools_extension/pubspec.yaml create mode 100644 packages/flutter_tools/packages/flutter_tools_extension_linux_prototype/lib/flutter_tools_extension_linux_prototype.dart create mode 100644 packages/flutter_tools/packages/flutter_tools_extension_linux_prototype/lib/src/build.dart create mode 100644 packages/flutter_tools/packages/flutter_tools_extension_linux_prototype/lib/src/config.dart create mode 100644 packages/flutter_tools/packages/flutter_tools_extension_linux_prototype/lib/src/device.dart create mode 100644 packages/flutter_tools/packages/flutter_tools_extension_linux_prototype/lib/src/diagnostics.dart create mode 100644 packages/flutter_tools/packages/flutter_tools_extension_linux_prototype/lib/src/template.dart create mode 100644 packages/flutter_tools/packages/flutter_tools_extension_linux_prototype/lib/templates/custom-linux-app/.custom_device_extension_info.copy.tmpl create mode 100644 packages/flutter_tools/packages/flutter_tools_extension_linux_prototype/lib/templates/custom-linux-app/lib/main.dart.tmpl create mode 100644 packages/flutter_tools/packages/flutter_tools_extension_linux_prototype/lib/templates/custom-linux-app/pubspec.yaml.tmpl create mode 100644 packages/flutter_tools/packages/flutter_tools_extension_linux_prototype/pubspec.yaml create mode 100644 packages/flutter_tools/packages/flutter_tools_extension_linux_prototype/test/linux_build_test.dart create mode 100644 packages/flutter_tools/packages/flutter_tools_extension_linux_prototype/test/linux_config_test.dart create mode 100644 packages/flutter_tools/packages/flutter_tools_extension_linux_prototype/test/linux_device_test.dart create mode 100644 packages/flutter_tools/packages/flutter_tools_extension_linux_prototype/test/linux_diagnostics_test.dart create mode 100644 packages/flutter_tools/packages/flutter_tools_extension_linux_prototype/test/linux_template_test.dart delete mode 100644 packages/flutter_tools/templates/app/android-kotlin.tmpl/app/src/main/kotlin/androidIdentifier/Application.kt.tmpl create mode 100644 packages/flutter_tools/templates/app/macos.tmpl/Runner/MainFlutterWindow.swift create mode 100644 packages/flutter_tools/templates/app/windows.tmpl/runner/flutter_window.cpp create mode 100644 packages/flutter_tools/templates/app/windows.tmpl/runner/flutter_window.h create mode 100644 packages/flutter_tools/templates/app/windows.tmpl/runner/win32_window.cpp create mode 100644 packages/flutter_tools/templates/app/windows.tmpl/runner/win32_window.h create mode 100644 packages/flutter_tools/templates/plugin/android-java.tmpl/src/main/java/androidIdentifier/Messages.java.tmpl create mode 100644 packages/flutter_tools/templates/plugin/android-kotlin.tmpl/src/main/kotlin/androidIdentifier/Messages.g.kt.tmpl delete mode 100644 packages/flutter_tools/templates/plugin/lib/projectName_method_channel.dart.tmpl create mode 100644 packages/flutter_tools/templates/plugin/lib/projectName_pigeon.dart.tmpl create mode 100644 packages/flutter_tools/templates/plugin/lib/src/messages.g.dart.tmpl create mode 100644 packages/flutter_tools/templates/plugin/linux.tmpl/messages.g.cc.tmpl create mode 100644 packages/flutter_tools/templates/plugin/linux.tmpl/messages.g.h.tmpl create mode 100644 packages/flutter_tools/templates/plugin/pigeon/messages.dart.tmpl delete mode 100644 packages/flutter_tools/templates/plugin/test/projectName_method_channel_test.dart.tmpl create mode 100644 packages/flutter_tools/templates/plugin/test/projectName_pigeon_test.dart.tmpl create mode 100644 packages/flutter_tools/templates/plugin/windows.tmpl/messages.g.cpp.tmpl create mode 100644 packages/flutter_tools/templates/plugin/windows.tmpl/messages.g.h.tmpl create mode 100644 packages/flutter_tools/templates/plugin_cocoapods/ios.tmpl/Classes/Messages.g.swift.tmpl create mode 100644 packages/flutter_tools/templates/plugin_cocoapods/macos.tmpl/Classes/Messages.g.swift.tmpl create mode 100644 packages/flutter_tools/templates/plugin_darwin_cocoapods/darwin.tmpl/Classes/Messages.g.swift.tmpl create mode 100644 packages/flutter_tools/templates/plugin_darwin_spm/darwin.tmpl/projectName.tmpl/Sources/projectName.tmpl/Messages.g.swift.tmpl create mode 100644 packages/flutter_tools/templates/plugin_swift_package_manager/ios.tmpl/projectName.tmpl/Sources/projectName.tmpl/Messages.g.swift.tmpl create mode 100644 packages/flutter_tools/templates/plugin_swift_package_manager/macos.tmpl/projectName.tmpl/Sources/projectName.tmpl/Messages.g.swift.tmpl create mode 100644 packages/flutter_tools/test/commands.shard/hermetic/analysis_server_mock.dart create mode 100644 packages/flutter_tools/test/commands.shard/hermetic/build_apk_test.dart create mode 100644 packages/flutter_tools/test/commands.shard/hermetic/build_appbundle_test.dart create mode 100644 packages/flutter_tools/test/commands.shard/hermetic/build_bundle_test.dart create mode 100644 packages/flutter_tools/test/commands.shard/hermetic/debug_adapter_test.dart create mode 100644 packages/flutter_tools/test/commands.shard/hermetic/emulators_test.dart create mode 100644 packages/flutter_tools/test/commands.shard/hermetic/generate_test.dart create mode 100644 packages/flutter_tools/test/commands.shard/hermetic/tool_extensions_build_test.dart create mode 100644 packages/flutter_tools/test/commands.shard/hermetic/tool_extensions_device_test.dart create mode 100644 packages/flutter_tools/test/commands.shard/hermetic/tool_extensions_integration_test.dart create mode 100644 packages/flutter_tools/test/commands.shard/hermetic/widget_preview/dtd_services_test.dart create mode 100644 packages/flutter_tools/test/commands.shard/hermetic/widget_preview/lsp_preview_detector_test.dart delete mode 100644 packages/flutter_tools/test/commands.shard/permeable/build_bundle_test.dart delete mode 100644 packages/flutter_tools/test/general.shard/base/task_queue_test.dart create mode 100644 packages/flutter_tools/test/general.shard/context/dependency_injection_test.dart create mode 100644 packages/flutter_tools/test/general.shard/drive/import_validator_test.dart create mode 100644 packages/flutter_tools/test/general.shard/executable_test.dart create mode 100644 packages/flutter_tools/test/general.shard/extension_protocol/config_service_test.dart create mode 100644 packages/flutter_tools/test/general.shard/extension_protocol/diagnostics_service_test.dart create mode 100644 packages/flutter_tools/test/general.shard/extension_protocol/extension_discovery_test.dart create mode 100644 packages/flutter_tools/test/general.shard/extension_protocol/extension_manager_test.dart create mode 100644 packages/flutter_tools/test/general.shard/extension_protocol/template_service_test.dart delete mode 100644 packages/flutter_tools/test/general.shard/intellij/intellij_test.dart create mode 100644 packages/flutter_tools/test/general.shard/ios/iproxy_test.dart create mode 100644 packages/flutter_tools/test/general.shard/isolated/native_assets_manifest_test.dart create mode 100644 packages/flutter_tools/test/general.shard/pub_workspace_test.dart create mode 100644 packages/flutter_tools/test/general.shard/runner/options/build_info_options_bundle_test.dart create mode 100644 packages/flutter_tools/test/general.shard/runner/options/option_descriptor_test.dart delete mode 100644 packages/flutter_tools/test/general.shard/web/devfs_web_ddc_modules_test.dart create mode 100644 packages/flutter_tools/test/integration.shard/android_gradle_asset_merging_test.dart create mode 100644 packages/flutter_tools/test/integration.shard/hot_reload_non_ascii_path_test.dart create mode 100644 packages/flutter_tools/test/integration.shard/isolated/dart_data_asset_flutter_test_test.dart create mode 100644 packages/flutter_tools/test/integration.shard/tool_extensions_test.dart create mode 100644 packages/flutter_tools/test/integration.shard/workspace_pub_get_test.dart create mode 100644 packages/flutter_tools/test/src/fake_build_command.dart create mode 100644 packages/flutter_tools/test/src/fake_web_devfs.dart delete mode 100644 packages/flutter_tools/test/web.shard/test_data/hot_restart_chrome_test_common.dart diff --git a/packages/flutter_tools/analysis_options.yaml b/packages/flutter_tools/analysis_options.yaml index 71e95192..d50dacdc 100644 --- a/packages/flutter_tools/analysis_options.yaml +++ b/packages/flutter_tools/analysis_options.yaml @@ -9,6 +9,14 @@ analyzer: - linux/** include: ../analysis_options.yaml +plugins: + flutter_analyzer_plugin: + path: ../../dev/flutter_analyzer_plugin + diagnostics: + avoid_future_catch_error: true + dependency_overrides: + analysis_server_plugin: ^0.3.22 + linter: rules: avoid_catches_without_on_clauses: true @@ -21,3 +29,4 @@ linter: public_member_api_docs: false # Tool does not have any public API. unawaited_futures: true unintended_html_in_doc_comment: true + use_null_aware_elements: true diff --git a/packages/flutter_tools/bin/fuchsia_tester.dart b/packages/flutter_tools/bin/fuchsia_tester.dart index 411e63bb..4107a0ad 100644 --- a/packages/flutter_tools/bin/fuchsia_tester.dart +++ b/packages/flutter_tools/bin/fuchsia_tester.dart @@ -14,6 +14,7 @@ import 'package:flutter_tools/src/base/file_system.dart'; import 'package:flutter_tools/src/base/io.dart'; import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/cache.dart'; +import 'package:flutter_tools/src/context/tool_dependencies.dart'; import 'package:flutter_tools/src/context_runner.dart'; import 'package:flutter_tools/src/device.dart'; import 'package:flutter_tools/src/globals.dart' as globals; @@ -116,6 +117,14 @@ Future run(List args) async { // TODO(tvolkert): Remove once flutter_tester no longer looks for this. globals.fs.link(sdkRootDest.childFile('platform.dill').path).createSync('platform_strong.dill'); + final ToolDependencies dependencies = await ToolDependencies.bootstrap( + artifacts: globals.artifacts, + fs: globals.fs, + logger: globals.logger, + platform: globals.platform, + processManager: globals.processManager, + ); + Directory? testDirectory; CoverageCollector? collector; if (argResults['coverage'] as bool? ?? false) { @@ -128,9 +137,10 @@ Future run(List args) async { globals.fs.path.absolute(argResults[_kOptionPackages] as String), ); collector = CoverageCollector( - packagesPath: packagesPath, libraryNames: libraryNames, + packagesPath: packagesPath, resolver: await CoverageCollector.getResolver(packagesPath), + toolContext: dependencies.toolContext, ); if (!argResults.options.contains(_kOptionTestDirectory)) { throwToolExit('Use of --coverage requires setting --test-directory'); @@ -158,18 +168,22 @@ Future run(List args) async { globals.fs.path.absolute(argResults[_kOptionPackages] as String), ), ); - exitCode = await const FlutterTestRunner().runTests( + final debuggingOptions = DebuggingOptions.enabled(buildInfo); + final testRunner = FlutterTestRunner(toolContext: dependencies.toolContext); + exitCode = await testRunner.runTests( const TestWrapper(), tests.keys.map(Uri.file).toList(), - debuggingOptions: DebuggingOptions.enabled(buildInfo), - buildInfo: buildInfo, - watcher: collector, - enableVmService: collector != null, - precompiledDillFiles: tests, - concurrency: math.max(1, globals.platform.numberOfProcessors - 2), - icudtlPath: globals.fs.path.absolute(argResults[_kOptionIcudtl] as String), - coverageDirectory: coverageDirectory, - nativeAssetsBuilder: const TestCompilerNativeAssetsBuilderImpl(), + TestExecutionSpecification( + debuggingOptions: debuggingOptions, + buildInfo: buildInfo, + watcher: collector, + enableVmService: collector != null, + precompiledDillFiles: tests, + concurrency: math.max(1, globals.platform.numberOfProcessors - 2), + icudtlPath: globals.fs.path.absolute(argResults[_kOptionIcudtl] as String), + coverageDirectory: coverageDirectory, + nativeAssetsBuilder: const TestCompilerNativeAssetsBuilderImpl(), + ), ); if (collector != null) { diff --git a/packages/flutter_tools/bin/podhelper.rb b/packages/flutter_tools/bin/podhelper.rb index e09c48df..f7308a6f 100644 --- a/packages/flutter_tools/bin/podhelper.rb +++ b/packages/flutter_tools/bin/podhelper.rb @@ -82,6 +82,13 @@ def flutter_additional_ios_build_settings(target) # ARC code targeting iOS 8 does not build on Xcode 14.3. Force to at least iOS 9. build_configuration.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '9.0' if force_to_arc_supported_min + # Suppress warning/error when pod supports a version lower than the minimum supported by Xcode. + # Xcode 27+ produces an error while previous versions produced a warning. + # When it's just a warning, it's harmless but confusing--it's not a bad thing for dependencies to support a lower version. + # When deleted, the deployment version will inherit from the higher version derived from the 'Runner' target. + # If the pod only supports a higher version, do not delete to correctly produce an error. + build_configuration.build_settings.delete 'IPHONEOS_DEPLOYMENT_TARGET' if inherit_deployment_target + # Skip other updates if it does not depend on Flutter (including transitive dependency) next unless depends_on_flutter(target, 'Flutter') @@ -103,11 +110,6 @@ def flutter_additional_ios_build_settings(target) build_configuration.build_settings['OTHER_LDFLAGS'] = '$(inherited) -framework Flutter' build_configuration.build_settings['CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER'] = 'NO' - # Suppress warning when pod supports a version lower than the minimum supported by Xcode (Xcode 12 - iOS 9). - # This warning is harmless but confusing--it's not a bad thing for dependencies to support a lower version. - # When deleted, the deployment version will inherit from the higher version derived from the 'Runner' target. - # If the pod only supports a higher version, do not delete to correctly produce an error. - build_configuration.build_settings.delete 'IPHONEOS_DEPLOYMENT_TARGET' if inherit_deployment_target # Override legacy Xcode 11 style VALID_ARCHS[sdk=iphonesimulator*]=x86_64 and prefer Xcode 12 EXCLUDED_ARCHS. build_configuration.build_settings['VALID_ARCHS[sdk=iphonesimulator*]'] = '$(ARCHS_STANDARD)' @@ -128,8 +130,9 @@ def flutter_additional_macos_build_settings(target) (deployment_target_major.to_i < 10) || (deployment_target_major.to_i == 10 && deployment_target_minor.to_i < 11) - # Suppress warning when pod supports a version lower than the minimum supported by the latest stable version of Xcode (currently 12.0). - # This warning is harmless but confusing--it's not a bad thing for dependencies to support a lower version. + # Suppress warning/error when pod supports a version lower than the minimum supported by the latest stable version of Xcode (currently 12.0). + # Xcode 27+ produces an error while previous versions produced a warning. + # When it's just a warning, it's harmless but confusing--it's not a bad thing for dependencies to support a lower version. inherit_deployment_target = !target.deployment_target.blank? && (deployment_target_major.to_i < 12) @@ -152,6 +155,10 @@ def flutter_additional_macos_build_settings(target) # ARC code targeting macOS 10.10 does not build on Xcode 14.3. Force to at least macOS 10.11. build_configuration.build_settings['MACOSX_DEPLOYMENT_TARGET'] = '10.11' if force_to_arc_supported_min + # When deleted, the deployment version will inherit from the higher version derived from the 'Runner' target. + # If the pod only supports a higher version, do not delete to correctly produce an error. + build_configuration.build_settings.delete 'MACOSX_DEPLOYMENT_TARGET' if inherit_deployment_target + # Skip other updates if it does not depend on Flutter (including transitive dependency) next unless depends_on_flutter(target, 'FlutterMacOS') @@ -167,10 +174,6 @@ def flutter_additional_macos_build_settings(target) end end - # When deleted, the deployment version will inherit from the higher version derived from the 'Runner' target. - # If the pod only supports a higher version, do not delete to correctly produce an error. - build_configuration.build_settings.delete 'MACOSX_DEPLOYMENT_TARGET' if inherit_deployment_target - # Avoid error about Pods-Runner not supporting provisioning profiles. # Framework signing is handled at the app layer, not per framework, so disallow individual signing. build_configuration.build_settings.delete 'EXPANDED_CODE_SIGN_IDENTITY' diff --git a/packages/flutter_tools/bin/tool_backend.dart b/packages/flutter_tools/bin/tool_backend.dart index fb62e172..c2173d66 100644 --- a/packages/flutter_tools/bin/tool_backend.dart +++ b/packages/flutter_tools/bin/tool_backend.dart @@ -26,6 +26,8 @@ Future main(List arguments) async { final String? localEngineHost = Platform.environment['LOCAL_ENGINE_HOST']; final String? projectDirectory = Platform.environment['PROJECT_DIR']; final String? splitDebugInfo = Platform.environment['SPLIT_DEBUG_INFO']; + final String? buildName = Platform.environment['BUILD_NAME']; + final String? buildNumber = Platform.environment['BUILD_NUMBER']; final trackWidgetCreation = Platform.environment['TRACK_WIDGET_CREATION'] == 'true'; final treeShakeIcons = Platform.environment['TREE_SHAKE_ICONS'] == 'true'; final verbose = Platform.environment['VERBOSE_SCRIPT_LOGGING'] == 'true'; @@ -98,6 +100,8 @@ or if (codeSizeDirectory != null) '-dCodeSizeDirectory=$codeSizeDirectory', if (flavor != null && flavor.isNotEmpty) '-dFlavor=$flavor', if (splitDebugInfo != null) '-dSplitDebugInfo=$splitDebugInfo', + if (buildName != null) '-dBuildName=$buildName', + if (buildNumber != null) '-dBuildNumber=$buildNumber', if (dartDefines != null) '--DartDefines=$dartDefines', if (extraGenSnapshotOptions != null) '--ExtraGenSnapshotOptions=$extraGenSnapshotOptions', if (frontendServerStarterPath != null) '-dFrontendServerStarterPath=$frontendServerStarterPath', diff --git a/packages/flutter_tools/dart_test.yaml b/packages/flutter_tools/dart_test.yaml index c4e8817b..469d57d8 100644 --- a/packages/flutter_tools/dart_test.yaml +++ b/packages/flutter_tools/dart_test.yaml @@ -12,11 +12,6 @@ timeout: 15m tags: - # This tag tells the test framework to not shuffle the test order according to - # the --test-randomize-ordering-seed for the suites that have this tag. - no-shuffle: - allow_test_randomization: false - # Tests that invoke `flutter build apk` for integration. flutter-build-apk: {} diff --git a/packages/flutter_tools/gradle/AGENTS.md b/packages/flutter_tools/gradle/AGENTS.md new file mode 100644 index 00000000..058dc38d --- /dev/null +++ b/packages/flutter_tools/gradle/AGENTS.md @@ -0,0 +1,40 @@ +# Flutter Gradle Plugin (FGP) Architecture & Development Rules + +## Scope & Legacy Code Policy (The Ratchet Principle) +- **New Code & Modified Lines**: All newly introduced tasks, properties, build logic, and modified lines must strictly comply with these rules. +- **No Automatic Legacy Refactoring**: Pre-existing code that violates target architectural guidelines (such as Worker API adoption, configuration-cache compliance, or legacy mock boilerplate) should **not** be automatically refactored within an unrelated PR to prevent scope explosion, high review burden, and regression risks. +- **Surface Opportunities to the User**: When encountering adjacent legacy violations or cleanup opportunities during development, bring them to the user's attention with a brief rationale rather than silently skipping them or applying unapproved refactors. The user can then decide whether to include a localized cleanup or track it for a follow-up PR. +- **Dedicated Refactoring**: Large-scale migrations of existing tasks to modern Gradle APIs should be planned and executed in dedicated, standalone pull requests. + +--- + +## 1. Zero Pollution of Customer-Facing Build Scripts +- **Consumer Build Isolation**: `build.gradle.kts` is evaluated directly in customer projects via composite builds (`includeBuild`). Keep build scripts minimal with only logic needed to compile and package the plugin. +- **No Contributor Tasks in Build Scripts**: Never register contributor verification tasks, custom lint tasks, or lifecycle hooks (`check.dependsOn`, `test.dependsOn`) in customer-evaluated build scripts. +- **Offline Verification**: Implement all contributor assertions, bytecode validations, and ABI checks in unit tests (`src/test/kotlin/`) or CI integration test shards (`packages/flutter_tools/test/integration.shard/`). + +## 2. Type-Safe AGP & Gradle API Usage (No `@Suppress`) +- **No Unsafe Casts or Suppression**: Never use raw wildcard casts (e.g., `as NamedDomainObjectContainer`) or `@Suppress("UNCHECKED_CAST")`. Unchecked casts conceal breaking changes across AGP versions. +- **Use Idiomatic Type-Safe APIs**: Use official, type-safe AGP APIs (e.g., `pluginBuildTypes.create(name) { initWith(source) }`) to manipulate domain objects safely across AGP releases. +- **Structured Compatibility Layers**: When bridging binary- or DSL-incompatible AGP versions (e.g., AGP 8 vs 9), introduce explicit, type-safe abstraction wrappers or reflection bridges. + +## 3. Lazy Configuration & Execution Avoidance +- **Never Eagerly Resolve at Configuration Time**: Never call .get() or .getOrNull() during plugin application or task configuration (afterEvaluate, task creation). Wire inputs and outputs lazily using Property, Provider, ListProperty, MapProperty, DirectoryProperty, and RegularFileProperty. +- **Wire Providers Directly**: Pass providers directly into task inputs (e.g., `task.inputDir.set(extension.path)`). + +## 4. Strict Configuration Cache Compatibility +- **No Project State in Tasks**: Tasks must never hold references to `Project`, `SourceSet`, `Configuration`, or other non-serializable Gradle model objects in fields or action closures. +- **Pass Serializable Inputs**: Inject required values as primitive types, serializable data structures, or Gradle Property instances annotated with @Input, @InputFiles, or @InputDirectory. +- **Use Injected Services**: Use Gradle service injection (e.g., @Inject for FileSystemOperations, ArchiveOperations, or ExecOperations) inside tasks instead of calling Project helper methods. + +## 5. Build Avoidance & Path Normalization +- **Path Normalization**: Annotate all file inputs (such as @InputFile or @InputDirectory) with @PathSensitive(PathSensitivity.RELATIVE) or @PathSensitive(PathSensitivity.NAME_ONLY) (instead of absolute paths) to ensure cache hits across different machines and CI environments. +- **Deterministic Cache Keys**: Never mark non-deterministic inputs (such as timestamps or machine-dependent environment variables) as `@Input`. +- **Explicit Outputs**: Declare `@OutputFile` or `@OutputDirectory` for every produced artifact. + +## 6. Worker API for Heavy Computation +- **Thread & Classloader Isolation**: Offload heavy computation, class parsing, code generation, or external process execution to `WorkerExecutor` / `WorkQueue` (using `noIsolation()`, `classLoaderIsolation()`, or `processIsolation()`) to keep the main Gradle daemon thread non-blocking. + +## 7. Namespace & Environment Hygiene +- **Namespace Internal Properties**: Prefix all internal Gradle properties, project extensions, and system properties with `flutter.internal.` (e.g., `flutter.internal.agpVersion`) to prevent collisions with customer app configurations. +- **Matrix Verification**: Verify compatibility against the full matrix of supported AGP versions (AGP 8.x through 9.x) and Gradle versions. diff --git a/packages/flutter_tools/gradle/README.md b/packages/flutter_tools/gradle/README.md index e6c0349d..c65abc68 100644 --- a/packages/flutter_tools/gradle/README.md +++ b/packages/flutter_tools/gradle/README.md @@ -42,7 +42,7 @@ Sometimes changing a test name and then running it will cause an IDE error. To g to a good state on Mac, run `Help > "Repair IDE"`, and then in the popup window `"Rescan project indexes > Everything works now."` To add a new test, add a class under `src/test/kotlin`, with methods annotated with `@Test`. -These tests will get automatically run on CI by `packages/flutter_tools/test/integration.shard/android_run_flutter_gradle_plugin_tests_test.dart`. +These tests will get automatically run on CI by [`packages/flutter_tools/test/integration.shard/android_run_flutter_gradle_plugin_tests_test.dart`](../test/integration.shard/android_run_flutter_gradle_plugin_tests_test.dart). ### Kotlin Formatting diff --git a/packages/flutter_tools/gradle/build.gradle.kts b/packages/flutter_tools/gradle/build.gradle.kts index dd7341f6..45065e0d 100644 --- a/packages/flutter_tools/gradle/build.gradle.kts +++ b/packages/flutter_tools/gradle/build.gradle.kts @@ -49,26 +49,31 @@ tasks.withType { } } +// The AGP version to compile and test against. Namespaced with `flutter.internal.` to +// avoid collision with user app properties when evaluated as an included build. +val agpVersion: String = providers.gradleProperty("flutter.internal.agpVersion").getOrElse("9.1.1") + dependencies { // Versions available https://mvnrepository.com/artifact/androidx.annotation/annotation-jvm. // Version release notes https://developer.android.com/jetpack/androidx/releases/annotation compileOnly("androidx.annotation:annotation-jvm:1.9.1") // When bumping, also update: // * KGP error version in packages/flutter_tools/gradle/src/main/kotlin/DependencyVersionChecker.kt - implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.0") - // Update to 1.8.0 when min kotlin is 2.1 + implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:2.4.10") + // 1.11.0 is built with Kotlin 2.3.20. Update to 1.12.0 (built with Kotlin 2.4.10) once it is stable. // https://github.com/Kotlin/kotlinx.serialization/releases for kotlin version compatibility. // All kotlinx implementation dependencies must work with the oldest kotlin supported versions. // Defined in packages/flutter_tools/gradle/src/main/kotlin/DependencyVersionChecker.kt - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.0") - // When bumping, also update: + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0") + // When bumping the default agpVersion above, also update: // * AGP version constants in packages/flutter_tools/lib/src/android/gradle_utils.dart // * ndkVersion constant in packages/flutter_tools/lib/src/android/gradle_utils.dart // * ndkVersion in FlutterExtension in packages/flutter_tools/gradle/src/main/kotlin/FlutterExtension.kt - compileOnly("com.android.tools.build:gradle:8.11.1") + compileOnly("com.android.tools.build:gradle:$agpVersion") testImplementation(kotlin("test")) - testImplementation("com.android.tools.build:gradle:8.11.1") - testImplementation("org.mockito:mockito-core:5.8.0") - testImplementation("io.mockk:mockk:1.13.16") + testImplementation("com.android.tools.build:gradle:$agpVersion") + // MockK bundles Byte Buddy for inline mocking. Byte Buddy must support the + // JDK that runs the tests (Java 25 requires Byte Buddy >= 1.17.5). + testImplementation("io.mockk:mockk:1.14.11") } diff --git a/packages/flutter_tools/gradle/src/main/kotlin/AgpCommonExtensionWrapper.kt b/packages/flutter_tools/gradle/src/main/kotlin/AgpCommonExtensionWrapper.kt index 7c12c8cd..53b97e19 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/AgpCommonExtensionWrapper.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/AgpCommonExtensionWrapper.kt @@ -7,6 +7,7 @@ package com.flutter.gradle import com.android.build.api.dsl.ApplicationExtension import com.android.build.api.dsl.BuildType import com.android.build.api.dsl.DynamicFeatureExtension +import com.android.build.api.dsl.ExternalNativeBuild import com.android.build.api.dsl.LibraryExtension import com.android.build.api.dsl.Splits import com.android.build.api.dsl.TestExtension @@ -40,6 +41,25 @@ class AgpCommonExtensionWrapper( } } + var compileSdkPreview: String? + get() = + when (backingExtension) { + is ApplicationExtension -> backingExtension.compileSdkPreview + is LibraryExtension -> backingExtension.compileSdkPreview + is DynamicFeatureExtension -> backingExtension.compileSdkPreview + is TestExtension -> backingExtension.compileSdkPreview + else -> throw IllegalArgumentException(unsupportedMessage()) + } + set(value) { + when (backingExtension) { + is ApplicationExtension -> backingExtension.compileSdkPreview = value + is LibraryExtension -> backingExtension.compileSdkPreview = value + is DynamicFeatureExtension -> backingExtension.compileSdkPreview = value + is TestExtension -> backingExtension.compileSdkPreview = value + else -> throw IllegalArgumentException(unsupportedMessage()) + } + } + var namespace: String? get() = when (backingExtension) { @@ -88,6 +108,16 @@ class AgpCommonExtensionWrapper( else -> throw IllegalArgumentException(unsupportedMessage()) } + val externalNativeBuild: ExternalNativeBuild + get() = + when (backingExtension) { + is ApplicationExtension -> backingExtension.externalNativeBuild + is LibraryExtension -> backingExtension.externalNativeBuild + is DynamicFeatureExtension -> backingExtension.externalNativeBuild + is TestExtension -> backingExtension.externalNativeBuild + else -> throw IllegalArgumentException(unsupportedMessage()) + } + val splits: Splits get() = when (backingExtension) { diff --git a/packages/flutter_tools/gradle/src/main/kotlin/CompileSdkVersion.kt b/packages/flutter_tools/gradle/src/main/kotlin/CompileSdkVersion.kt new file mode 100644 index 00000000..1dd58990 --- /dev/null +++ b/packages/flutter_tools/gradle/src/main/kotlin/CompileSdkVersion.kt @@ -0,0 +1,48 @@ +// Copyright 2014 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. + +package com.flutter.gradle + +/** + * The compile SDK configured on a project's Android extension: either a numeric API level + * (`compileSdk = 36`) or a preview codename (`compileSdkPreview = "Baklava"`). Both are null + * when the DSL has not been configured (yet). + */ +internal data class CompileSdkVersion( + val apiLevel: Int?, + val previewCodename: String? +) { + init { + require(apiLevel == null || previewCodename == null) { + "CompileSdkVersion cannot have both apiLevel ($apiLevel) and previewCodename ($previewCodename) set." + } + } + + /** + * Whether this compile SDK is known to be higher than [other]. + * + * - numeric vs numeric: numeric comparison. + * - preview vs numeric: a preview codename targets an unreleased SDK, so it is + * considered higher than any numeric API level. + * - preview vs preview: codenames stopped being alphabetically ordered when the + * alphabet reset at "Baklava", so distinct codenames are incomparable and this + * returns false rather than guessing. + * - if either side is unset, returns false. + */ + fun isHigherThan(other: CompileSdkVersion): Boolean { + if (other.previewCodename != null) { + return false + } + if (previewCodename != null && other.apiLevel != null) { + return true + } + if (apiLevel != null && other.apiLevel != null) { + return apiLevel > other.apiLevel + } + return false + } + + /** The human-readable form used in log messages, e.g. "35" or "Baklava". */ + override fun toString(): String = previewCodename ?: apiLevel?.toString() ?: "unknown" +} diff --git a/packages/flutter_tools/gradle/src/main/kotlin/DependencyVersionChecker.kt b/packages/flutter_tools/gradle/src/main/kotlin/DependencyVersionChecker.kt index 28bd4c01..1b491847 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/DependencyVersionChecker.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/DependencyVersionChecker.kt @@ -91,22 +91,24 @@ object DependencyVersionChecker { // Advice for maintainers for other areas of code that are impacted are documented // in packages/flutter_tools/lib/src/android/README.md. - @VisibleForTesting internal val warnGradleVersion: Version = Version(9, 1, 0) + @VisibleForTesting internal val warnGradleVersion: Version = Version(9, 5, 0) - @VisibleForTesting internal val errorGradleVersion: Version = Version(8, 14, 0) + @VisibleForTesting internal val errorGradleVersion: Version = Version(9, 3, 1) // Java error and warn should align with packages/flutter_tools/lib/src/android/gradle_utils.dart. @VisibleForTesting internal val warnJavaVersion: JavaVersion = JavaVersion.VERSION_17 @VisibleForTesting internal val errorJavaVersion: JavaVersion = JavaVersion.VERSION_17 - @VisibleForTesting internal val warnAGPVersion: AndroidPluginVersion = AndroidPluginVersion(9, 0, 1) + @VisibleForTesting internal val warnAGPVersion: AndroidPluginVersion = AndroidPluginVersion(9, 3, 1) - @VisibleForTesting internal val errorAGPVersion: AndroidPluginVersion = AndroidPluginVersion(8, 11, 1) + @VisibleForTesting internal val errorAGPVersion: AndroidPluginVersion = AndroidPluginVersion(9, 1, 1) - @VisibleForTesting internal val warnKGPVersion: Version = Version(2, 3, 20) + @VisibleForTesting internal val firstUnsupportedAGPMajorVersion: Int = 10 - @VisibleForTesting internal val errorKGPVersion: Version = Version(2, 2, 20) + @VisibleForTesting internal val warnKGPVersion: Version = Version(2, 4, 20) + + @VisibleForTesting internal val errorKGPVersion: Version = Version(2, 4, 10) // If this value is changed, then make sure to change the documentation on https://docs.flutter.dev/reference/supported-platforms // Non inclusive. @@ -140,10 +142,16 @@ object DependencyVersionChecker { } val kgpVersion: Version? = VersionFetcher.getKGPVersion(project) - if (kgpVersion != null) { + val usesBuiltInKotlin = FlutterPluginUtils.isBuiltInKotlinEnabled(project, agpVersion) + + if (kgpVersion != null && !usesBuiltInKotlin) { checkKGPVersion(kgpVersion, project) + } else { + project.logger.debug( + "Skipping Kotlin min-version enforcement because the project uses " + + "AGP built-in Kotlin or does not apply KGP." + ) } - // KGP is not required, so don't log any warning if we can't find the version. } private fun configureMinSdkCheck(project: Project) { @@ -208,6 +216,20 @@ object DependencyVersionChecker { "\nAlternatively, use the flag \"--android-skip-build-dependency-validation\"" + " to bypass this check.\n\nPotential fix: $potentialFix" + @VisibleForTesting internal fun getFutureUnsupportedMajorVersionErrorMessage( + dependencyName: String, + versionString: String, + unsupportedMajorVersion: Int, + potentialFix: String + ): String = + "Error: Your project's $dependencyName version ($versionString) is not yet " + + "supported. Flutter does not support $dependencyName $unsupportedMajorVersion, " + + "and support will be added in a future Flutter release. Please downgrade your " + + "$dependencyName version to a version below $unsupportedMajorVersion.0.0 to " + + "continue.\nAlternatively, use the flag \"--android-skip-build-dependency-validation\"" + + " to bypass this check (unsupported; your build may fail).\n\n" + + "Potential fix: $potentialFix" + @VisibleForTesting internal fun getFlavorSpecificMessage( flavorName: String?, @@ -270,6 +292,17 @@ object DependencyVersionChecker { androidPluginVersion: AndroidPluginVersion, project: Project ) { + if (androidPluginVersion.major >= firstUnsupportedAGPMajorVersion) { + val errorMessage: String = + getFutureUnsupportedMajorVersionErrorMessage( + AGP_NAME, + "${androidPluginVersion.major}.${androidPluginVersion.minor}.${androidPluginVersion.micro}", + firstUnsupportedAGPMajorVersion, + getPotentialAGPFix(project.rootDir.path) + ) + project.extra.set(OUT_OF_SUPPORT_RANGE_PROPERTY, true) + throw DependencyValidationException(errorMessage) + } if (androidPluginVersion < errorAGPVersion) { val errorMessage: String = getErrorMessage( diff --git a/packages/flutter_tools/gradle/src/main/kotlin/DslVersionCodes.kt b/packages/flutter_tools/gradle/src/main/kotlin/DslVersionCodes.kt new file mode 100644 index 00000000..0ba30d05 --- /dev/null +++ b/packages/flutter_tools/gradle/src/main/kotlin/DslVersionCodes.kt @@ -0,0 +1,42 @@ +// Copyright 2014 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. + +package com.flutter.gradle + +import com.android.build.api.dsl.ApplicationExtension + +/** + * The versionCodes declared on `defaultConfig` and on each product flavor. + * + * Flutter computes per-ABI versionCodes from these instead of reading `VariantOutput.versionCode`, + * which AGP rejects during configuration when `android.compatibility.enableLegacyApi=false`. + * Create it with [from] in `finalizeDsl`. It does not see changes made by `finalizeDsl` callbacks + * registered after Flutter's. + * + * @property productFlavorVersionCodes keyed by flavor name, which is unique across dimensions. + */ +internal data class DslVersionCodes( + val defaultConfigVersionCode: Int?, + val productFlavorVersionCodes: Map +) { + /** + * The versionCode AGP merges for a variant: the first of [productFlavors] (in dimension + * priority order) that sets one, else `defaultConfig`. Null if none is set in the DSL. + */ + fun forVariant(productFlavors: List>): Int? = + productFlavors.firstNotNullOfOrNull { (_, flavorName) -> productFlavorVersionCodes[flavorName] } + ?: defaultConfigVersionCode + + companion object { + /** Reads the versionCodes declared in [extension]'s DSL. */ + fun from(extension: ApplicationExtension): DslVersionCodes = + DslVersionCodes( + defaultConfigVersionCode = extension.defaultConfig.versionCode, + productFlavorVersionCodes = + extension.productFlavors + .mapNotNull { flavor -> flavor.versionCode?.let { flavor.name to it } } + .toMap() + ) + } +} diff --git a/packages/flutter_tools/gradle/src/main/kotlin/FlutterCompileOptions.kt b/packages/flutter_tools/gradle/src/main/kotlin/FlutterCompileOptions.kt new file mode 100644 index 00000000..a82316f4 --- /dev/null +++ b/packages/flutter_tools/gradle/src/main/kotlin/FlutterCompileOptions.kt @@ -0,0 +1,99 @@ +// Copyright 2014 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. + +package com.flutter.gradle + +import org.gradle.api.Project + +/** + * The subset of [FlutterTask][com.flutter.gradle.tasks.FlutterTask] configuration that comes from + * Gradle project properties rather than from the Android variant. + * + * The Flutter tool passes these as `-P=` when it invokes Gradle, so they are constant + * for a build invocation and identical for every variant. Resolving them once, in [from], keeps + * property lookups out of task configuration and gives the property names a single definition + * site. + * + * The name of the Gradle property backing each value is the correspondingly named constant in the + * companion object, for example [fileSystemRoots] is read from [FILE_SYSTEM_ROOTS_PROPERTY]. + * Kotlin does not allow declarations between primary constructor parameters, so the constants are + * declared as one block in the same order as the parameters. + */ +internal data class FlutterCompileOptions( + val fileSystemRoots: List?, + val fileSystemScheme: String?, + val trackWidgetCreation: Boolean, + val frontendServerStarterPath: String?, + val extraFrontEndOptions: String?, + val extraGenSnapshotOptions: String?, + val splitDebugInfo: String?, + val dartObfuscation: Boolean, + val treeShakeIcons: Boolean, + val dartDefines: String?, + val performanceMeasurementFile: String?, + val codeSizeDirectory: String?, + val deferredComponents: Boolean, + val validateDeferredComponents: Boolean +) { + companion object { + const val FILE_SYSTEM_ROOTS_PROPERTY = "filesystem-roots" + const val FILE_SYSTEM_SCHEME_PROPERTY = "filesystem-scheme" + const val TRACK_WIDGET_CREATION_PROPERTY = "track-widget-creation" + const val FRONTEND_SERVER_STARTER_PATH_PROPERTY = "frontend-server-starter-path" + const val EXTRA_FRONT_END_OPTIONS_PROPERTY = "extra-front-end-options" + const val EXTRA_GEN_SNAPSHOT_OPTIONS_PROPERTY = "extra-gen-snapshot-options" + const val SPLIT_DEBUG_INFO_PROPERTY = "split-debug-info" + const val DART_OBFUSCATION_PROPERTY = "dart-obfuscation" + const val TREE_SHAKE_ICONS_PROPERTY = "tree-shake-icons" + const val DART_DEFINES_PROPERTY = "dart-defines" + const val PERFORMANCE_MEASUREMENT_FILE_PROPERTY = "performance-measurement-file" + const val CODE_SIZE_DIRECTORY_PROPERTY = "code-size-directory" + const val DEFERRED_COMPONENTS_PROPERTY = "deferred-components" + const val VALIDATE_DEFERRED_COMPONENTS_PROPERTY = "validate-deferred-components" + + /** + * Separator used to split [FILE_SYSTEM_ROOTS_PROPERTY] into individual roots. + * + * The Flutter tool joins the roots with a single `|`, see `gradle.dart`. Kotlin's + * [String.split] takes a literal delimiter rather than a regular expression, so this + * two-character delimiter never matches and the property arrives as a single element. + * The behavior predates the variant API migration and is preserved here unchanged. + * + * TODO(reidbaker): Split on "|" so that multiple `--filesystem-root` values reach + * `flutter assemble`. https://github.com/flutter/flutter/issues/192824 + */ + private const val FILE_SYSTEM_ROOTS_SEPARATOR = "\\|" + + /** Reads every Gradle property that configures a Flutter compile task from [project]. */ + fun from(project: Project): FlutterCompileOptions = + FlutterCompileOptions( + fileSystemRoots = + project + .findProperty(FILE_SYSTEM_ROOTS_PROPERTY) + ?.toString() + ?.split(FILE_SYSTEM_ROOTS_SEPARATOR), + fileSystemScheme = project.findProperty(FILE_SYSTEM_SCHEME_PROPERTY)?.toString(), + trackWidgetCreation = + project.findProperty(TRACK_WIDGET_CREATION_PROPERTY)?.toString()?.toBoolean() ?: true, + frontendServerStarterPath = + project.findProperty(FRONTEND_SERVER_STARTER_PATH_PROPERTY)?.toString(), + extraFrontEndOptions = project.findProperty(EXTRA_FRONT_END_OPTIONS_PROPERTY)?.toString(), + extraGenSnapshotOptions = + project.findProperty(EXTRA_GEN_SNAPSHOT_OPTIONS_PROPERTY)?.toString(), + splitDebugInfo = project.findProperty(SPLIT_DEBUG_INFO_PROPERTY)?.toString(), + dartObfuscation = + project.findProperty(DART_OBFUSCATION_PROPERTY)?.toString()?.toBoolean() ?: false, + treeShakeIcons = + project.findProperty(TREE_SHAKE_ICONS_PROPERTY)?.toString()?.toBoolean() ?: false, + dartDefines = project.findProperty(DART_DEFINES_PROPERTY)?.toString(), + performanceMeasurementFile = + project.findProperty(PERFORMANCE_MEASUREMENT_FILE_PROPERTY)?.toString(), + codeSizeDirectory = project.findProperty(CODE_SIZE_DIRECTORY_PROPERTY)?.toString(), + deferredComponents = + project.findProperty(DEFERRED_COMPONENTS_PROPERTY)?.toString()?.toBoolean() ?: false, + validateDeferredComponents = + project.findProperty(VALIDATE_DEFERRED_COMPONENTS_PROPERTY)?.toString()?.toBoolean() ?: true + ) + } +} diff --git a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt index 8d898e0d..77dc2e9c 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPlugin.kt @@ -4,26 +4,22 @@ package com.flutter.gradle +import com.android.build.api.artifact.SingleArtifact import com.android.build.api.dsl.ApplicationExtension -import com.android.build.api.dsl.BuildType import com.android.build.api.variant.AndroidComponentsExtension -import com.android.build.gradle.AbstractAppExtension -import com.android.build.gradle.LibraryExtension -import com.android.build.gradle.api.ApkVariant -import com.android.build.gradle.tasks.PackageAndroidArtifact -import com.android.build.gradle.tasks.ProcessAndroidResources +import com.android.build.api.variant.ApplicationVariant +import com.android.build.api.variant.LibraryVariant +import com.android.build.api.variant.Variant import com.flutter.gradle.FlutterPluginConstants.PLATFORM_ABI_LIST import com.flutter.gradle.FlutterPluginUtils.readPropertiesIfExist import com.flutter.gradle.plugins.PluginHandler +import com.flutter.gradle.tasks.CopyFlutterApksTask +import com.flutter.gradle.tasks.CopyFlutterAssetsTask import com.flutter.gradle.tasks.CopyFlutterJniLibsTask import com.flutter.gradle.tasks.FlutterTask import org.gradle.api.GradleException import org.gradle.api.Plugin import org.gradle.api.Project -import org.gradle.api.Task -import org.gradle.api.UnknownTaskException -import org.gradle.api.file.Directory -import org.gradle.api.tasks.Copy import org.gradle.api.tasks.TaskProvider import org.gradle.internal.os.OperatingSystem import org.gradle.kotlin.dsl.support.serviceOf @@ -32,6 +28,7 @@ import java.io.File import java.nio.charset.StandardCharsets import java.nio.file.Paths import java.util.Properties +import com.android.build.api.dsl.BuildType as DslBuildType class FlutterPlugin : Plugin { private var project: Project? = null @@ -203,7 +200,7 @@ class FlutterPlugin : Plugin { "flutter_proguard_rules.pro" ).toFile() // TODO(gmackall): reconsider getting the android extension every time - val debugBuildType: BuildType = FlutterPluginUtils.getAndroidExtension(project).buildTypes.getByName("debug") + val debugBuildType: DslBuildType = FlutterPluginUtils.getAndroidExtension(project).buildTypes.getByName("debug") FlutterPluginUtils.getAndroidExtension(project).buildTypes.create( "profile", { @@ -214,7 +211,7 @@ class FlutterPlugin : Plugin { } ) if (FlutterPluginUtils.shouldShrinkResources(project)) { - val releaseBuildType: BuildType = FlutterPluginUtils.getAndroidExtension(project).buildTypes.getByName("release") + val releaseBuildType: DslBuildType = FlutterPluginUtils.getAndroidExtension(project).buildTypes.getByName("release") releaseBuildType.isMinifyEnabled = true releaseBuildType.isShrinkResources = FlutterPluginUtils.isBuiltAsApp(project) releaseBuildType.proguardFiles.add( @@ -246,12 +243,12 @@ class FlutterPlugin : Plugin { } localEngineHost = engineHostOut.name } - FlutterPluginUtils.getLegacyAndroidExtension(project).buildTypes.all { + FlutterPluginUtils.getAndroidExtension(project).buildTypes.all { addFlutterDependencies(this) } } - private fun addFlutterDependencies(buildType: com.android.builder.model.BuildType) { + private fun addFlutterDependencies(buildType: DslBuildType) { FlutterPluginUtils.addFlutterDependencies( project!!, buildType, @@ -303,6 +300,9 @@ class FlutterPlugin : Plugin { FlutterPluginUtils.addTaskForPrintBuildVariants(projectToAddTasksTo) FlutterPluginUtils.addTaskForPrintNdkVersion(projectToAddTasksTo) FlutterPluginUtils.addTasksForOutputsAppLinkSettings(projectToAddTasksTo) + + // Task required to pass command line flags for apps to the Flutter Android embedding. + FlutterPluginUtils.addTaskForGeneratingEngineShellArgumentManifest(projectToAddTasksTo) } // Only applies to app projects. For module (aar) projects the host app's manifest is // the source of truth for HCPP; see addTasksForEnableHcppManifest for why injecting @@ -316,114 +316,42 @@ class FlutterPlugin : Plugin { // extension is expected to be present. Use getByType (not findByType) so a misconfiguration // fails loudly rather than silently skipping libapp.so registration. val androidComponents = projectToAddTasksTo.extensions.getByType(AndroidComponentsExtension::class.java) - val targetPlatformsList = targetPlatforms + // `this` is shadowed inside the task configuration blocks reached from here, so capture the + // plugin instance that owns the resolved Flutter SDK and local engine paths. + val flutterGradlePlugin = this + val isApplicationProject = FlutterPluginUtils.isFlutterAppProject(projectToAddTasksTo) + // Set in finalizeDsl, which AGP runs before any onVariants callback. + var dslVersionCodes: DslVersionCodes? = null + if (isApplicationProject) { + val appExtension = FlutterPluginUtils.getAndroidApplicationExtension(projectToAddTasksTo) + androidComponents.finalizeDsl { dslVersionCodes = DslVersionCodes.from(appExtension) } + } androidComponents.onVariants { variant -> - val capitalizeVariantName = FlutterPluginUtils.capitalize(variant.name) - val compileTaskName = flutterCompileTaskName(variant.name) - val copyJniLibsTaskProvider: TaskProvider = - projectToAddTasksTo.tasks.register( - "copyJniLibs${FLUTTER_BUILD_PREFIX}$capitalizeVariantName", - CopyFlutterJniLibsTask::class.java - ) { - // The Flutter compile task is registered later (in the legacy - // `applicationVariants` callback in addFlutterDeps) and only for variants that - // are actually built as a Flutter app. It is absent for e.g. an - // `assembleAndroidTest` build, where `shouldConfigureFlutterTask` returns false. - // Look it up tolerantly (findByName, not named) so this task degrades to a no-op - // with empty output instead of failing to be created when there is no Flutter - // build for the variant. See https://github.com/flutter/flutter/issues/188785. - dependsOn(projectToAddTasksTo.tasks.matching { it.name == compileTaskName }) - intermediateDir.set( - projectToAddTasksTo.layout.dir( - projectToAddTasksTo.provider { - val compileTask = projectToAddTasksTo.tasks.findByName(compileTaskName) as? FlutterTask - compileTask?.outputDirectory - } - ) + if (shouldCompileFlutterForVariant(projectToAddTasksTo, variant)) { + val compileTaskProvider = + registerFlutterCompileTask( + projectToAddTasksTo, + variant, + flutterGradlePlugin, + targetPlatforms + ) + registerFlutterAssetTasks(projectToAddTasksTo, variant, compileTaskProvider) + registerFlutterJniLibsTask(projectToAddTasksTo, variant, compileTaskProvider, targetPlatforms) + if (variant is ApplicationVariant) { + configureApplicationOutputs( + projectToAddTasksTo, + variant, + checkNotNull(dslVersionCodes) { + "Flutter read the DSL versionCodes before AGP ran finalizeDsl." + } ) - this.targetPlatforms.set(targetPlatformsList) } - variant.sources.jniLibs?.addGeneratedSourceDirectory( - copyJniLibsTaskProvider, - CopyFlutterJniLibsTask::destinationDir - ) + } } - val flutterPlugin = this - if (FlutterPluginUtils.isFlutterAppProject(projectToAddTasksTo)) { val appExtension = FlutterPluginUtils.getAndroidApplicationExtension(projectToAddTasksTo) configureAbis(projectToAddTasksTo, appExtension) - val android: AbstractAppExtension = - projectToAddTasksTo.extensions.findByName("android") as AbstractAppExtension - android.applicationVariants.configureEach { - val variant = this - val assembleTask = variant.assembleProvider.get() - if (!FlutterPluginUtils.shouldConfigureFlutterTask( - projectToAddTasksTo, - assembleTask - ) - ) { - return@configureEach - } - val copyFlutterAssetsTask: Task = - addFlutterDeps(variant, flutterPlugin, targetPlatforms) - - // TODO(gmackall): Migrate to AGPs variant api. - // https://github.com/flutter/flutter/issues/166550 - @Suppress("DEPRECATION") - val variantOutput: com.android.build.gradle.api.BaseVariantOutput = variant.outputs.first() - val processResources: ProcessAndroidResources = - try { - variantOutput.processResourcesProvider.get() - } catch (e: UnknownTaskException) { - // TODO(gmackall): Migrate to AGPs variant api. - // https://github.com/flutter/flutter/issues/166550 - @Suppress("DEPRECATION") - variantOutput.processResources - } - processResources.dependsOn(copyFlutterAssetsTask) - - // Copy the output APKs into a known location, so `flutter run` or `flutter build apk` - // can discover them. By default, this is `/build/app/outputs/flutter-apk/.apk`. - // - // The filename consists of `app<-abi>?<-flavor-name>?-.apk`. - // Where: - // * `abi` can be `armeabi-v7a|arm64-v8a|x86_64` only if the flag `split-per-abi` is set. - // * `flavor-name` is the flavor used to build the app in lower case if the assemble task is called. - // * `build-mode` can be `release|debug|profile`. - variant.outputs.forEach { output -> - assembleTask.doLast { - // TODO(gmackall): Migrate to AGPs variant api. - // https://github.com/flutter/flutter/issues/166550 - @Suppress("DEPRECATION") - output as com.android.build.gradle.api.ApkVariantOutput - val packageApplicationProvider: PackageAndroidArtifact = - variant.packageApplicationProvider.get() - val outputDirectory: Directory = - packageApplicationProvider.outputDirectory.get() - val outputDirectoryStr: String = outputDirectory.toString() - var filename = "app" - - // TODO(gmackall): Migrate to AGPs variant api. - // https://github.com/flutter/flutter/issues/166550 - @Suppress("DEPRECATION") - val abi = output.getFilter(com.android.build.VariantOutput.FilterType.ABI) - if (abi != null && abi.isNotEmpty()) { - filename += "-$abi" - } - if (variant.flavorName != null && variant.flavorName.isNotEmpty()) { - filename += "-${FlutterPluginUtils.lowercase(variant.flavorName)}" - } - filename += "-${FlutterPluginUtils.buildModeFor(variant.buildType)}" - projectToAddTasksTo.copy { - from(File("$outputDirectoryStr/${output.outputFileName}")) - into(projectToAddTasksTo.layout.buildDirectory.dir("outputs/flutter-apk")) - rename { "$filename.apk" } - } - } - } - } getPluginHandler(projectToAddTasksTo).configurePlugins(engineVersion!!) FlutterPluginUtils.detectLowCompileSdkVersionOrNdkVersion( projectToAddTasksTo, @@ -435,77 +363,7 @@ class FlutterPlugin : Plugin { return } // Flutter host module project (Add-to-app). - val hostAppProjectName: String? = - if (projectToAddTasksTo.rootProject.hasProperty("flutter.hostAppProjectName")) { - projectToAddTasksTo.rootProject.property( - "flutter.hostAppProjectName" - ) as? String - } else { - "app" - } - val appProject: Project? = - projectToAddTasksTo.rootProject.findProject(":$hostAppProjectName") - check(appProject != null) { - "Project :$hostAppProjectName doesn't exist. To customize the host app project name, set `flutter.hostAppProjectName=` in gradle.properties." - } - // Wait for the host app project configuration. - appProject.afterEvaluate { - val androidLibraryExtension = - projectToAddTasksTo.extensions.findByType(LibraryExtension::class.java) - check(androidLibraryExtension != null) - androidLibraryExtension.libraryVariants.all libraryVariantAll@{ - val libraryVariant = this - var copyFlutterAssetsTask: Task? = null - val androidAppExtension = - appProject.extensions.findByName("android") as? AbstractAppExtension - check(androidAppExtension != null) - androidAppExtension.applicationVariants.all applicationVariantAll@{ - val appProjectVariant = this - val appAssembleTask: Task = appProjectVariant.assembleProvider.get() - if (!FlutterPluginUtils.shouldConfigureFlutterTask(project, appAssembleTask)) { - return@applicationVariantAll - } - - // Find a compatible application variant in the host app. - // - // For example, consider a host app that defines the following variants: - // | ----------------- | ----------------------------- | - // | Build Variant | Flutter Equivalent Variant | - // | ----------------- | ----------------------------- | - // | freeRelease | release | - // | freeDebug | debug | - // | freeDevelop | debug | - // | profile | profile | - // | ----------------- | ----------------------------- | - // - // This mapping is based on the following rules: - // 1. If the host app build variant name is `profile` then the equivalent - // Flutter variant is `profile`. - // 2. If the host app build variant is debuggable - // (e.g. `buildType.debuggable = true`), then the equivalent Flutter - // variant is `debug`. - // 3. Otherwise, the equivalent Flutter variant is `release`. - val variantBuildMode: String = - FlutterPluginUtils.buildModeFor(libraryVariant.buildType) - if (FlutterPluginUtils.buildModeFor(appProjectVariant.buildType) != variantBuildMode) { - return@applicationVariantAll - } - copyFlutterAssetsTask = copyFlutterAssetsTask ?: addFlutterDeps( - libraryVariant, - flutterPlugin, - targetPlatforms - ) - // TODO(gmackall): Migrate to AGPs variant api. - // https://github.com/flutter/flutter/issues/166550 - val mergeAssets = - projectToAddTasksTo - .tasks - .findByPath(":$hostAppProjectName:merge${FlutterPluginUtils.capitalize(appProjectVariant.name)}Assets") - check(mergeAssets != null) - mergeAssets.dependsOn(copyFlutterAssetsTask) - } - } - } + warnIfHostAppProjectNameIsSet(projectToAddTasksTo) getPluginHandler(projectToAddTasksTo).configurePlugins(engineVersion!!) FlutterPluginUtils.detectLowCompileSdkVersionOrNdkVersion( projectToAddTasksTo, @@ -526,6 +384,8 @@ class FlutterPlugin : Plugin { companion object { const val PROP_LOCAL_ENGINE_REPO: String = "local-engine-repo" + internal const val PROP_HOST_APP_PROJECT_NAME: String = "flutter.hostAppProjectName" + /** * The name prefix for flutter builds. This is used to identify gradle tasks * where we expect the flutter tool to provide any error output, and skip the @@ -535,13 +395,6 @@ class FlutterPlugin : Plugin { */ private const val FLUTTER_BUILD_PREFIX: String = "flutterBuild" - /** - * The name of the [FlutterTask] (the `flutter assemble` invocation) for [variantName]. - * - * Built identically by [addFlutterDeps], which registers the task, and by the variant API - * callback in [addFlutterTasks], which references it by name (because that callback runs - * before the task is registered). - */ private fun flutterCompileTaskName(variantName: String): String = FlutterPluginUtils.toCamelCase(listOf("compile", FLUTTER_BUILD_PREFIX, variantName)) @@ -605,217 +458,285 @@ class FlutterPlugin : Plugin { } /** - * Finds a task by name, returning null if the task does not exist. + * Whether to configure `flutter assemble`, and everything that depends on it, for [variant]. + * + * When a single `assemble` task is named on the command line, Flutter is only + * compiled for the variants that task can build. This keeps a release build from also + * configuring (and therefore building) the debug Dart artifacts, which is what + * [FlutterPluginUtils.shouldConfigureFlutterTask] exists to prevent. Removing it is + * tracked by https://github.com/flutter/flutter/issues/109560, which also documents the + * AGP behavior that made it necessary. + * + * Library variants (an add-to-app module) skip this check. The command line names a host + * task such as `:app:assembleDemoStaging`, and the host's `matchingFallbacks` pick the + * module variant it consumes, so the task name can't identify that variant. Registering + * every library variant is safe because registration is lazy: Gradle only runs the module + * tasks that the host build depends on. */ - private fun findTaskOrNull( + private fun shouldCompileFlutterForVariant( project: Project, - taskName: String - ): Task? = - try { - project.tasks.named(taskName).get() - } catch (ignored: UnknownTaskException) { - null - } + variant: Variant + ): Boolean = + variant is LibraryVariant || + FlutterPluginUtils.shouldConfigureFlutterTask( + project, + "assemble${FlutterPluginUtils.capitalize(variant.name)}" + ) - // TODO(gmackall): Migrate to AGPs variant api. - // https://github.com/flutter/flutter/issues/166550 - private fun addFlutterDeps( - @Suppress("DEPRECATION") variant: com.android.build.gradle.api.BaseVariant, - flutterPlugin: FlutterPlugin, + /** + * Registers the tasks that stage Flutter's assets for [variant], and declares the + * directory they stage into as a generated assets source directory. + * + * The assets are produced by [compileTaskProvider], the `flutter assemble` task registered + * by [registerFlutterCompileTask]. That task also produces the Dart code and native + * libraries, so it is registered by the caller rather than here. + * + * AGP then merges and packages that directory like any other assets source, which is what + * puts `flutter_assets` into the APK. + */ + private fun registerFlutterAssetTasks( + project: Project, + variant: Variant, + compileTaskProvider: TaskProvider + ) { + val copyFlutterAssetsTaskProvider: TaskProvider = + project.tasks.register( + "copyFlutterAssets${FlutterPluginUtils.capitalize(variant.name)}", + CopyFlutterAssetsTask::class.java + ) { + // registerFlutterCompileTask always sets an output directory, so this is an + // invariant rather than a tolerated absence. Letting the provider go absent + // instead would stage an empty directory and produce an APK with no + // flutter_assets, which fails at runtime rather than at build time. + intermediateDir.set( + project.layout.dir( + compileTaskProvider.map { requireNotNull(it.outputDirectory) } + ) + ) + } + // Fail at configuration rather than silently build an APK or AAR without Flutter assets. + val assetSources = + variant.sources.assets + ?: throw GradleException( + "Flutter could not register its generated assets for variant " + + "'${variant.name}' because the Android Gradle Plugin did not " + + "expose an assets source set for it. Please file an issue at " + + "https://github.com/flutter/flutter/issues." + ) + assetSources.addGeneratedSourceDirectory( + copyFlutterAssetsTaskProvider, + CopyFlutterAssetsTask::destinationDir + ) + } + + /** + * Registers the task that stages Flutter's native libraries for [variant], and declares + * the directory it stages into as a generated jniLibs source directory. + */ + private fun registerFlutterJniLibsTask( + project: Project, + variant: Variant, + compileTaskProvider: TaskProvider, targetPlatforms: List - ): Task { - // Shorthand - val project: Project = flutterPlugin.project!! - - val fileSystemRootsValue: Array? = - project - .findProperty("filesystem-roots") - ?.toString() - ?.split("\\|") - ?.toTypedArray() - val fileSystemSchemeValue: String? = - project.findProperty("filesystem-scheme")?.toString() - val trackWidgetCreationValue: Boolean = - project.findProperty("track-widget-creation")?.toString()?.toBoolean() ?: true - val frontendServerStarterPathValue: String? = - project.findProperty("frontend-server-starter-path")?.toString() - val extraFrontEndOptionsValue: String? = - project.findProperty("extra-front-end-options")?.toString() - val extraGenSnapshotOptionsValue: String? = - project.findProperty("extra-gen-snapshot-options")?.toString() - val splitDebugInfoValue: String? = project.findProperty("split-debug-info")?.toString() - val dartObfuscationValue: Boolean = - project.findProperty("dart-obfuscation")?.toString()?.toBoolean() ?: false - val treeShakeIconsOptionsValue: Boolean = - project.findProperty("tree-shake-icons")?.toString()?.toBoolean() ?: false - val dartDefinesValue: String? = project.findProperty("dart-defines")?.toString() - val performanceMeasurementFileValue: String? = - project.findProperty("performance-measurement-file")?.toString() - val codeSizeDirectoryValue: String? = - project.findProperty("code-size-directory")?.toString() - val deferredComponentsValue: Boolean = - project.findProperty("deferred-components")?.toString()?.toBoolean() ?: false - val validateDeferredComponentsValue: Boolean = - project.findProperty("validate-deferred-components")?.toString()?.toBoolean() ?: true - - if (FlutterPluginUtils.shouldProjectSplitPerAbi(project)) { - variant.outputs.forEach { output -> - // need to force this as the API does not return the right thing for our use. - // TODO(gmackall): Migrate to AGPs variant api. - // https://github.com/flutter/flutter/issues/166550 - @Suppress("DEPRECATION") - output as com.android.build.gradle.api.ApkVariantOutput - val versionCodeIfPresent: Int? = if (variant is ApkVariant) variant.versionCode else null - - // TODO(gmackall): Migrate to AGPs variant api. - // https://github.com/flutter/flutter/issues/166550 - @Suppress("DEPRECATION") - val filterIdentifier: String? = - output.getFilter(com.android.build.VariantOutput.FilterType.ABI) - val abiVersionCode: Int? = FlutterPluginConstants.ABI_VERSION[filterIdentifier] - if (abiVersionCode != null && !FlutterPluginUtils.shouldForceVersionCodeIgnoringAbi(project)) { - output.versionCodeOverride = abiVersionCode * 1000 + ( - versionCodeIfPresent - ?: variant.mergedFlavor.versionCode as Int + ) { + val copyJniLibsTaskProvider: TaskProvider = + project.tasks.register( + "copyJniLibs$FLUTTER_BUILD_PREFIX${FlutterPluginUtils.capitalize(variant.name)}", + CopyFlutterJniLibsTask::class.java + ) { + // registerFlutterCompileTask always sets an output directory, so an absent + // value would be a bug that builds an APK or AAR without libapp.so. + intermediateDir.set( + project.layout.dir( + compileTaskProvider.map { requireNotNull(it.outputDirectory) } ) - } + ) + this.targetPlatforms.set(targetPlatforms) } + variant.sources.jniLibs?.addGeneratedSourceDirectory( + copyJniLibsTaskProvider, + CopyFlutterJniLibsTask::destinationDir + ) + } + + /** Registers the [FlutterTask] (the `flutter assemble` invocation) for [variant]. */ + private fun registerFlutterCompileTask( + project: Project, + variant: Variant, + flutterGradlePlugin: FlutterPlugin, + targetPlatforms: List + ): TaskProvider { + val buildMode: String = flutterBuildModeFor(variant) + return project.tasks.register(flutterCompileTaskName(variant.name), FlutterTask::class.java) { + configureCompileTask( + project = project, + flutterGradlePlugin = flutterGradlePlugin, + buildMode = buildMode, + minSdkVersion = variant.minSdk.apiLevel, + variantName = variant.name, + flavorName = variant.flavorName ?: "", + targetPlatforms = targetPlatforms + ) } + } - // Build an AAR when this property is defined. - val isBuildingAar: Boolean = project.hasProperty("is-plugin") - // In add to app scenarios, a Gradle project contains a `:flutter` and `:app` project. - // `:flutter` is used as a subproject when these tasks exists and the build isn't building an AAR. - // TODO(gmackall): I think this is just always null? Which is great news! Consider removing. - val packageAssets: Task? = - findTaskOrNull( - project, - "package${FlutterPluginUtils.capitalize(variant.name)}Assets" + /** "debug", "profile" or "release" for [variant]; debuggable custom build types are "debug". */ + private fun flutterBuildModeFor(variant: Variant): String { + val variantBuildType = + requireNotNull(variant.buildType) { + "Variant ${variant.name} has no buildType configured." + } + return FlutterPluginUtils.buildModeFor(variantBuildType, variant.debuggable) + } + + /** + * Applies the `flutter assemble` configuration to this task. + * + * Every value the task needs from the variant is passed in, because reading it inside the + * configuration block would resolve against the task itself: in that scope `flavor` is the + * task's own property, not the variant's. + */ + private fun FlutterTask.configureCompileTask( + project: Project, + flutterGradlePlugin: FlutterPlugin, + buildMode: String, + minSdkVersion: Int, + variantName: String, + flavorName: String, + targetPlatforms: List + ) { + val compileOptions = FlutterCompileOptions.from(project) + flutterRoot = flutterGradlePlugin.flutterRoot + flutterExecutable = flutterGradlePlugin.flutterExecutable + this.buildMode = buildMode + this.minSdkVersion = minSdkVersion + localEngine = flutterGradlePlugin.localEngine + localEngineHost = flutterGradlePlugin.localEngineHost + localEngineSrcPath = flutterGradlePlugin.localEngineSrcPath + targetPath = FlutterPluginUtils.getFlutterTarget(project) + verbose = FlutterPluginUtils.isProjectVerbose(project) + fileSystemRoots = compileOptions.fileSystemRoots?.toTypedArray() + fileSystemScheme = compileOptions.fileSystemScheme + trackWidgetCreation = compileOptions.trackWidgetCreation + targetPlatformValues = targetPlatforms + sourceDir = FlutterPluginUtils.getFlutterSourceDirectory(project) + intermediateDir = + project.file( + project.layout.buildDirectory.dir( + "${FlutterPluginConstants.INTERMEDIATES_DIR}/flutter/$variantName/" + ) ) - val cleanPackageAssets: Task? = - findTaskOrNull( - project, - "cleanPackage${FlutterPluginUtils.capitalize(variant.name)}Assets" + frontendServerStarterPath = compileOptions.frontendServerStarterPath + extraFrontEndOptions = compileOptions.extraFrontEndOptions + extraGenSnapshotOptions = compileOptions.extraGenSnapshotOptions + splitDebugInfo = compileOptions.splitDebugInfo + treeShakeIcons = compileOptions.treeShakeIcons + dartObfuscation = compileOptions.dartObfuscation + dartDefines = compileOptions.dartDefines + performanceMeasurementFile = compileOptions.performanceMeasurementFile + codeSizeDirectory = compileOptions.codeSizeDirectory + deferredComponents = compileOptions.deferredComponents + validateDeferredComponents = compileOptions.validateDeferredComponents + flavor = flavorName + } + + /** + * Configures the per-ABI versionCodes of [variant] and the copy of its APKs into + * `build/outputs/flutter-apk/`. + */ + private fun configureApplicationOutputs( + project: Project, + variant: ApplicationVariant, + dslVersionCodes: DslVersionCodes + ) { + configureSplitPerAbiVersionCodes( + project, + variant, + dslVersionCodes.forVariant(variant.productFlavors) + ) + registerCopyFlutterApksTask(project, variant) + } + + /** + * The module ships its assets as a generated assets source of its own variants, so it + * does not need the host app's project name. + */ + private fun warnIfHostAppProjectNameIsSet(project: Project) { + if (project.providers.gradleProperty(PROP_HOST_APP_PROJECT_NAME).isPresent) { + project.logger.warn( + "Warning: The Gradle property '$PROP_HOST_APP_PROJECT_NAME' has no effect. " + + "Remove it from gradle.properties." ) + } + } - val isUsedAsSubproject: Boolean = - packageAssets != null && cleanPackageAssets != null && !isBuildingAar - - val variantBuildMode: String = FlutterPluginUtils.buildModeFor(variant.buildType) - val flavorValue: String = variant.flavorName - val taskName: String = flutterCompileTaskName(variant.name) - // The task provider below will shadow a lot of the variable names, so provide this reference - // to access them within that scope. - - // Be careful when configuring task below, Groovy has bizarre - // scoping rules: writing `verbose isVerbose()` means calling - // `isVerbose` on the task itself - which would return `verbose` - // original value. You either need to hoist the value - // into a separate variable `verbose verboseValue` or prefix with - // `this` (`verbose this.isVerbose()`). - val compileTaskProvider: TaskProvider = - project.tasks.register(taskName, FlutterTask::class.java) { - flutterRoot = flutterPlugin.flutterRoot - flutterExecutable = flutterPlugin.flutterExecutable - buildMode = variantBuildMode - minSdkVersion = variant.mergedFlavor.minSdkVersion!!.apiLevel - localEngine = flutterPlugin.localEngine - localEngineHost = flutterPlugin.localEngineHost - localEngineSrcPath = flutterPlugin.localEngineSrcPath - targetPath = FlutterPluginUtils.getFlutterTarget(project) - verbose = FlutterPluginUtils.isProjectVerbose(project) - fileSystemRoots = fileSystemRootsValue - fileSystemScheme = fileSystemSchemeValue - trackWidgetCreation = trackWidgetCreationValue - targetPlatformValues = targetPlatforms - sourceDir = FlutterPluginUtils.getFlutterSourceDirectory(project) - intermediateDir = - project.file( - project.layout.buildDirectory.dir("${FlutterPluginConstants.INTERMEDIATES_DIR}/flutter/${variant.name}/") - ) - frontendServerStarterPath = frontendServerStarterPathValue - extraFrontEndOptions = extraFrontEndOptionsValue - extraGenSnapshotOptions = extraGenSnapshotOptionsValue - splitDebugInfo = splitDebugInfoValue - treeShakeIcons = treeShakeIconsOptionsValue - dartObfuscation = dartObfuscationValue - dartDefines = dartDefinesValue - performanceMeasurementFile = performanceMeasurementFileValue - codeSizeDirectory = codeSizeDirectoryValue - deferredComponents = deferredComponentsValue - validateDeferredComponents = validateDeferredComponentsValue - flavor = flavorValue - } - val flutterCompileTask: FlutterTask = compileTaskProvider.get() - val copyFlutterAssetsTaskProvider: TaskProvider = + /** + * For `--split-per-abi`, sets each output's versionCode to + * `ABI_VERSION[abi] * 1000 + baseVersionCode`, where [baseVersionCode] comes from the DSL + * (see [DslVersionCodes]). A versionCode set only in the manifest is not offset. + * + * Skipped with `force-version-code-ignoring-abi`. Outputs without an ABI filter are left + * unchanged. An `onVariants` block in the app's build script runs after this one, so a + * value it sets wins. See "Setting per-ABI or per-variant versionCode" in + * docs/platforms/android/website-page-draft.md. + * + * TODO(reidbaker): Link to the docs.flutter.dev page once it is published. + * https://github.com/flutter/flutter/issues/193713 + */ + private fun configureSplitPerAbiVersionCodes( + project: Project, + variant: ApplicationVariant, + baseVersionCode: Int? + ) { + if (!FlutterPluginUtils.shouldProjectSplitPerAbi(project) || + FlutterPluginUtils.shouldForceVersionCodeIgnoringAbi(project) + ) { + return + } + if (baseVersionCode == null) { + // logger.error, not warn: the flutter tool runs Gradle with -q, which hides warn. + project.logger.error( + "Warning: Flutter could not give each split APK of variant '${variant.name}' a " + + "distinct versionCode because its android {} block declares no versionCode, " + + "so Google Play will not accept these APKs together. Set " + + "versionCode = flutter.versionCode in defaultConfig, or set versionCode on " + + "each product flavor." + ) + return + } + variant.outputs.forEach { output -> + val abiVersionCode = + FlutterPluginConstants.ABI_VERSION[CopyFlutterApksTask.abiOf(output.filters)] + ?: return@forEach + output.versionCode.set(abiVersionCode * 1000 + baseVersionCode) + } + } + + /** Registers the copy of [variant]'s APKs into flutter-apk, run by `assemble`. */ + private fun registerCopyFlutterApksTask( + project: Project, + variant: ApplicationVariant + ) { + val copyFlutterApksTaskProvider: TaskProvider = project.tasks.register( - "copyFlutterAssets${FlutterPluginUtils.capitalize(variant.name)}", - Copy::class.java + "copyFlutterApks${FlutterPluginUtils.capitalize(variant.name)}", + CopyFlutterApksTask::class.java ) { - dependsOn(flutterCompileTask) - with(flutterCompileTask.assets) - filePermissions { - user { - read = true - write = true - } - } - if (isUsedAsSubproject) { - // TODO(gmackall): above is always false, can delete - dependsOn(packageAssets) - dependsOn(cleanPackageAssets) - into(packageAssets!!.outputs) - } - val mergeAssets = - try { - variant.mergeAssetsProvider.get() - } catch (e: IllegalStateException) { - // TODO(gmackall): Migrate to AGPs variant api. - // https://github.com/flutter/flutter/issues/166550 - @Suppress("DEPRECATION") - variant.mergeAssets - } - dependsOn(mergeAssets) - dependsOn("clean${FlutterPluginUtils.capitalize(mergeAssets.name)}") - mergeAssets.mustRunAfter("clean${FlutterPluginUtils.capitalize(mergeAssets.name)}") - into(mergeAssets.outputDir) + apkDirectory.set(variant.artifacts.get(SingleArtifact.APK)) + builtArtifactsLoader.set(variant.artifacts.getBuiltArtifactsLoader()) + outputAbis.set(variant.outputs.map { CopyFlutterApksTask.abiOf(it.filters) }) + flavorName.set(variant.flavorName?.takeIf { it.isNotEmpty() }) + buildMode.set(flutterBuildModeFor(variant)) + destinationDir.set(project.layout.buildDirectory.dir("outputs/flutter-apk")) } - val copyFlutterAssetsTask: Task = copyFlutterAssetsTaskProvider.get() - if (!isUsedAsSubproject) { - // TODO(gmackall): Migrate to AGPs variant api. - // https://github.com/flutter/flutter/issues/166550 - @Suppress("DEPRECATION") - val variantOutput: com.android.build.gradle.api.BaseVariantOutput = variant.outputs.first() - val processResources = - try { - variantOutput.processResourcesProvider.get() - } catch (e: IllegalStateException) { - // TODO(gmackall): Migrate to AGPs variant api. - // https://github.com/flutter/flutter/issues/166550 - @Suppress("DEPRECATION") - variantOutput.processResources - } - processResources.dependsOn(copyFlutterAssetsTask) - } - // The following tasks use the output of copyFlutterAssetsTask, - // so it's necessary to declare it as an dependency since Gradle 8. - // See https://docs.gradle.org/8.1/userguide/validation_problems.html#implicit_dependency. - val tasksToCheck = - listOf( - "compress${FlutterPluginUtils.capitalize(variant.name)}Assets", - "bundle${FlutterPluginUtils.capitalize(variant.name)}Aar", - "bundle${FlutterPluginUtils.capitalize(variant.name)}LocalLintAar" - ) - tasksToCheck.forEach { taskTocheck -> - try { - project.tasks.named(taskTocheck).configure { - dependsOn(copyFlutterAssetsTask) - } - } catch (ignored: UnknownTaskException) { - // ignored + // The variant API has no provider for the assemble task, so match it by name. + // `configureEach` realizes no tasks; `tasks.named(Spec)` is @Incubating in our API. + val assembleTaskName = "assemble${FlutterPluginUtils.capitalize(variant.name)}" + project.tasks.configureEach { + if (name == assembleTaskName) { + dependsOn(copyFlutterApksTaskProvider) } } - return copyFlutterAssetsTask } } diff --git a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt index eb618a2b..293b1c91 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt @@ -6,20 +6,21 @@ package com.flutter.gradle import com.android.build.api.AndroidPluginVersion import com.android.build.api.artifact.SingleArtifact +import com.android.build.api.dsl.ApplicationBuildType import com.android.build.api.dsl.ApplicationExtension +import com.android.build.api.dsl.DynamicFeatureBuildType import com.android.build.api.dsl.LibraryExtension import com.android.build.api.variant.AndroidComponentsExtension -import com.android.build.gradle.BaseExtension -import com.android.builder.model.BuildType +import com.android.build.api.variant.ApplicationVariant import com.flutter.gradle.plugins.PluginHandler import com.flutter.gradle.tasks.DeepLinkJsonFromManifestTask import com.flutter.gradle.tasks.EnableHcppManifestTask +import com.flutter.gradle.tasks.GenerateEngineFlagsManifestTask import com.flutter.gradle.tasks.PrintTask import com.flutter.gradle.tasks.ValidateCompileSdkVersionTask import groovy.lang.Closure import org.gradle.api.GradleException import org.gradle.api.Project -import org.gradle.api.Task import org.gradle.api.UnknownTaskException import org.gradle.api.logging.Logger import org.gradle.kotlin.dsl.register @@ -29,6 +30,7 @@ import java.io.File import java.io.IOException import java.nio.charset.StandardCharsets import java.util.Properties +import com.android.build.api.dsl.BuildType as DslBuildType /** * A collection of static utility functions used by the Flutter Gradle Plugin. @@ -325,40 +327,31 @@ object FlutterPluginUtils { project.findProperty(PROP_FORCE_VERSION_CODE_IGNORING_ABI)?.toString()?.toBoolean() ?: false /** - * TODO: Remove this AGP hack. https://github.com/flutter/flutter/issues/109560 - * - * In AGP 4.0, the Android linter task depends on the JAR tasks that generate `libapp.so`. - * When building APKs, this causes an issue where building release requires the debug JAR, - * but Gradle won't build debug. + * Whether Flutter's tasks should be configured for the variant that [assembleTaskName] + * assembles. * - * To workaround this issue, only configure the JAR task that is required given the task - * from the command line. + * Returns true unless exactly one `assemble` task is named on the command line and that task + * builds a different variant. Build mode suffixes match, so `assembleRelease` also configures + * `assembleFreeRelease`. * - * The AGP team said that this issue is fixed in Gradle 7.0, which isn't released at the - * time of adding this code. Once released, this can be removed. However, after updating to - * AGP/Gradle 7.2.0/7.5, removing this hack still causes build failures. Further - * investigation necessary to remove this. + * This exists because the Android linter task depends on the JAR tasks that generate + * `libapp.so`, so building release requires the debug JAR, which Gradle will not build. + * Configuring only the variant named on the command line avoids that. Removing the check was + * tried on AGP/Gradle 7.2.0/7.5 and still caused build failures. * - * Tested cases: - * * `./gradlew assembleRelease` - * * `./gradlew app:assembleRelease.` - * * `./gradlew assemble{flavorName}Release` - * * `./gradlew app:assemble{flavorName}Release` - * * `./gradlew assemble.` - * * `./gradlew app:assemble.` - * * `./gradlew bundle.` - * * `./gradlew bundleRelease.` - * * `./gradlew app:bundleRelease.` + * Callers in [FlutterPlugin][com.flutter.gradle.FlutterPlugin] gate the compile task and + * everything that depends on it, for application variants only. * - * Related issues: - * https://issuetracker.google.com/issues/158060799 - * https://issuetracker.google.com/issues/158753935 + * TODO: Remove this AGP hack. https://github.com/flutter/flutter/issues/109560 + * That issue records the original rationale verbatim, the Gradle invocations that were + * tested, and the related AGP bugs https://issuetracker.google.com/issues/158060799 and + * https://issuetracker.google.com/issues/158753935. */ @JvmStatic @JvmName("shouldConfigureFlutterTask") internal fun shouldConfigureFlutterTask( project: Project, - assembleTask: Task + assembleTaskName: String ): Boolean { val cliTasksNames = project.gradle.startParameter.taskNames if (cliTasksNames.size != 1 || !cliTasksNames.first().contains("assemble")) { @@ -368,16 +361,16 @@ object FlutterPluginUtils { if (taskName == "assemble") { return true } - if (taskName == assembleTask.name) { + if (taskName == assembleTaskName) { return true } - if (taskName.endsWith("Release") && assembleTask.name.endsWith("Release")) { + if (taskName.endsWith("Release") && assembleTaskName.endsWith("Release")) { return true } - if (taskName.endsWith("Debug") && assembleTask.name.endsWith("Debug")) { + if (taskName.endsWith("Debug") && assembleTaskName.endsWith("Debug")) { return true } - if (taskName.endsWith("Profile") && assembleTask.name.endsWith("Profile")) { + if (taskName.endsWith("Profile") && assembleTaskName.endsWith("Profile")) { return true } return false @@ -466,21 +459,44 @@ object FlutterPluginUtils { } /** - * Returns a Flutter build mode suitable for the specified Android buildType. + * Returns a Flutter build mode for a build type identified by [buildTypeName] and its + * [isDebuggable] flag. * * @return "debug", "profile", or "release" (fall-back). */ @JvmStatic @JvmName("buildModeFor") - internal fun buildModeFor(buildType: BuildType): String { - if (buildType.name == "profile") { + internal fun buildModeFor( + buildTypeName: String, + isDebuggable: Boolean + ): String { + if (buildTypeName == "profile") { return "profile" - } else if (buildType.isDebuggable) { + } else if (isDebuggable) { return "debug" } return "release" } + /** + * Returns a Flutter build mode for an AGP public DSL [buildType]. + * + * Application and dynamic-feature build types expose a public `isDebuggable` flag. + * Library build types do not, so for them the conventional "debug" name is the only + * public signal available at DSL scope. + */ + @JvmStatic + @JvmName("buildModeFor") + internal fun buildModeFor(buildType: DslBuildType): String { + val isDebuggable = + when (buildType) { + is ApplicationBuildType -> buildType.isDebuggable + is DynamicFeatureBuildType -> buildType.isDebuggable + else -> buildType.name == "debug" + } + return buildModeFor(buildType.name, isDebuggable) + } + /** * Returns true if the build mode is supported by the current call to Gradle. * This only relevant when using a local engine. Because the engine @@ -501,22 +517,6 @@ object FlutterPluginUtils { return project.property(PROP_LOCAL_ENGINE_BUILD_MODE) == flutterBuildMode } - /** - * Returns BaseExtension for the project. Used for compatibility. - * - * From BaseExtension docs: - * "Don't use this extension directly Instead, use one of the following: - * ApplicationExtension, LibraryExtension, TestExtension, DynamicFeatureExtension" - * - * For ApplicationExtension use `getAndroidApplicationExtension`. - * For LibraryExtension use `getAndroidLibraryExtension`. - */ - internal fun getLegacyAndroidExtension(project: Project): BaseExtension { - // Common supertype of the android extension types. - // But maybe this should be https://developer.android.com/reference/tools/gradle-api/8.7/com/android/build/api/dsl/TestedExtension. - return project.extensions.findByType(BaseExtension::class.java)!! - } - internal fun getAndroidExtension(project: Project): AgpCommonExtensionWrapper { // Look up by name to completely avoid importing or resolving CommonExtension val androidExtension = @@ -531,18 +531,22 @@ object FlutterPluginUtils { internal fun getAndroidApplicationExtension(project: Project): ApplicationExtension = project.extensions.getByType(ApplicationExtension::class.java) - internal fun getConfiguredNdkVersion(project: Project): String? = - project.extensions.findByType(ApplicationExtension::class.java)?.ndkVersion - ?: getLegacyAndroidExtension(project).ndkVersion + internal fun getConfiguredNdkVersion(project: Project): String? = getAndroidExtension(project).ndkVersion /** - * Expected format of getAndroidExtension(project).compileSdkVersion is a string of the form - * `android-` followed by either the numeric version, e.g. `android-35`, or a preview version, - * e.g. `android-UpsideDownCake`. + * Returns the compile SDK configured on the project's Android extension: the numeric + * API level (`compileSdk = 36`) or a preview codename (`compileSdkPreview = "Baklava"`). */ @JvmStatic @JvmName("getCompileSdkFromProject") - internal fun getCompileSdkFromProject(project: Project): String = getLegacyAndroidExtension(project).compileSdkVersion!!.substring(8) + internal fun getCompileSdkFromProject(project: Project): CompileSdkVersion { + val androidExtension = getAndroidExtension(project) + val preview = androidExtension.compileSdkPreview + return CompileSdkVersion( + apiLevel = if (preview != null) null else androidExtension.compileSdk, + previewCodename = preview + ) + } /** * Returns: @@ -797,11 +801,11 @@ object FlutterPluginUtils { } // If the project is already configuring a native build, we don't need to do anything. - val gradleProjectAndroidExtension = getLegacyAndroidExtension(gradleProject) + val gradleProjectAndroidExtension = getAndroidExtension(gradleProject) val externalNativeBuild = gradleProjectAndroidExtension.externalNativeBuild val forcingNotRequired: Boolean = - externalNativeBuild?.cmake?.path != null || - externalNativeBuild?.ndkBuild?.path != null + externalNativeBuild.cmake.path != null || + externalNativeBuild.ndkBuild.path != null if (forcingNotRequired) { return } @@ -925,10 +929,9 @@ object FlutterPluginUtils { gradleProject: Project, flutterSdkRootPath: String ) { - val gradleProjectAndroidExtension = getLegacyAndroidExtension(gradleProject) - gradleProjectAndroidExtension.externalNativeBuild.cmake.path( - "$flutterSdkRootPath/packages/flutter_tools/gradle/src/main/scripts/CMakeLists.txt" - ) + val gradleProjectAndroidExtension = getAndroidExtension(gradleProject) + gradleProjectAndroidExtension.externalNativeBuild.cmake.path = + File("$flutterSdkRootPath/packages/flutter_tools/gradle/src/main/scripts/CMakeLists.txt") // AGP defaults to outputting build artifacts in `android/app/.cxx`. This directory is a // build artifact, so we move it from that directory to within Flutter's build directory @@ -940,22 +943,22 @@ object FlutterPluginUtils { // but as we are not actually building anything (and are instead only tricking AGP into // downloading the NDK), it is acceptable for the buildStagingDirectory to be removed // and rebuilt when running clean builds. - gradleProjectAndroidExtension.externalNativeBuild.cmake.buildStagingDirectory( + gradleProjectAndroidExtension.externalNativeBuild.cmake.buildStagingDirectory = gradleProject.layout.buildDirectory .dir("../.cxx") .get() - .asFile.path - ) + .asFile // CMake will print warnings when you try to build an empty project. // These arguments silence the warnings - our project is intentionally // empty. gradleProjectAndroidExtension.buildTypes.forEach { buildType -> - buildType.externalNativeBuild.cmake.arguments( - "-Wno-dev", - "--no-warn-unused-cli", - "-DCMAKE_BUILD_TYPE=${buildType.name}" - ) + buildType.externalNativeBuild.cmake.arguments += + listOf( + "-Wno-dev", + "--no-warn-unused-cli", + "-DCMAKE_BUILD_TYPE=${buildType.name}" + ) } } @@ -986,7 +989,7 @@ object FlutterPluginUtils { @JvmName("addFlutterDependencies") internal fun addFlutterDependencies( project: Project, - buildType: BuildType, + buildType: DslBuildType, pluginHandler: PluginHandler, engineVersion: String ) { @@ -1135,9 +1138,12 @@ object FlutterPluginUtils { // flutter/flutter/packages/flutter_tools/test/integration.shard/android_gradle_outputs_app_link_settings_test.dart val androidComponents = project.extensions.getByType(AndroidComponentsExtension::class.java) androidComponents.onVariants { variant -> + if (variant !is ApplicationVariant) { + return@onVariants + } val manifestUpdater = project.tasks.register("output${capitalize(variant.name)}AppLinkSettings", DeepLinkJsonFromManifestTask::class.java) { - namespace.set(variant.namespace) + applicationId.set(variant.applicationId) // Flutter should always use project.layout.buildDirectory.file("deeplink.json") // instead of relying on passing in a path. if (project.hasProperty("outputPath")) { @@ -1160,10 +1166,44 @@ object FlutterPluginUtils { } } + /** + * Creates a task to generate an AndroidManifest.xml containing the engine shell arguments + * and adds it to the variant's manifests. + */ + @JvmStatic + @JvmName("addTaskForGeneratingEngineShellArgumentManifest") + internal fun addTaskForGeneratingEngineShellArgumentManifest(project: Project) { + val engineShellArgsJson = project.findProperty("flutter.engineShellArgs") as? String ?: return + val androidComponents = project.extensions.getByType(AndroidComponentsExtension::class.java) + + androidComponents.onVariants { variant -> + val capitalizeVariantName = capitalize(variant.name) + val generateManifestTaskProvider = + project.tasks.register( + "generateEngineFlagsManifest$capitalizeVariantName", + GenerateEngineFlagsManifestTask::class.java + ) { + this.engineShellArgsJson.set(engineShellArgsJson) + this.manifestOutputFile.set( + project.layout.buildDirectory.file( + "intermediates/flutter/${variant.name}/AndroidManifest.xml" + ) + ) + } + + variant.sources.manifests?.addGeneratedManifestFile( + generateManifestTaskProvider, + GenerateEngineFlagsManifestTask::manifestOutputFile + ) + } + } + /** * Adds tasks that inject the `io.flutter.embedding.android.EnableHcpp` meta-data into the - * merged manifest of each variant, when the flutter tool passed `-Penable-hcpp=true` (i.e. - * when the `--enable-hcpp` flag was passed). + * merged manifest of each variant, when the flutter tool passed `-Penable-hcpp=true`. The + * tool does that when the `enable-hcpp` feature flag is on, or when `--enable-hcpp` was + * passed explicitly (these are not the same thing: an explicit flag sets the property + * regardless of the feature flag). * * The meta-data is only added when not already present in the merged manifest, so an * explicit value in the developer's manifest always takes priority over the tool's default. diff --git a/packages/flutter_tools/gradle/src/main/kotlin/VersionFetcher.kt b/packages/flutter_tools/gradle/src/main/kotlin/VersionFetcher.kt index 4d6e546f..6bb4c26e 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/VersionFetcher.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/VersionFetcher.kt @@ -6,7 +6,6 @@ package com.flutter.gradle import com.android.build.api.AndroidPluginVersion import com.android.build.api.variant.AndroidComponentsExtension -import com.android.build.gradle.internal.utils.getKotlinAndroidPluginVersion import org.gradle.api.JavaVersion import org.gradle.api.Project import org.jetbrains.kotlin.gradle.plugin.KotlinAndroidPluginWrapper @@ -42,19 +41,16 @@ internal object VersionFetcher { } /** - * Returns the version of the Kotlin Gradle plugin. + * Returns the version of the Kotlin Gradle plugin, or null if it cannot be determined. + * + * Null is an expected result when the Kotlin Gradle plugin has not been applied to the + * project — most notably under AGP's built-in Kotlin support (`android.builtInKotlin`), + * where there is no standalone KGP. Callers must treat null as "unknown/not applied", + * not as an error. */ internal fun getKGPVersion(project: Project): Version? { - // AGP and Kgp have methods for getting kotlin version. - // AGP's method is internal, we try to use it anyway. // KGP's version in org.jetbrains.kotlin.gradle.plugin.DefaultKotlinBasePlugin is not // available when this method is called. - // When testing call `setAgpKotlinVersionToNull(project)`. - val agpDefinedKgpVersion = getKotlinAndroidPluginVersion(project) - if (agpDefinedKgpVersion != null && agpDefinedKgpVersion != "unknown") { - return Version.fromString(agpDefinedKgpVersion) - } - val kotlinVersionProperty = "kotlin_version" val firstKotlinVersionFieldName = "pluginVersion" val secondKotlinVersionFieldName = "kotlinPluginVersion" @@ -68,7 +64,7 @@ internal object VersionFetcher { .findPlugin(KotlinAndroidPluginWrapper::class.java) // Partial implementation of getKotlinPluginVersion from the comment above. var versionString: String? = kotlinPlugin?.pluginVersion - if (!versionString.isNullOrEmpty()) { + if (!versionString.isNullOrEmpty() && versionString != "unknown") { return Version.fromString(versionString) } // Fall back to reflection. @@ -77,7 +73,7 @@ internal object VersionFetcher { it.name == firstKotlinVersionFieldName || it.name == secondKotlinVersionFieldName } versionString = versionField?.call(kotlinPlugin) as String? - return if (versionString == null) { + return if (versionString == null || versionString == "unknown") { null } else { Version.fromString(versionString) diff --git a/packages/flutter_tools/gradle/src/main/kotlin/plugins/PluginHandler.kt b/packages/flutter_tools/gradle/src/main/kotlin/plugins/PluginHandler.kt index bc771798..142f9836 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/plugins/PluginHandler.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/plugins/PluginHandler.kt @@ -4,21 +4,20 @@ package com.flutter.gradle.plugins -import com.android.builder.model.BuildType +import com.android.build.api.dsl.ApplicationBuildType +import com.flutter.gradle.CompileSdkVersion import com.flutter.gradle.FlutterExtension import com.flutter.gradle.FlutterPluginUtils import com.flutter.gradle.FlutterPluginUtils.addApiDependencies import com.flutter.gradle.FlutterPluginUtils.buildModeFor +import com.flutter.gradle.FlutterPluginUtils.getAndroidExtension import com.flutter.gradle.FlutterPluginUtils.getCompileSdkFromProject -import com.flutter.gradle.FlutterPluginUtils.getLegacyAndroidExtension -import com.flutter.gradle.FlutterPluginUtils.isBuiltAsApp import com.flutter.gradle.FlutterPluginUtils.supportsBuildMode import com.flutter.gradle.NativePluginLoaderReflectionBridge -import org.gradle.api.NamedDomainObjectContainer import org.gradle.api.Project import org.jetbrains.kotlin.gradle.plugin.extraProperties import java.io.File -import com.android.build.gradle.internal.dsl.BuildType as dslBuildType +import com.android.build.api.dsl.BuildType as DslBuildType /** * Handles interactions with the flutter plugins (not Gradle plugins) used by the Flutter project, @@ -111,7 +110,7 @@ class PluginHandler( // Add plugin dependency to the app project. We only want to add dependency // for dev dependencies in non-release builds. project.afterEvaluate { - getLegacyAndroidExtension(project).buildTypes.forEach { buildType -> + getAndroidExtension(project).buildTypes.forEach { buildType -> if (!(pluginObject["dev_dependency"] as Boolean) || buildType.name != "release") { project.dependencies.add("${buildType.name}Api", pluginProject) } @@ -121,12 +120,9 @@ class PluginHandler( // Wait until the Android plugin loaded. pluginProject.afterEvaluate { // Checks if there is a mismatch between the plugin compileSdkVersion and the project compileSdkVersion. - val projectCompileSdkVersion: String = getCompileSdkFromProject(project) - val pluginCompileSdkVersion: String = getCompileSdkFromProject(pluginProject) - // TODO(gmackall): This is doing a string comparison, which is odd and also can be wrong - // when comparing preview versions (against non preview, and also in the - // case of alphabet reset which happened with "Baklava". - if (pluginCompileSdkVersion > projectCompileSdkVersion) { + val projectCompileSdkVersion: CompileSdkVersion = getCompileSdkFromProject(project) + val pluginCompileSdkVersion: CompileSdkVersion = getCompileSdkFromProject(pluginProject) + if (pluginCompileSdkVersion.isHigherThan(projectCompileSdkVersion)) { project.logger.quiet( "Warning: The plugin $pluginName requires Android SDK version $pluginCompileSdkVersion or higher." ) @@ -135,16 +131,45 @@ class PluginHandler( ) } - getLegacyAndroidExtension(project).buildTypes.forEach { buildType -> + copyAppBuildTypesToPlugin(project, pluginProject) + + getAndroidExtension(project).buildTypes.forEach { buildType -> addEmbeddingDependencyToPlugin(project, pluginProject, buildType, engineVersion) } } } + private fun copyAppBuildTypesToPlugin( + project: Project, + pluginProject: Project + ) { + if (!pluginProject.hasProperty("android")) { + return + } + + // Copy the app project's build types onto the plugin project so that its variants + // resolve. These are `initWith` copies, not live aliases: `initWith` copies the + // properties both build types understand (matchingFallbacks included), and + // app-specific properties are additionally copied when both sides are application + // build types. Library build types cannot receive app-specific properties (such + // as isDebuggable) through the public DSL. + val pluginProjectBuildTypes = getAndroidExtension(pluginProject).buildTypes + getAndroidExtension(project).buildTypes.forEach { appBuildType -> + if (pluginProjectBuildTypes.findByName(appBuildType.name) == null) { + pluginProjectBuildTypes.create(appBuildType.name) { + initWith(appBuildType) + if (this is ApplicationBuildType && appBuildType is ApplicationBuildType) { + isDebuggable = appBuildType.isDebuggable + } + } + } + } + } + private fun addEmbeddingDependencyToPlugin( project: Project, pluginProject: Project, - buildType: BuildType, + buildType: DslBuildType, engineVersion: String ) { val flutterBuildMode: String = buildModeFor(buildType) @@ -160,27 +185,6 @@ class PluginHandler( return } - // Copy build types from the app to the plugin. - // This allows to build apps with plugins and custom build types or flavors. - // However, only copy if the plugin is also an app project, since library projects - // cannot have applicationIdSuffix and other app-specific properties. - if (isBuiltAsApp(pluginProject)) { - (getLegacyAndroidExtension(pluginProject).buildTypes as NamedDomainObjectContainer) - .addAll(getLegacyAndroidExtension(project).buildTypes as NamedDomainObjectContainer) - } else { - // For library projects, create compatible build types without app-specific properties - getLegacyAndroidExtension(project).buildTypes.forEach { appBuildType -> - if (getLegacyAndroidExtension(pluginProject).buildTypes.findByName(appBuildType.name) == null) { - getLegacyAndroidExtension(pluginProject).buildTypes.create(appBuildType.name) { - // Copy library-compatible properties only - isDebuggable = appBuildType.isDebuggable - isMinifyEnabled = appBuildType.isMinifyEnabled - // Note: applicationIdSuffix and other app-specific properties are intentionally not copied - } - } - } - } - // The embedding is API dependency of the plugin, so the AGP is able to desugar // default method implementations when the interface is implemented by a plugin. // @@ -215,7 +219,10 @@ class PluginHandler( } val pluginProject: Project = project.rootProject.findProject(":$pluginName") ?: return - getLegacyAndroidExtension(project).buildTypes.forEach { buildType -> + // Warning: Iterating buildTypes causes the project-level 'implementation' dependency to be added multiple times. + // When an app defines 3 build types (debug, profile, release), this registers 3 + // separate afterEvaluate actions adding the same dependencyProject. + getAndroidExtension(project).buildTypes.forEach { buildType -> val flutterBuildMode: String = buildModeFor(buildType) if (flutterBuildMode == "release" && (pluginObject["dev_dependency"] as? Boolean == true)) { // This plugin is a dev dependency will not be included in the @@ -232,7 +239,6 @@ class PluginHandler( val dependencyProject = project.rootProject.findProject(":$pluginDependencyName") ?: return@innerForEach pluginProject.afterEvaluate { - // this.dependencies.add("implementation", dependencyProject) pluginProject.dependencies.add("implementation", dependencyProject) } } diff --git a/packages/flutter_tools/gradle/src/main/kotlin/tasks/CopyFlutterApksTask.kt b/packages/flutter_tools/gradle/src/main/kotlin/tasks/CopyFlutterApksTask.kt new file mode 100644 index 00000000..b1500b96 --- /dev/null +++ b/packages/flutter_tools/gradle/src/main/kotlin/tasks/CopyFlutterApksTask.kt @@ -0,0 +1,147 @@ +// Copyright 2014 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. + +package com.flutter.gradle.tasks + +import com.android.build.api.variant.BuiltArtifactsLoader +import com.android.build.api.variant.FilterConfiguration +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.FileSystemOperations +import org.gradle.api.file.RegularFile +import org.gradle.api.provider.ListProperty +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.Optional +import org.gradle.api.tasks.OutputFiles +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.work.DisableCachingByDefault +import javax.inject.Inject + +/** + * Copies one variant's APKs into the flutter-apk directory, renamed to what the Flutter tool + * expects (see [apkFileName]). + * + * Every variant writes into the same directory, so the task declares the individual + * [outputApks] as outputs rather than the directory. + * + * Fails if another plugin transforms `SingleArtifact.APK` without writing `output-metadata.json`, + * or into APKs whose ABI filters differ from the variant outputs. Flutter cannot fix either, so + * the error points at that plugin. + */ +@DisableCachingByDefault(because = "Copies local APKs; a cache entry would only duplicate them") +abstract class CopyFlutterApksTask : DefaultTask() { + /** The variant's `SingleArtifact.APK` directory: the APKs and AGP's metadata file. */ + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val apkDirectory: DirectoryProperty + + /** Reads the metadata in [apkDirectory], which already covers its inputs. */ + @get:Internal + abstract val builtArtifactsLoader: Property + + /** The ABI of each variant output, or [NO_ABI] for an output without an ABI filter. */ + @get:Input + abstract val outputAbis: ListProperty + + /** The variant's flavor name. Absent when the project has no product flavors. */ + @get:Optional + @get:Input + abstract val flavorName: Property + + /** The Flutter build mode: "debug", "profile" or "release". */ + @get:Input + abstract val buildMode: Property + + /** The flutter-apk directory, shared by every variant, so not an output itself. */ + @get:Internal + abstract val destinationDir: DirectoryProperty + + /** The files this task writes into [destinationDir], one per entry in [outputAbis]. */ + @get:OutputFiles + val outputApks: Provider> + get() = + destinationDir.zip(outputAbis) { dir, abis -> + abis.map { abi -> dir.file(apkFileName(abi, flavorName.orNull, buildMode.get())) } + } + + @get:Inject + abstract val fileSystemOperations: FileSystemOperations + + @TaskAction + fun copyApks() { + val apkDir = apkDirectory.get() + // `load` returns null only when `output-metadata.json` is missing. + val builtArtifacts = + builtArtifactsLoader.get().load(apkDir) + ?: throw transformedApkError( + problem = + "Flutter could not read the APK metadata in $apkDir " + + "(no output-metadata.json), so it cannot copy the APKs to " + + "${destinationDir.get()}.", + fix = + "write output-metadata.json, for example with " + + "ArtifactTransformationRequest or BuiltArtifacts.save()" + ) + val declaredAbis = outputAbis.get() + builtArtifacts.elements.forEach { artifact -> + val abi = abiOf(artifact.filters) + // With AGP alone this always matches, because both come from the variant outputs. + if (abi !in declaredAbis) { + throw transformedApkError( + problem = + "Flutter expected APKs for the ABIs $declaredAbis, but the APK metadata " + + "in $apkDir lists ${artifact.outputFile} for ABI '$abi'.", + fix = "keep one APK per variant output, with the same ABI filters" + ) + } + val fileName = apkFileName(abi, flavorName.orNull, buildMode.get()) + fileSystemOperations.copy { + from(artifact.outputFile) + into(destinationDir) + rename { fileName } + } + } + } + + companion object { + /** The [outputAbis] entry for an APK that has no ABI filter. */ + const val NO_ABI: String = "" + + /** The ABI that [filters] select, or [NO_ABI] if they have no ABI filter. */ + internal fun abiOf(filters: Collection): String = + filters.find { it.filterType == FilterConfiguration.FilterType.ABI }?.identifier ?: NO_ABI + + /** + * `app[-][-]-.apk`, with the flavor in lower case. + * + * Must match `listApkPaths` in `packages/flutter_tools/lib/src/android/gradle.dart`. + */ + internal fun apkFileName( + abi: String, + flavorName: String?, + buildMode: String + ): String { + val abiPart = if (abi == NO_ABI) "" else "-$abi" + val flavorPart = if (flavorName.isNullOrEmpty()) "" else "-${flavorName.lowercase()}" + return "app$abiPart$flavorPart-$buildMode.apk" + } + + private fun transformedApkError( + problem: String, + fix: String + ): GradleException = + GradleException( + "$problem Another Gradle plugin or build script in this project transforms the " + + "APK artifact (SingleArtifact.APK). Find the plugin or script that transforms " + + "SingleArtifact.APK, and ask its maintainer to $fix." + ) + } +} diff --git a/packages/flutter_tools/gradle/src/main/kotlin/tasks/CopyFlutterAssetsTask.kt b/packages/flutter_tools/gradle/src/main/kotlin/tasks/CopyFlutterAssetsTask.kt new file mode 100644 index 00000000..bb05b519 --- /dev/null +++ b/packages/flutter_tools/gradle/src/main/kotlin/tasks/CopyFlutterAssetsTask.kt @@ -0,0 +1,62 @@ +// Copyright 2014 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. + +package com.flutter.gradle.tasks + +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.file.FileSystemOperations +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction +import org.gradle.work.DisableCachingByDefault +import javax.inject.Inject + +/** + * Stages the `flutter_assets` directory produced by the Flutter build into a dedicated + * [destinationDir], which is registered with the variant as a generated assets source + * directory (`variant.sources.assets.addGeneratedSourceDirectory`). AGP then merges it like + * any other assets source; collisions with user assets resolve by AGP source-set priority. + * (The pre-migration behavior was a copy into the merged-assets output after merging, which + * silently overwrote colliding user assets.) + * + * It deliberately writes to its own output directory rather than reusing the Flutter task's + * output directory, for the same overlapping-output reasons as [CopyFlutterJniLibsTask]. + */ +@DisableCachingByDefault(because = "Syncing local asset directories is faster than unpacking cache archives") +abstract class CopyFlutterAssetsTask : DefaultTask() { + /** + * The Flutter build output directory (the `flutter assemble` `--output` location). + * + * Required, because this task is only registered together with its variant's compile task. + */ + @get:InputDirectory + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val intermediateDir: DirectoryProperty + + @get:OutputDirectory + abstract val destinationDir: DirectoryProperty + + @get:Inject + abstract val fileSystemOperations: FileSystemOperations + + @TaskAction + fun copy() { + fileSystemOperations.sync { + into(destinationDir) + from(intermediateDir) { + // The engine loads assets from `flutter_assets/` by default, so keep the prefix. + include(FlutterTaskHelper.FLUTTER_ASSETS_INCLUDE_DIRECTORY) + } + filePermissions { + user { + read = true + write = true + } + } + } + } +} diff --git a/packages/flutter_tools/gradle/src/main/kotlin/tasks/CopyFlutterJniLibsTask.kt b/packages/flutter_tools/gradle/src/main/kotlin/tasks/CopyFlutterJniLibsTask.kt index 6950e827..8bc990f3 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/tasks/CopyFlutterJniLibsTask.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/tasks/CopyFlutterJniLibsTask.kt @@ -11,7 +11,6 @@ import org.gradle.api.file.FileSystemOperations import org.gradle.api.provider.ListProperty import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputDirectory -import org.gradle.api.tasks.Optional import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.PathSensitive import org.gradle.api.tasks.PathSensitivity @@ -34,11 +33,8 @@ abstract class CopyFlutterJniLibsTask : DefaultTask() { /** * The Flutter build output directory (the `flutter assemble` `--output` location). * - * Optional: it is absent when there is no Flutter compile task for the variant (e.g. an - * `assembleAndroidTest` build), in which case this task stages nothing. See - * https://github.com/flutter/flutter/issues/188785. + * Required, because this task is only registered together with its variant's compile task. */ - @get:Optional @get:InputDirectory @get:PathSensitive(PathSensitivity.RELATIVE) abstract val intermediateDir: DirectoryProperty @@ -56,21 +52,17 @@ abstract class CopyFlutterJniLibsTask : DefaultTask() { fun copy() { fileSystemOperations.sync { into(destinationDir) - // When there is no Flutter build for this variant (e.g. an assembleAndroidTest build), - // there is nothing to stage; the empty sync simply clears destinationDir. - if (intermediateDir.isPresent) { - targetPlatforms.get().forEach { targetPlatform -> - val abi: String? = FlutterPluginConstants.PLATFORM_ARCH_MAP[targetPlatform] - from(intermediateDir.dir(abi ?: "null")) { - include("*.so") - rename { filename: String -> "lib$filename" } - into(abi ?: "null") - } - val nativeAssetsDir = intermediateDir.dir("native_assets/jniLibs/lib/$abi") - from(nativeAssetsDir) { - include("*.so") - into(abi ?: "null") - } + targetPlatforms.get().forEach { targetPlatform -> + val abi: String? = FlutterPluginConstants.PLATFORM_ARCH_MAP[targetPlatform] + from(intermediateDir.dir(abi ?: "null")) { + include("*.so") + rename { filename: String -> "lib$filename" } + into(abi ?: "null") + } + val nativeAssetsDir = intermediateDir.dir("native_assets/jniLibs/lib/$abi") + from(nativeAssetsDir) { + include("*.so") + into(abi ?: "null") } } } diff --git a/packages/flutter_tools/gradle/src/main/kotlin/tasks/DeepLinkJsonFromManifestTask.kt b/packages/flutter_tools/gradle/src/main/kotlin/tasks/DeepLinkJsonFromManifestTask.kt index d3b470ab..4501864e 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/tasks/DeepLinkJsonFromManifestTask.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/tasks/DeepLinkJsonFromManifestTask.kt @@ -29,9 +29,9 @@ abstract class DeepLinkJsonFromManifestTask : DefaultTask() { @get:PathSensitive(PathSensitivity.RELATIVE) abstract val manifestFile: RegularFileProperty - // In the past for this task namespace was the ApplicationId. + // The resolved application ID for this variant. @get:Input - abstract val namespace: Property + abstract val applicationId: Property // Does not need to transform manifest at all but there does not appear to be another dsl // supported way to depend on the merged manifest. @@ -46,7 +46,11 @@ abstract class DeepLinkJsonFromManifestTask : DefaultTask() { manifestFile.get().asFile.copyTo(updatedManifest.get().asFile, overwrite = true) logger.debug("DeepLinkJsonFromManifestTask: Unmodified manifest written.") - DeepLinkJsonFromManifestTaskHelper.createAppLinkSettingsFile(namespace.get(), manifestFile, deepLinkJson) + DeepLinkJsonFromManifestTaskHelper.createAppLinkSettingsFile( + applicationId.get(), + manifestFile, + deepLinkJson + ) logger.debug("DeepLinkJsonFromManifestTask: appLinkSettings written to ${deepLinkJson.get().asFile.absolutePath}.") } } diff --git a/packages/flutter_tools/gradle/src/main/kotlin/tasks/GenerateEngineFlagsManifestTask.kt b/packages/flutter_tools/gradle/src/main/kotlin/tasks/GenerateEngineFlagsManifestTask.kt new file mode 100644 index 00000000..6448f09a --- /dev/null +++ b/packages/flutter_tools/gradle/src/main/kotlin/tasks/GenerateEngineFlagsManifestTask.kt @@ -0,0 +1,52 @@ +// Copyright 2014 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. + +package com.flutter.gradle.tasks + +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction +import java.nio.charset.StandardCharsets +import java.util.Base64 + +abstract class GenerateEngineFlagsManifestTask : DefaultTask() { + @get:Input + abstract val engineShellArgsJson: Property + + @get:OutputFile + abstract val manifestOutputFile: RegularFileProperty + + @TaskAction + fun generateManifest() { + val base64Encoded = engineShellArgsJson.get() + val decodedJsonStr = String(Base64.getDecoder().decode(base64Encoded), StandardCharsets.UTF_8) + val escapedValue = escapeXml(decodedJsonStr) + val metaDataTags = "" + + val manifestContent = + """ + + + + $metaDataTags + + + """.trimIndent() + + val outputFile = manifestOutputFile.get().asFile + outputFile.parentFile.mkdirs() + outputFile.writeText(manifestContent) + } + + private fun escapeXml(str: String): String = + str + .replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """) + .replace("'", "'") +} diff --git a/packages/flutter_tools/gradle/src/test/kotlin/AgpCommonExtensionWrapperTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/AgpCommonExtensionWrapperTest.kt index 2c13a22b..ee44ed2c 100644 --- a/packages/flutter_tools/gradle/src/test/kotlin/AgpCommonExtensionWrapperTest.kt +++ b/packages/flutter_tools/gradle/src/test/kotlin/AgpCommonExtensionWrapperTest.kt @@ -4,46 +4,227 @@ package com.flutter.gradle +import com.android.build.api.dsl.ApplicationBuildType import com.android.build.api.dsl.ApplicationExtension +import com.android.build.api.dsl.DynamicFeatureBuildType +import com.android.build.api.dsl.DynamicFeatureExtension +import com.android.build.api.dsl.ExternalNativeBuild +import com.android.build.api.dsl.LibraryBuildType import com.android.build.api.dsl.LibraryExtension import com.android.build.api.dsl.Splits +import com.android.build.api.dsl.TestBuildType +import com.android.build.api.dsl.TestExtension import io.mockk.every import io.mockk.mockk +import io.mockk.verify +import org.gradle.api.NamedDomainObjectContainer +import java.io.File import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertSame class AgpCommonExtensionWrapperTest { @Test - fun `splits delegates to the backing application extension`() { - val mockSplits = mockk(relaxed = true) - val mockApplicationExtension = - mockk(relaxed = true) { - every { splits } returns mockSplits - } + fun `compileSdk delegates to application, library, dynamicFeature, and test extensions`() { + val appExt = mockk(relaxed = true) { every { compileSdk } returns 35 } + val libExt = mockk(relaxed = true) { every { compileSdk } returns 34 } + val dfExt = mockk(relaxed = true) { every { compileSdk } returns 33 } + val testExt = mockk(relaxed = true) { every { compileSdk } returns 32 } + + assertEquals(35, AgpCommonExtensionWrapper(appExt).compileSdk) + assertEquals(34, AgpCommonExtensionWrapper(libExt).compileSdk) + assertEquals(33, AgpCommonExtensionWrapper(dfExt).compileSdk) + assertEquals(32, AgpCommonExtensionWrapper(testExt).compileSdk) + + AgpCommonExtensionWrapper(appExt).compileSdk = 36 + verify { appExt.compileSdk = 36 } + + AgpCommonExtensionWrapper(libExt).compileSdk = 37 + verify { libExt.compileSdk = 37 } + + AgpCommonExtensionWrapper(dfExt).compileSdk = 38 + verify { dfExt.compileSdk = 38 } + + AgpCommonExtensionWrapper(testExt).compileSdk = 39 + verify { testExt.compileSdk = 39 } + } + + @Test + fun `compileSdkPreview delegates to application, library, dynamicFeature, and test extensions`() { + val appExt = mockk(relaxed = true) { every { compileSdkPreview } returns "Baklava" } + val libExt = mockk(relaxed = true) { every { compileSdkPreview } returns "VanillaIceCream" } + val dfExt = mockk(relaxed = true) { every { compileSdkPreview } returns "UpsideDownCake" } + val testExt = mockk(relaxed = true) { every { compileSdkPreview } returns "Tiramisu" } + + assertEquals("Baklava", AgpCommonExtensionWrapper(appExt).compileSdkPreview) + assertEquals("VanillaIceCream", AgpCommonExtensionWrapper(libExt).compileSdkPreview) + assertEquals("UpsideDownCake", AgpCommonExtensionWrapper(dfExt).compileSdkPreview) + assertEquals("Tiramisu", AgpCommonExtensionWrapper(testExt).compileSdkPreview) + + AgpCommonExtensionWrapper(appExt).compileSdkPreview = "AppPreview" + verify { appExt.compileSdkPreview = "AppPreview" } + + AgpCommonExtensionWrapper(libExt).compileSdkPreview = "LibPreview" + verify { libExt.compileSdkPreview = "LibPreview" } + + AgpCommonExtensionWrapper(dfExt).compileSdkPreview = "DfPreview" + verify { dfExt.compileSdkPreview = "DfPreview" } + + AgpCommonExtensionWrapper(testExt).compileSdkPreview = "TestPreview" + verify { testExt.compileSdkPreview = "TestPreview" } + } + + @Test + fun `namespace delegates to application, library, dynamicFeature, and test extensions`() { + val appExt = mockk(relaxed = true) { every { namespace } returns "com.example.app" } + val libExt = mockk(relaxed = true) { every { namespace } returns "com.example.lib" } + val dfExt = mockk(relaxed = true) { every { namespace } returns "com.example.df" } + val testExt = mockk(relaxed = true) { every { namespace } returns "com.example.test" } + + assertEquals("com.example.app", AgpCommonExtensionWrapper(appExt).namespace) + assertEquals("com.example.lib", AgpCommonExtensionWrapper(libExt).namespace) + assertEquals("com.example.df", AgpCommonExtensionWrapper(dfExt).namespace) + assertEquals("com.example.test", AgpCommonExtensionWrapper(testExt).namespace) + + AgpCommonExtensionWrapper(appExt).namespace = "com.example.app.updated" + verify { appExt.namespace = "com.example.app.updated" } + + AgpCommonExtensionWrapper(libExt).namespace = "com.example.lib.updated" + verify { libExt.namespace = "com.example.lib.updated" } + + AgpCommonExtensionWrapper(dfExt).namespace = "com.example.df.updated" + verify { dfExt.namespace = "com.example.df.updated" } + + AgpCommonExtensionWrapper(testExt).namespace = "com.example.test.updated" + verify { testExt.namespace = "com.example.test.updated" } + } + + @Test + fun `ndkVersion delegates to application, library, dynamicFeature, and test extensions`() { + val appExt = mockk(relaxed = true) { every { ndkVersion } returns "29.0.1" } + val libExt = mockk(relaxed = true) { every { ndkVersion } returns "29.0.2" } + val dfExt = mockk(relaxed = true) { every { ndkVersion } returns "29.0.3" } + val testExt = mockk(relaxed = true) { every { ndkVersion } returns "29.0.4" } + + assertEquals("29.0.1", AgpCommonExtensionWrapper(appExt).ndkVersion) + assertEquals("29.0.2", AgpCommonExtensionWrapper(libExt).ndkVersion) + assertEquals("29.0.3", AgpCommonExtensionWrapper(dfExt).ndkVersion) + assertEquals("29.0.4", AgpCommonExtensionWrapper(testExt).ndkVersion) + + AgpCommonExtensionWrapper(appExt).ndkVersion = "29.0.5" + verify { appExt.ndkVersion = "29.0.5" } + + AgpCommonExtensionWrapper(libExt).ndkVersion = "29.0.6" + verify { libExt.ndkVersion = "29.0.6" } + + AgpCommonExtensionWrapper(dfExt).ndkVersion = "29.0.7" + verify { dfExt.ndkVersion = "29.0.7" } + + AgpCommonExtensionWrapper(testExt).ndkVersion = "29.0.8" + verify { testExt.ndkVersion = "29.0.8" } + } + + @Test + fun `buildTypes delegates to application, library, dynamicFeature, and test extensions`() { + val appContainer = mockk>() + val libContainer = mockk>() + val dfContainer = mockk>() + val testContainer = mockk>() - val wrapper = AgpCommonExtensionWrapper(mockApplicationExtension) + val appExt = mockk(relaxed = true) { every { buildTypes } returns appContainer } + val libExt = mockk(relaxed = true) { every { buildTypes } returns libContainer } + val dfExt = mockk(relaxed = true) { every { buildTypes } returns dfContainer } + val testExt = mockk(relaxed = true) { every { buildTypes } returns testContainer } - assertSame(mockSplits, wrapper.splits) + assertSame(appContainer, AgpCommonExtensionWrapper(appExt).buildTypes) + assertSame(libContainer, AgpCommonExtensionWrapper(libExt).buildTypes) + assertSame(dfContainer, AgpCommonExtensionWrapper(dfExt).buildTypes) + assertSame(testContainer, AgpCommonExtensionWrapper(testExt).buildTypes) } @Test - fun `splits delegates to the backing library extension`() { + fun `splits delegates to application, library, dynamicFeature, and test extensions`() { val mockSplits = mockk(relaxed = true) - val mockLibraryExtension = - mockk(relaxed = true) { - every { splits } returns mockSplits - } + val appExt = mockk(relaxed = true) { every { splits } returns mockSplits } + val libExt = mockk(relaxed = true) { every { splits } returns mockSplits } + val dfExt = mockk(relaxed = true) { every { splits } returns mockSplits } + val testExt = mockk(relaxed = true) { every { splits } returns mockSplits } + + assertSame(mockSplits, AgpCommonExtensionWrapper(appExt).splits) + assertSame(mockSplits, AgpCommonExtensionWrapper(libExt).splits) + assertSame(mockSplits, AgpCommonExtensionWrapper(dfExt).splits) + assertSame(mockSplits, AgpCommonExtensionWrapper(testExt).splits) + } + + @Test + fun `getDefaultProguardFile delegates to application, library, dynamicFeature, and test extensions`() { + val fileApp = File("/app/proguard.txt") + val fileLib = File("/lib/proguard.txt") + val fileDf = File("/df/proguard.txt") + val fileTest = File("/test/proguard.txt") + + val appExt = mockk(relaxed = true) { every { getDefaultProguardFile("file.txt") } returns fileApp } + val libExt = mockk(relaxed = true) { every { getDefaultProguardFile("file.txt") } returns fileLib } + val dfExt = mockk(relaxed = true) { every { getDefaultProguardFile("file.txt") } returns fileDf } + val testExt = mockk(relaxed = true) { every { getDefaultProguardFile("file.txt") } returns fileTest } + + assertSame(fileApp, AgpCommonExtensionWrapper(appExt).getDefaultProguardFile("file.txt")) + assertSame(fileLib, AgpCommonExtensionWrapper(libExt).getDefaultProguardFile("file.txt")) + assertSame(fileDf, AgpCommonExtensionWrapper(dfExt).getDefaultProguardFile("file.txt")) + assertSame(fileTest, AgpCommonExtensionWrapper(testExt).getDefaultProguardFile("file.txt")) + } + + @Test + fun `compileOptions delegates to application, library, dynamicFeature, and test extensions`() { + val appExt = mockk(relaxed = true) + val libExt = mockk(relaxed = true) + val dfExt = mockk(relaxed = true) + val testExt = mockk(relaxed = true) - val wrapper = AgpCommonExtensionWrapper(mockLibraryExtension) + AgpCommonExtensionWrapper(appExt).compileOptions {} + verify { appExt.compileOptions(any()) } + + AgpCommonExtensionWrapper(libExt).compileOptions {} + verify { libExt.compileOptions(any()) } + + AgpCommonExtensionWrapper(dfExt).compileOptions {} + verify { dfExt.compileOptions(any()) } + + AgpCommonExtensionWrapper(testExt).compileOptions {} + verify { testExt.compileOptions(any()) } + } + + @Test + fun `externalNativeBuild delegates to application, library, dynamicFeature, and test extensions`() { + val mockExternalNativeBuild = mockk(relaxed = true) + val appExt = mockk(relaxed = true) { every { externalNativeBuild } returns mockExternalNativeBuild } + val libExt = mockk(relaxed = true) { every { externalNativeBuild } returns mockExternalNativeBuild } + val dfExt = mockk(relaxed = true) { every { externalNativeBuild } returns mockExternalNativeBuild } + val testExt = mockk(relaxed = true) { every { externalNativeBuild } returns mockExternalNativeBuild } - assertSame(mockSplits, wrapper.splits) + assertSame(mockExternalNativeBuild, AgpCommonExtensionWrapper(appExt).externalNativeBuild) + assertSame(mockExternalNativeBuild, AgpCommonExtensionWrapper(libExt).externalNativeBuild) + assertSame(mockExternalNativeBuild, AgpCommonExtensionWrapper(dfExt).externalNativeBuild) + assertSame(mockExternalNativeBuild, AgpCommonExtensionWrapper(testExt).externalNativeBuild) } @Test - fun `splits throws for an unsupported backing extension type`() { + fun `wrapper throws for an unsupported backing extension type`() { val wrapper = AgpCommonExtensionWrapper("not an android extension") assertFailsWith { wrapper.splits } + assertFailsWith { wrapper.buildTypes } + assertFailsWith { wrapper.compileSdk } + assertFailsWith { wrapper.compileSdk = 35 } + assertFailsWith { wrapper.compileSdkPreview } + assertFailsWith { wrapper.compileSdkPreview = "Baklava" } + assertFailsWith { wrapper.namespace } + assertFailsWith { wrapper.namespace = "com.example" } + assertFailsWith { wrapper.ndkVersion } + assertFailsWith { wrapper.ndkVersion = "29.0.0" } + assertFailsWith { wrapper.getDefaultProguardFile("proguard-android.txt") } + assertFailsWith { wrapper.compileOptions {} } + assertFailsWith { wrapper.externalNativeBuild } } } diff --git a/packages/flutter_tools/gradle/src/test/kotlin/BaseApplicationNameHandlerTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/BaseApplicationNameHandlerTest.kt index e0d6b0ef..907c9d14 100644 --- a/packages/flutter_tools/gradle/src/test/kotlin/BaseApplicationNameHandlerTest.kt +++ b/packages/flutter_tools/gradle/src/test/kotlin/BaseApplicationNameHandlerTest.kt @@ -7,10 +7,11 @@ package com.flutter.gradle import com.android.build.api.dsl.ApplicationDefaultConfig import com.android.build.api.dsl.ApplicationExtension import com.flutter.gradle.BaseApplicationNameHandler.GRADLE_BASE_APPLICATION_NAME_PROPERTY +import io.mockk.every +import io.mockk.mockk import org.gradle.api.Project import org.gradle.api.plugins.ExtensionContainer import org.junit.jupiter.api.Assertions.assertEquals -import org.mockito.Mockito import kotlin.test.Test class BaseApplicationNameHandlerTest { @@ -19,19 +20,19 @@ class BaseApplicationNameHandlerTest { val baseApplicationNamePassedByFlutterTool = "toolSetBaseApplicationName" // Set up mocks. - val mockProject: Project = Mockito.mock(Project::class.java) - val mockAndroidComponentsExtension: ApplicationExtension = Mockito.mock(ApplicationExtension::class.java) - val mockExtensionContainer: ExtensionContainer = Mockito.mock(ExtensionContainer::class.java) - val mockDefaultConfig = Mockito.mock(ApplicationDefaultConfig::class.java) + val mockProject = mockk() + val mockAndroidComponentsExtension = mockk() + val mockExtensionContainer = mockk() + val mockDefaultConfig = mockk() val mockManifestPlaceholders = HashMap() - Mockito.`when`(mockProject.hasProperty(GRADLE_BASE_APPLICATION_NAME_PROPERTY)).thenReturn(true) - Mockito.`when`(mockProject.property(GRADLE_BASE_APPLICATION_NAME_PROPERTY)).thenReturn(baseApplicationNamePassedByFlutterTool) + every { mockProject.hasProperty(GRADLE_BASE_APPLICATION_NAME_PROPERTY) } returns true + every { mockProject.property(GRADLE_BASE_APPLICATION_NAME_PROPERTY) } returns baseApplicationNamePassedByFlutterTool - Mockito.`when`(mockProject.extensions).thenReturn(mockExtensionContainer) - Mockito.`when`(mockExtensionContainer.findByType(ApplicationExtension::class.java)).thenReturn(mockAndroidComponentsExtension) - Mockito.`when`(mockAndroidComponentsExtension.defaultConfig).thenReturn(mockDefaultConfig) - Mockito.`when`(mockDefaultConfig.manifestPlaceholders).thenReturn(mockManifestPlaceholders) + every { mockProject.extensions } returns mockExtensionContainer + every { mockExtensionContainer.findByType(ApplicationExtension::class.java) } returns mockAndroidComponentsExtension + every { mockAndroidComponentsExtension.defaultConfig } returns mockDefaultConfig + every { mockDefaultConfig.manifestPlaceholders } returns mockManifestPlaceholders // Call the base name handler. BaseApplicationNameHandler.setBaseName(mockProject) @@ -43,18 +44,18 @@ class BaseApplicationNameHandlerTest { @Test fun `setBaseName defaults to correct value`() { // Set up mocks. - val mockProject: Project = Mockito.mock(Project::class.java) - val mockAndroidComponentsExtension: ApplicationExtension = Mockito.mock(ApplicationExtension::class.java) - val mockExtensionContainer: ExtensionContainer = Mockito.mock(ExtensionContainer::class.java) - val mockDefaultConfig = Mockito.mock(ApplicationDefaultConfig::class.java) + val mockProject = mockk() + val mockAndroidComponentsExtension = mockk() + val mockExtensionContainer = mockk() + val mockDefaultConfig = mockk() val mockManifestPlaceholders = HashMap() - Mockito.`when`(mockProject.hasProperty(GRADLE_BASE_APPLICATION_NAME_PROPERTY)).thenReturn(false) + every { mockProject.hasProperty(GRADLE_BASE_APPLICATION_NAME_PROPERTY) } returns false - Mockito.`when`(mockProject.extensions).thenReturn(mockExtensionContainer) - Mockito.`when`(mockExtensionContainer.findByType(ApplicationExtension::class.java)).thenReturn(mockAndroidComponentsExtension) - Mockito.`when`(mockAndroidComponentsExtension.defaultConfig).thenReturn(mockDefaultConfig) - Mockito.`when`(mockDefaultConfig.manifestPlaceholders).thenReturn(mockManifestPlaceholders) + every { mockProject.extensions } returns mockExtensionContainer + every { mockExtensionContainer.findByType(ApplicationExtension::class.java) } returns mockAndroidComponentsExtension + every { mockAndroidComponentsExtension.defaultConfig } returns mockDefaultConfig + every { mockDefaultConfig.manifestPlaceholders } returns mockManifestPlaceholders // Call the base name handler. BaseApplicationNameHandler.setBaseName(mockProject) diff --git a/packages/flutter_tools/gradle/src/test/kotlin/CompileSdkVersionTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/CompileSdkVersionTest.kt new file mode 100644 index 00000000..9992956b --- /dev/null +++ b/packages/flutter_tools/gradle/src/test/kotlin/CompileSdkVersionTest.kt @@ -0,0 +1,82 @@ +// Copyright 2014 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. + +package com.flutter.gradle + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith + +class CompileSdkVersionTest { + @Test + fun `constructor rejects both apiLevel and previewCodename set`() { + assertFailsWith { + CompileSdkVersion(apiLevel = 36, previewCodename = "Baklava") + } + } + + @Test + fun `constructor accepts numeric api level`() { + val version = CompileSdkVersion(apiLevel = 35, previewCodename = null) + assertEquals(35, version.apiLevel) + assertEquals(null, version.previewCodename) + } + + @Test + fun `constructor accepts preview codename`() { + val version = CompileSdkVersion(apiLevel = null, previewCodename = "Baklava") + assertEquals(null, version.apiLevel) + assertEquals("Baklava", version.previewCodename) + } + + @Test + fun `constructor accepts unset values`() { + val version = CompileSdkVersion(apiLevel = null, previewCodename = null) + assertEquals(null, version.apiLevel) + assertEquals(null, version.previewCodename) + } + + @Test + fun `isHigherThan compares numeric api levels numerically`() { + val sdk35 = CompileSdkVersion(apiLevel = 35, previewCodename = null) + val sdk36 = CompileSdkVersion(apiLevel = 36, previewCodename = null) + assertEquals(true, sdk36.isHigherThan(sdk35)) + assertEquals(false, sdk35.isHigherThan(sdk36)) + assertEquals(false, sdk35.isHigherThan(sdk35)) + } + + @Test + fun `isHigherThan treats a preview codename as higher than any numeric api level`() { + val preview = CompileSdkVersion(apiLevel = null, previewCodename = "Baklava") + val numeric = CompileSdkVersion(apiLevel = 36, previewCodename = null) + assertEquals(true, preview.isHigherThan(numeric)) + assertEquals(false, numeric.isHigherThan(preview)) + } + + @Test + fun `isHigherThan treats distinct preview codenames as incomparable`() { + // Codenames stopped being alphabetically ordered at the "Baklava" alphabet reset, + // so neither side may claim to be higher. + val baklava = CompileSdkVersion(apiLevel = null, previewCodename = "Baklava") + val vanilla = CompileSdkVersion(apiLevel = null, previewCodename = "VanillaIceCream") + assertEquals(false, baklava.isHigherThan(vanilla)) + assertEquals(false, vanilla.isHigherThan(baklava)) + assertEquals(false, baklava.isHigherThan(baklava)) + } + + @Test + fun `isHigherThan returns false when either side is unset`() { + val unset = CompileSdkVersion(apiLevel = null, previewCodename = null) + val numeric = CompileSdkVersion(apiLevel = 36, previewCodename = null) + assertEquals(false, unset.isHigherThan(numeric)) + assertEquals(false, numeric.isHigherThan(unset)) + } + + @Test + fun `toString formats preview or apiLevel or unknown`() { + assertEquals("Baklava", CompileSdkVersion(apiLevel = null, previewCodename = "Baklava").toString()) + assertEquals("35", CompileSdkVersion(apiLevel = 35, previewCodename = null).toString()) + assertEquals("unknown", CompileSdkVersion(apiLevel = null, previewCodename = null).toString()) + } +} diff --git a/packages/flutter_tools/gradle/src/test/kotlin/DeeplinkTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/DeeplinkTest.kt index 1b779b21..19151c05 100644 --- a/packages/flutter_tools/gradle/src/test/kotlin/DeeplinkTest.kt +++ b/packages/flutter_tools/gradle/src/test/kotlin/DeeplinkTest.kt @@ -4,9 +4,9 @@ package com.flutter.gradle -import org.gradle.internal.impldep.org.junit.Assert.assertThrows import kotlin.test.Test import kotlin.test.assertContains +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -41,7 +41,7 @@ class DeeplinkTest { val deeplink1 = Deeplink("scheme1", "host1", "path1", IntentFilterCheck()) val deeplink2 = null - assertThrows(NullPointerException::class.java) { deeplink1.equals(deeplink2) } + assertFailsWith { deeplink1.equals(deeplink2) } } @Test diff --git a/packages/flutter_tools/gradle/src/test/kotlin/DependencyVersionCheckerTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/DependencyVersionCheckerTest.kt index 99c7b629..0586ec49 100644 --- a/packages/flutter_tools/gradle/src/test/kotlin/DependencyVersionCheckerTest.kt +++ b/packages/flutter_tools/gradle/src/test/kotlin/DependencyVersionCheckerTest.kt @@ -19,8 +19,10 @@ import com.flutter.gradle.DependencyVersionChecker.errorGradleVersion import com.flutter.gradle.DependencyVersionChecker.errorJavaVersion import com.flutter.gradle.DependencyVersionChecker.errorKGPVersion import com.flutter.gradle.DependencyVersionChecker.errorMinSdkVersion +import com.flutter.gradle.DependencyVersionChecker.firstUnsupportedAGPMajorVersion import com.flutter.gradle.DependencyVersionChecker.getErrorMessage import com.flutter.gradle.DependencyVersionChecker.getFlavorSpecificMessage +import com.flutter.gradle.DependencyVersionChecker.getFutureUnsupportedMajorVersionErrorMessage import com.flutter.gradle.DependencyVersionChecker.getPotentialAGPFix import com.flutter.gradle.DependencyVersionChecker.getPotentialGradleFix import com.flutter.gradle.DependencyVersionChecker.getPotentialKGPFix @@ -30,7 +32,6 @@ import com.flutter.gradle.DependencyVersionChecker.warnAGPVersion import com.flutter.gradle.DependencyVersionChecker.warnGradleVersion import com.flutter.gradle.DependencyVersionChecker.warnKGPVersion import com.flutter.gradle.DependencyVersionChecker.warnMinSdkVersion -import com.flutter.gradle.testing.setAgpKotlinVersionToNull import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic @@ -42,6 +43,8 @@ import org.gradle.api.Project import org.gradle.api.Task import org.gradle.api.logging.Logger import org.gradle.api.plugins.ExtraPropertiesExtension +import org.gradle.api.provider.Provider +import org.gradle.api.provider.ProviderFactory import org.gradle.api.tasks.TaskContainer import org.gradle.internal.extensions.core.extra import kotlin.test.Test @@ -52,10 +55,10 @@ private const val FAKE_PROJECT_ROOT_DIR = "/fake/root/dir" // The following values will need to be modified when the corresponding "warn$DepName" versions // are updated in DependencyVersionChecker.kt -private const val SUPPORTED_GRADLE_VERSION: String = "9.1.0" +private const val SUPPORTED_GRADLE_VERSION: String = "9.5.0" private val SUPPORTED_JAVA_VERSION: JavaVersion = JavaVersion.VERSION_17 -private val SUPPORTED_AGP_VERSION: AndroidPluginVersion = AndroidPluginVersion(9, 0, 1) -private const val SUPPORTED_KGP_VERSION: String = "2.3.20" +private val SUPPORTED_AGP_VERSION: AndroidPluginVersion = AndroidPluginVersion(9, 3, 1) +private const val SUPPORTED_KGP_VERSION: String = "2.4.20" private val SUPPORTED_SDK_VERSION: MinSdkVersion = MinSdkVersion("release", 30) class DependencyVersionCheckerTest { @@ -80,7 +83,7 @@ class DependencyVersionCheckerTest { @Test fun `AGP version in error range results in DependencyValidationException`() { - val exampleErrorAgpVersion = AndroidPluginVersion(8, 11, 0) + val exampleErrorAgpVersion = AndroidPluginVersion(9, 1, 0) val mockProject = MockProjectFactory.createMockProjectWithSpecifiedDependencyVersions(agpVersion = exampleErrorAgpVersion) val mockExtraPropertiesExtension = mockProject.extra @@ -102,7 +105,7 @@ class DependencyVersionCheckerTest { @Test fun `AGP version in warn range results in warning logs`() { - val exampleWarnAgpVersion = AndroidPluginVersion(8, 11, 1) + val exampleWarnAgpVersion = AndroidPluginVersion(9, 1, 1) val mockProject = MockProjectFactory.createMockProjectWithSpecifiedDependencyVersions(agpVersion = exampleWarnAgpVersion) val mockExtraPropertiesExtension = mockProject.extra @@ -124,9 +127,72 @@ class DependencyVersionCheckerTest { verify(exactly = 0) { mockExtraPropertiesExtension.set(OUT_OF_SUPPORT_RANGE_PROPERTY, true) } } + @Test + fun `AGP version with unsupported major version results in DependencyValidationException`() { + val exampleUnsupportedAgpVersion = AndroidPluginVersion(firstUnsupportedAGPMajorVersion, 0, 0) + val mockProject = + MockProjectFactory.createMockProjectWithSpecifiedDependencyVersions(agpVersion = exampleUnsupportedAgpVersion) + + val mockExtraPropertiesExtension = mockProject.extra + every { mockExtraPropertiesExtension.set(any(), any()) } returns Unit + + val dependencyValidationException = + assertFailsWith { DependencyVersionChecker.checkDependencyVersions(mockProject) } + assertEquals( + getFutureUnsupportedMajorVersionErrorMessage( + AGP_NAME, + "$firstUnsupportedAGPMajorVersion.0.0", + firstUnsupportedAGPMajorVersion, + getPotentialAGPFix(FAKE_PROJECT_ROOT_DIR) + ), + dependencyValidationException.message + ) + verify { mockExtraPropertiesExtension.set(OUT_OF_SUPPORT_RANGE_PROPERTY, true) } + } + + @Test + fun `AGP version above unsupported major version results in DependencyValidationException`() { + val exampleUnsupportedAgpVersion = AndroidPluginVersion(firstUnsupportedAGPMajorVersion, 3, 1) + val mockProject = + MockProjectFactory.createMockProjectWithSpecifiedDependencyVersions(agpVersion = exampleUnsupportedAgpVersion) + + val mockExtraPropertiesExtension = mockProject.extra + every { mockExtraPropertiesExtension.set(any(), any()) } returns Unit + + val dependencyValidationException = + assertFailsWith { DependencyVersionChecker.checkDependencyVersions(mockProject) } + assertEquals( + getFutureUnsupportedMajorVersionErrorMessage( + AGP_NAME, + "$firstUnsupportedAGPMajorVersion.3.1", + firstUnsupportedAGPMajorVersion, + getPotentialAGPFix(FAKE_PROJECT_ROOT_DIR) + ), + dependencyValidationException.message + ) + verify { mockExtraPropertiesExtension.set(OUT_OF_SUPPORT_RANGE_PROPERTY, true) } + } + + @Test + fun `AGP version below unsupported major version is considered supported`() { + val exampleSupportedAgpVersion = AndroidPluginVersion(firstUnsupportedAGPMajorVersion - 1, 99, 99) + val mockProject = + MockProjectFactory.createMockProjectWithSpecifiedDependencyVersions(agpVersion = exampleSupportedAgpVersion) + + val mockExtraPropertiesExtension = mockProject.extra + every { mockExtraPropertiesExtension.set(OUT_OF_SUPPORT_RANGE_PROPERTY, false) } returns Unit + val mockLogger = mockProject.logger + every { mockLogger.error(any()) } returns Unit + + DependencyVersionChecker.checkDependencyVersions(mockProject) + + verify(exactly = 0) { mockLogger.error(any()) } + verify(exactly = 0) { mockExtraPropertiesExtension.set(OUT_OF_SUPPORT_RANGE_PROPERTY, true) } + } + @Test fun `KGP version in error range results in DependencyValidationException`() { - val exampleErrorKgpVersion = "2.0.0" + val exampleErrorKgpVersion = "2.4.0" val mockProject = MockProjectFactory.createMockProjectWithSpecifiedDependencyVersions(kgpVersion = exampleErrorKgpVersion) val mockExtraPropertiesExtension = mockProject.extra @@ -150,7 +216,7 @@ class DependencyVersionCheckerTest { @Test fun `KGP version in warn range results in warning logs`() { - val exampleWarnKgpVersion = "2.2.20" + val exampleWarnKgpVersion = "2.4.10" val mockProject = MockProjectFactory.createMockProjectWithSpecifiedDependencyVersions(kgpVersion = exampleWarnKgpVersion) val mockExtraPropertiesExtension = mockProject.extra @@ -172,6 +238,30 @@ class DependencyVersionCheckerTest { verify(exactly = 0) { mockExtraPropertiesExtension.set(OUT_OF_SUPPORT_RANGE_PROPERTY, true) } } + @Test + fun `AGP 9 built-in Kotlin skips KGP min version enforcement by default`() { + val mockProject = + MockProjectFactory.createMockProjectWithSpecifiedDependencyVersions( + kgpVersion = "2.2.10", + builtInKotlinProperty = null + ) + + val mockExtraPropertiesExtension = mockProject.extra + every { mockExtraPropertiesExtension.set(OUT_OF_SUPPORT_RANGE_PROPERTY, false) } returns Unit + val mockLogger = mockProject.logger + every { mockLogger.debug(any()) } returns Unit + + DependencyVersionChecker.checkDependencyVersions(mockProject) + + verify { + mockLogger.debug( + "Skipping Kotlin min-version enforcement because the project uses " + + "AGP built-in Kotlin or does not apply KGP." + ) + } + verify(exactly = 0) { mockExtraPropertiesExtension.set(OUT_OF_SUPPORT_RANGE_PROPERTY, true) } + } + // No test for Java version in warn range, as the lowest supported Java version is also the // lowest possible. @Test @@ -201,7 +291,7 @@ class DependencyVersionCheckerTest { @Test fun `Gradle version in error range results in DependencyValidationException`() { - val exampleErrorGradleVersion = "8.13.0" + val exampleErrorGradleVersion = "9.1.0" val mockProject = MockProjectFactory.createMockProjectWithSpecifiedDependencyVersions(gradleVersion = exampleErrorGradleVersion) val mockExtraPropertiesExtension = mockProject.extra @@ -224,7 +314,7 @@ class DependencyVersionCheckerTest { @Test fun `Gradle version in warn range results in warning logs`() { - val exampleWarnGradleVersion = "8.14.0" + val exampleWarnGradleVersion = "9.3.1" val mockProject = MockProjectFactory.createMockProjectWithSpecifiedDependencyVersions(gradleVersion = exampleWarnGradleVersion) val mockExtraPropertiesExtension = mockProject.extra @@ -423,6 +513,7 @@ private object MockProjectFactory { gradleVersion: String = SUPPORTED_GRADLE_VERSION, agpVersion: AndroidPluginVersion = SUPPORTED_AGP_VERSION, kgpVersion: String = SUPPORTED_KGP_VERSION, + builtInKotlinProperty: String? = "false", minSdkVersions: List = listOf(SUPPORTED_SDK_VERSION) ): Project { // Java @@ -441,6 +532,11 @@ private object MockProjectFactory { // KGP every { mockProject.hasProperty(eq("kotlin_version")) } returns true every { mockProject.properties["kotlin_version"] } returns kgpVersion + val mockBuiltInKotlinProperty = mockk>() + every { mockBuiltInKotlinProperty.orNull } returns builtInKotlinProperty + val mockProviders = mockk() + every { mockProviders.gradleProperty("android.builtInKotlin") } returns mockBuiltInKotlinProperty + every { mockProject.providers } returns mockProviders // Logger val mockLogger = mockk() @@ -499,7 +595,6 @@ private object MockProjectFactory { } return@answers Unit } - setAgpKotlinVersionToNull(mockProject) return mockProject } diff --git a/packages/flutter_tools/gradle/src/test/kotlin/DslVersionCodesTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/DslVersionCodesTest.kt new file mode 100644 index 00000000..967bdc0f --- /dev/null +++ b/packages/flutter_tools/gradle/src/test/kotlin/DslVersionCodesTest.kt @@ -0,0 +1,85 @@ +// Copyright 2014 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. + +package com.flutter.gradle + +import com.android.build.api.dsl.ApplicationDefaultConfig +import com.android.build.api.dsl.ApplicationExtension +import com.flutter.gradle.testing.mockProductFlavors +import io.mockk.every +import io.mockk.mockk +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class DslVersionCodesTest { + @Test + fun `from reads the defaultConfig versionCode and the flavors that set one`() { + val extension = + mockApplicationExtension( + defaultConfigVersionCode = 42, + productFlavorVersionCodes = mapOf("free" to 7, "paid" to null, "demo" to 9) + ) + + assertEquals( + DslVersionCodes( + defaultConfigVersionCode = 42, + productFlavorVersionCodes = mapOf("free" to 7, "demo" to 9) + ), + DslVersionCodes.from(extension) + ) + } + + @Test + fun `from reads a null defaultConfig versionCode`() { + val extension = mockApplicationExtension(defaultConfigVersionCode = null) + + assertEquals( + DslVersionCodes(defaultConfigVersionCode = null, productFlavorVersionCodes = emptyMap()), + DslVersionCodes.from(extension) + ) + } + + @Test + fun `forVariant returns the versionCode of the first flavor, in dimension order, that sets one`() { + val versionCodes = + DslVersionCodes( + defaultConfigVersionCode = 42, + productFlavorVersionCodes = mapOf("paid" to 7, "demo" to 9) + ) + + assertEquals(7, versionCodes.forVariant(listOf("tier" to "paid", "mode" to "demo"))) + assertEquals(9, versionCodes.forVariant(listOf("tier" to "free", "mode" to "demo"))) + } + + @Test + fun `forVariant falls back to the defaultConfig versionCode`() { + val versionCodes = + DslVersionCodes(defaultConfigVersionCode = 42, productFlavorVersionCodes = mapOf("paid" to 7)) + + assertEquals(42, versionCodes.forVariant(emptyList())) + assertEquals(42, versionCodes.forVariant(listOf("tier" to "free"))) + } + + @Test + fun `forVariant returns null when neither the flavors nor defaultConfig set a versionCode`() { + val versionCodes = + DslVersionCodes(defaultConfigVersionCode = null, productFlavorVersionCodes = mapOf("paid" to 7)) + + assertNull(versionCodes.forVariant(listOf("tier" to "free"))) + } + + private fun mockApplicationExtension( + defaultConfigVersionCode: Int?, + productFlavorVersionCodes: Map = emptyMap() + ): ApplicationExtension { + val defaultConfig = mockk() + every { defaultConfig.versionCode } returns defaultConfigVersionCode + val productFlavors = mockProductFlavors(productFlavorVersionCodes) + val extension = mockk() + every { extension.defaultConfig } returns defaultConfig + every { extension.productFlavors } returns productFlavors + return extension + } +} diff --git a/packages/flutter_tools/gradle/src/test/kotlin/FlutterCompileOptionsTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/FlutterCompileOptionsTest.kt new file mode 100644 index 00000000..3929ac08 --- /dev/null +++ b/packages/flutter_tools/gradle/src/test/kotlin/FlutterCompileOptionsTest.kt @@ -0,0 +1,110 @@ +// Copyright 2014 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. + +package com.flutter.gradle + +import io.mockk.every +import io.mockk.mockk +import org.gradle.api.Project +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class FlutterCompileOptionsTest { + @Test + fun `from reads each compile property from the project`() { + val project = + projectWithProperties( + FlutterCompileOptions.FILE_SYSTEM_ROOTS_PROPERTY to "root1", + FlutterCompileOptions.FILE_SYSTEM_SCHEME_PROPERTY to "custom-scheme", + FlutterCompileOptions.TRACK_WIDGET_CREATION_PROPERTY to "false", + FlutterCompileOptions.FRONTEND_SERVER_STARTER_PATH_PROPERTY to "starter.dart", + FlutterCompileOptions.EXTRA_FRONT_END_OPTIONS_PROPERTY to "--opt1", + FlutterCompileOptions.EXTRA_GEN_SNAPSHOT_OPTIONS_PROPERTY to "--opt2", + FlutterCompileOptions.SPLIT_DEBUG_INFO_PROPERTY to "debug/info", + FlutterCompileOptions.DART_OBFUSCATION_PROPERTY to "true", + FlutterCompileOptions.TREE_SHAKE_ICONS_PROPERTY to "true", + FlutterCompileOptions.DART_DEFINES_PROPERTY to "key=val", + FlutterCompileOptions.PERFORMANCE_MEASUREMENT_FILE_PROPERTY to "perf.json", + FlutterCompileOptions.CODE_SIZE_DIRECTORY_PROPERTY to "code/size", + FlutterCompileOptions.DEFERRED_COMPONENTS_PROPERTY to "true", + FlutterCompileOptions.VALIDATE_DEFERRED_COMPONENTS_PROPERTY to "false" + ) + + val options = FlutterCompileOptions.from(project) + + assertEquals(listOf("root1"), options.fileSystemRoots) + assertEquals("custom-scheme", options.fileSystemScheme) + assertEquals(false, options.trackWidgetCreation) + assertEquals("starter.dart", options.frontendServerStarterPath) + assertEquals("--opt1", options.extraFrontEndOptions) + assertEquals("--opt2", options.extraGenSnapshotOptions) + assertEquals("debug/info", options.splitDebugInfo) + assertEquals(true, options.dartObfuscation) + assertEquals(true, options.treeShakeIcons) + assertEquals("key=val", options.dartDefines) + assertEquals("perf.json", options.performanceMeasurementFile) + assertEquals("code/size", options.codeSizeDirectory) + assertEquals(true, options.deferredComponents) + assertEquals(false, options.validateDeferredComponents) + } + + @Test + fun `from falls back to defaults when no properties are set`() { + val options = FlutterCompileOptions.from(projectWithProperties()) + + assertNull(options.fileSystemRoots) + assertNull(options.fileSystemScheme) + assertNull(options.frontendServerStarterPath) + assertNull(options.extraFrontEndOptions) + assertNull(options.extraGenSnapshotOptions) + assertNull(options.splitDebugInfo) + assertNull(options.dartDefines) + assertNull(options.performanceMeasurementFile) + assertNull(options.codeSizeDirectory) + assertEquals(true, options.trackWidgetCreation) + assertEquals(false, options.dartObfuscation) + assertEquals(false, options.treeShakeIcons) + assertEquals(false, options.deferredComponents) + assertEquals(true, options.validateDeferredComponents) + } + + // The generated data class equals is only correct because every property has value equality. + // An Array property would compare by identity and make two identically configured builds look + // different, so this asserts the property that lets the generated implementation stand. + @Test + fun `options resolved from equivalent projects are equal`() { + val properties = + arrayOf( + FlutterCompileOptions.FILE_SYSTEM_ROOTS_PROPERTY to "root1", + FlutterCompileOptions.DART_DEFINES_PROPERTY to "key=val" + ) + + val options = FlutterCompileOptions.from(projectWithProperties(*properties)) + val sameOptions = FlutterCompileOptions.from(projectWithProperties(*properties)) + + assertEquals(options, sameOptions) + assertEquals(options.hashCode(), sameOptions.hashCode()) + assertTrue( + options != + FlutterCompileOptions.from( + projectWithProperties( + FlutterCompileOptions.FILE_SYSTEM_ROOTS_PROPERTY to "other-root", + FlutterCompileOptions.DART_DEFINES_PROPERTY to "key=val" + ) + ) + ) + } + + /** A project whose `findProperty` answers [properties] and null for everything else. */ + private fun projectWithProperties(vararg properties: Pair): Project { + val project = mockk() + every { project.findProperty(any()) } returns null + properties.forEach { (name, value) -> + every { project.findProperty(name) } returns value + } + return project + } +} diff --git a/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginTest.kt index cad147bd..9953063b 100644 --- a/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginTest.kt +++ b/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginTest.kt @@ -1,143 +1,752 @@ package com.flutter.gradle +import com.android.build.api.artifact.SingleArtifact import com.android.build.api.dsl.ApplicationBuildType import com.android.build.api.dsl.ApplicationDefaultConfig import com.android.build.api.dsl.ApplicationExtension -import com.android.build.api.dsl.CommonExtension +import com.android.build.api.dsl.LibraryBuildType import com.android.build.api.dsl.LibraryExtension import com.android.build.api.variant.AndroidComponentsExtension +import com.android.build.api.variant.ApplicationVariant +import com.android.build.api.variant.BuiltArtifactsLoader +import com.android.build.api.variant.LibraryVariant +import com.android.build.api.variant.SourceDirectories +import com.android.build.api.variant.Sources +import com.android.build.api.variant.Variant +import com.android.build.api.variant.VariantBuilder +import com.android.build.api.variant.VariantOutput import com.android.build.gradle.AbstractAppExtension import com.android.build.gradle.BaseExtension import com.android.build.gradle.api.AndroidSourceDirectorySet -import com.android.build.gradle.internal.core.InternalBaseVariant -import com.android.build.gradle.tasks.MergeSourceSetFolders -import com.android.build.gradle.tasks.ProcessAndroidResources +import com.flutter.gradle.tasks.CopyFlutterApksTask +import com.flutter.gradle.tasks.CopyFlutterAssetsTask +import com.flutter.gradle.tasks.CopyFlutterJniLibsTask import com.flutter.gradle.tasks.FlutterTask import com.flutter.gradle.tasks.PrintTask +import com.flutter.gradle.testing.mockAbiFilters +import com.flutter.gradle.testing.mockProductFlavors +import io.mockk.Runs import io.mockk.every +import io.mockk.just import io.mockk.mockk import io.mockk.mockkObject import io.mockk.slot +import io.mockk.unmockkAll import io.mockk.verify import org.gradle.api.Action +import org.gradle.api.GradleException +import org.gradle.api.NamedDomainObjectContainer import org.gradle.api.Project import org.gradle.api.Task +import org.gradle.api.Transformer import org.gradle.api.file.Directory -import org.gradle.api.tasks.Copy -import org.gradle.api.tasks.TaskContainer +import org.gradle.api.provider.Property +import org.gradle.api.provider.Provider +import org.gradle.api.specs.Spec import org.gradle.api.tasks.TaskProvider import org.jetbrains.kotlin.gradle.plugin.extraProperties -import org.junit.jupiter.api.Assertions.fail +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.charset.StandardCharsets import java.nio.file.Path +import java.util.Base64 import kotlin.io.path.writeText import kotlin.test.Test import kotlin.test.assertContains +import kotlin.test.assertEquals class FlutterPluginTest { + // Clear global singleton mocks to prevent mock state leaking into other tests in the same JVM. + @AfterEach + fun tearDown() { + unmockkAll() + } + @Test fun `FlutterPlugin apply() adds expected tasks`( @TempDir tempDir: Path ) { - val projectDir = tempDir.resolve("project-dir").resolve("android").resolve("app") - projectDir.toFile().mkdirs() - val settingsFile = projectDir.parent.resolve("settings.gradle") - settingsFile.writeText("empty for now") - val fakeFlutterSdkDir = tempDir.resolve("fake-flutter-sdk") - fakeFlutterSdkDir.toFile().mkdirs() - val fakeCacheDir = fakeFlutterSdkDir.resolve("bin").resolve("cache") - fakeCacheDir.toFile().mkdirs() - val fakeEngineStampFile = fakeCacheDir.resolve("engine.stamp") - fakeEngineStampFile.writeText(FAKE_ENGINE_STAMP) - val fakeEngineRealmFile = fakeCacheDir.resolve("engine.realm") - fakeEngineRealmFile.writeText(FAKE_ENGINE_REALM) - val project = mockk(relaxed = true) - val mockAbstractAppExtension = - mockk( - moreInterfaces = arrayOf(ApplicationExtension::class), - relaxed = true - ) - val mockLibraryExtension = mockk(relaxed = true) - every { project.extensions.findByType(AbstractAppExtension::class.java) } returns mockAbstractAppExtension - val mockAndroidComponentsExtension = mockk>(relaxed = true) - every { project.extensions.getByType(AndroidComponentsExtension::class.java) } returns mockAndroidComponentsExtension - every { project.extensions.findByType(AndroidComponentsExtension::class.java) } returns mockAndroidComponentsExtension - val mockSelector = mockk(relaxed = true) - every { mockAndroidComponentsExtension.selector() } returns mockSelector - every { mockSelector.all() } returns mockSelector - every { mockSelector.withName(any()) } returns mockSelector - every { project.extensions.getByType(AbstractAppExtension::class.java) } returns mockAbstractAppExtension - every { project.extensions.getByType(LibraryExtension::class.java) } returns mockLibraryExtension - every { project.extensions.findByName("android") } returns mockAbstractAppExtension - every { project.projectDir } returns projectDir.toFile() - every { project.findProperty("flutter.sdk") } returns fakeFlutterSdkDir.toString() - every { project.file(fakeFlutterSdkDir.toString()) } returns fakeFlutterSdkDir.toFile() - val flutterExtension = FlutterExtension() - every { project.extensions.create("flutter", any>()) } returns flutterExtension - every { project.extensions.findByType(FlutterExtension::class.java) } returns flutterExtension - val mockBaseExtension = mockk(relaxed = true) - val mockCommonExtension = mockk>(relaxed = true) - val mockDebugBuildType = mockk(relaxed = true) - val mockReleaseBuildType = mockk(relaxed = true) + val env = setupTestProjectEnvironment(tempDir) + setupMockApplicationExtension(env.project) + setupMockComponentsExtension(env.project) + setupMockNativePluginLoader(env.project, env.flutterExtension) - // Cast our multi-interface mock instead of creating a brand new one - val mockApplicationExtension = mockAbstractAppExtension as ApplicationExtension + val flutterPlugin = FlutterPlugin() + flutterPlugin.apply(env.project) - // Mock buildTypes on our new dual-purpose mock so AgpCommonExtensionWrapper can read them - every { mockApplicationExtension.buildTypes.getByName("debug") } returns mockDebugBuildType - every { mockApplicationExtension.buildTypes.getByName("release") } returns mockReleaseBuildType + verify { env.project.tasks.register("generateLockfiles", any()) } + val registeredPrintTasks = mutableListOf() + verify { + env.project.tasks.register(capture(registeredPrintTasks), PrintTask::class.java, any()) + } - // Keep the CommonExtension mocks just in case other parts of the plugin look for it - every { mockCommonExtension.buildTypes.getByName("debug") } returns mockDebugBuildType - every { mockCommonExtension.buildTypes.getByName("release") } returns mockReleaseBuildType + assertContains(registeredPrintTasks, "javaVersion") + assertContains(registeredPrintTasks, "kgpVersion") + assertContains(registeredPrintTasks, "printBuildVariants") + assertContains(registeredPrintTasks, "printNdkVersion") + } - every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension - every { project.extensions.findByType(CommonExtension::class.java) } returns mockCommonExtension + @Test + fun `FlutterPlugin apply wires flutter embedding dependencies on all build types`( + @TempDir tempDir: Path + ) { + val env = setupTestProjectEnvironment(tempDir) + setupMockApplicationExtension(env.project) + setupMockComponentsExtension(env.project) + setupMockNativePluginLoader(env.project, env.flutterExtension) - // Pass the dual-purpose mock for any ApplicationExtension lookups - every { project.extensions.findByType(ApplicationExtension::class.java) } returns mockApplicationExtension - every { project.extensions.getByType(ApplicationExtension::class.java) } returns mockApplicationExtension + val flutterPlugin = FlutterPlugin() + flutterPlugin.apply(env.project) - val mockApplicationDefaultConfig = - mockk( - moreInterfaces = arrayOf(ApplicationDefaultConfig::class), - relaxed = true + verify { + env.project.dependencies.add( + "debugApi", + "io.flutter:flutter_embedding_debug:1.0.0-$FAKE_ENGINE_STAMP" ) - every { mockApplicationExtension.defaultConfig } returns mockApplicationDefaultConfig - every { project.rootProject } returns project - every { project.state.failure as Throwable? } returns null - val mockDirectory = mockk(relaxed = true) - every { project.layout.buildDirectory.get() } returns mockDirectory - val mockAndroidSourceSet = mockk(relaxed = true) - val mockAndroidSourceDirectorySet = mockk(relaxed = true) - every { mockAndroidSourceSet.jniLibs.srcDir(any()) } returns mockAndroidSourceDirectorySet - every { mockAbstractAppExtension.sourceSets.getByName("main") } returns mockAndroidSourceSet - // mock return of NativePluginLoaderReflectionBridge.getPlugins - mockkObject(NativePluginLoaderReflectionBridge) - every { NativePluginLoaderReflectionBridge.getPlugins(any(), any()) } returns - listOf() - // mock method calls that are invoked by the args to NativePluginLoaderReflectionBridge - every { project.extraProperties } returns mockk() - every { project.file(flutterExtension.source!!) } returns mockk() + } + verify { + env.project.dependencies.add( + "releaseApi", + "io.flutter:flutter_embedding_release:1.0.0-$FAKE_ENGINE_STAMP" + ) + } + } + + @Test + fun `apply adds task for generating manifest with engine shell arguments`( + @TempDir tempDir: Path + ) { + val env = setupTestProjectEnvironment(tempDir) + val project = env.project + val engineShellArgsJson = """["--enable-impeller=true"]""" + val base64EngineShellArgs = + Base64.getEncoder().encodeToString(engineShellArgsJson.toByteArray(StandardCharsets.UTF_8)) + every { project.findProperty("flutter.engineShellArgs") } returns base64EngineShellArgs + + setupMockApplicationExtension(project) + setupMockComponentsExtension(project) + setupMockNativePluginLoader(project, env.flutterExtension) + + mockkObject(FlutterPluginUtils) val flutterPlugin = FlutterPlugin() flutterPlugin.apply(project) - verify { project.tasks.register("generateLockfiles", any()) } - val registeredPrintTasks = mutableListOf() verify { - project.tasks.register(capture(registeredPrintTasks), PrintTask::class.java, any()) + FlutterPluginUtils.addTaskForGeneratingEngineShellArgumentManifest(project) } + } - assertContains(registeredPrintTasks, "javaVersion") - assertContains(registeredPrintTasks, "kgpVersion") - assertContains(registeredPrintTasks, "printBuildVariants") - assertContains(registeredPrintTasks, "printNdkVersion") + @Test + fun `onVariants registers compile and asset tasks and adds generated asset source directory`( + @TempDir tempDir: Path + ) { + val env = setupTestProjectEnvironment(tempDir) + val project = env.project + val mockCompileTaskProvider = mockk>(relaxed = true) + val mockCopyAssetsTaskProvider = mockk>(relaxed = true) + every { + project.tasks.register("compileFlutterBuildDebug", FlutterTask::class.java, any()) + } returns mockCompileTaskProvider + every { + project.tasks.register("copyFlutterAssetsDebug", CopyFlutterAssetsTask::class.java, any()) + } returns mockCopyAssetsTaskProvider + + val onVariant = applyPluginCapturingVariantCallback(env) + val mockAssetsSource = mockk(relaxed = true) + onVariant(mockApplicationVariant(assetsSource = mockAssetsSource)) + + verify { + project.tasks.register("compileFlutterBuildDebug", FlutterTask::class.java, any()) + } + verify { + project.tasks.register("copyFlutterAssetsDebug", CopyFlutterAssetsTask::class.java, any()) + } + verify { + mockAssetsSource.addGeneratedSourceDirectory( + mockCopyAssetsTaskProvider, + CopyFlutterAssetsTask::destinationDir + ) + } + } + + @Test + fun `onVariants throws GradleException when variant sources assets is null`( + @TempDir tempDir: Path + ) { + val env = setupTestProjectEnvironment(tempDir) + + val onVariant = applyPluginCapturingVariantCallback(env) + + val exception = + assertThrows { + onVariant(mockApplicationVariant(assetsSource = null)) + } + assertContains( + exception.message!!, + "Flutter could not register its generated assets for variant 'debug'" + ) } @Test - fun `copyFlutterAssets task sets filePermissions correctly`( + fun `onVariants skips Flutter compile and asset task registration when shouldConfigureFlutterTask is false`( @TempDir tempDir: Path ) { + val env = setupTestProjectEnvironment(tempDir) + val project = env.project + // A single assembleDebug on the command line must not configure Flutter for androidTest. + val mockStartParameter = mockk() + every { mockStartParameter.taskNames } returns listOf("assembleDebug") + val mockGradle = mockk() + every { mockGradle.startParameter } returns mockStartParameter + every { project.gradle } returns mockGradle + + val onVariant = applyPluginCapturingVariantCallback(env) + onVariant(mockApplicationVariant(name = "androidTest")) + + val taskContainer = project.tasks + verify(exactly = 0) { + taskContainer.register("compileFlutterBuildAndroidTest", FlutterTask::class.java, any()) + } + verify(exactly = 0) { + taskContainer.register("copyFlutterAssetsAndroidTest", CopyFlutterAssetsTask::class.java, any()) + } + verify(exactly = 0) { + taskContainer.register("copyFlutterApksAndroidTest", CopyFlutterApksTask::class.java, any()) + } + verify(exactly = 0) { + taskContainer.register("copyJniLibsflutterBuildAndroidTest", CopyFlutterJniLibsTask::class.java, any()) + } + } + + @Test + fun `onVariants wires the jniLibs copy to the output of the variant's compile task`( + @TempDir tempDir: Path + ) { + val env = setupTestProjectEnvironment(tempDir) + val project = env.project + val compileTaskProvider = project.stubTaskRegistration("compileFlutterBuildDebug") + val compileOutputSlot = slot>() + val compileOutput = mockk>() + every { compileTaskProvider.map(capture(compileOutputSlot)) } returns compileOutput + val compileOutputDir = mockk>() + every { project.layout.dir(compileOutput) } returns compileOutputDir + val configureJniLibsTask = project.captureTaskConfiguration("copyJniLibsflutterBuildDebug") + + applyPluginCapturingVariantCallback(env)(mockApplicationVariant()) + val jniLibsTask = configureJniLibsTask() + + verify { jniLibsTask.intermediateDir.set(compileOutputDir) } + val compileTask = mockk() + val outputDirectory = File("build/intermediates/flutter/debug") + every { compileTask.outputDirectory } returns outputDirectory + assertEquals(outputDirectory, compileOutputSlot.captured.transform(compileTask)) + // The provider carries the task dependency, so the compile task is not looked up by name. + val taskContainer = project.tasks + verify(exactly = 0) { taskContainer.findByName(any()) } + verify(exactly = 0) { taskContainer.matching(any>()) } + } + + @Test + fun `onVariants configures a module variant for any command line task, without APK wiring`( + @TempDir tempDir: Path + ) { + val env = setupTestProjectEnvironment(tempDir) + val project = env.project + // A host task, whose name matches none of the module's variant names. + project.setCommandLineTasks(":app:assembleDemoStaging") + val copyAssetsTaskProvider = project.stubTaskRegistration("copyFlutterAssetsDebug") + val copyJniLibsTaskProvider = project.stubTaskRegistration("copyJniLibsflutterBuildDebug") + val assetsSource = mockk(relaxed = true) + val jniLibsSource = mockk(relaxed = true) + + applyPluginToModuleCapturingVariantCallback(env)( + mockLibraryVariant(assetsSource = assetsSource, jniLibsSource = jniLibsSource) + ) + + val taskContainer = project.tasks + verify { taskContainer.register("compileFlutterBuildDebug", FlutterTask::class.java, any()) } + verify { assetsSource.addGeneratedSourceDirectory(copyAssetsTaskProvider, CopyFlutterAssetsTask::destinationDir) } + verify { jniLibsSource.addGeneratedSourceDirectory(copyJniLibsTaskProvider, CopyFlutterJniLibsTask::destinationDir) } + verify(exactly = 0) { taskContainer.register(any(), CopyFlutterApksTask::class.java, any()) } + verify(exactly = 0) { taskContainer.configureEach(any>()) } + } + + @Test + fun `onVariants compiles each module variant in the build mode of its build type`( + @TempDir tempDir: Path + ) { + val env = setupTestProjectEnvironment(tempDir) + val configureCompileTasks = + listOf("Debug", "Profile", "Release").associateWith { variantName -> + env.project.captureTaskConfiguration("compileFlutterBuild$variantName") + } + + val onVariant = applyPluginToModuleCapturingVariantCallback(env) + onVariant(mockLibraryVariant(name = "debug", buildType = "debug", debuggable = true, minSdkApiLevel = 24)) + // The plugin creates `profile` with initWith(debug), so it is debuggable too. + onVariant(mockLibraryVariant(name = "profile", buildType = "profile", debuggable = true)) + onVariant(mockLibraryVariant(name = "release", buildType = "release", debuggable = false)) + + val buildModes = + configureCompileTasks.mapValues { (_, configureCompileTask) -> + val task = configureCompileTask() + val buildModeSlot = slot() + verify { task.buildMode = capture(buildModeSlot) } + buildModeSlot.captured + } + assertEquals(mapOf("Debug" to "debug", "Profile" to "profile", "Release" to "release"), buildModes) + } + + @Test + fun `apply warns that flutter hostAppProjectName has no effect in a module and does not look up the host`( + @TempDir tempDir: Path + ) { + val env = setupTestProjectEnvironment(tempDir) + val project = env.project + every { project.providers.gradleProperty(FlutterPlugin.PROP_HOST_APP_PROJECT_NAME).isPresent } returns true + + applyPluginToModuleCapturingVariantCallback(env) + + val logger = project.logger + verify { + logger.warn(match { it.contains("'flutter.hostAppProjectName' has no effect") }) + } + val rootProject = project.rootProject + verify(exactly = 0) { rootProject.findProject(any()) } + } + + @Test + fun `apply does not warn about flutter hostAppProjectName when it is not set`( + @TempDir tempDir: Path + ) { + val env = setupTestProjectEnvironment(tempDir) + val project = env.project + every { project.providers.gradleProperty(FlutterPlugin.PROP_HOST_APP_PROJECT_NAME).isPresent } returns false + + applyPluginToModuleCapturingVariantCallback(env) + + val logger = project.logger + verify(exactly = 0) { logger.warn(match { it.contains("hostAppProjectName") }) } + } + + @Test + fun `onVariants offsets the defaultConfig versionCode for each per-ABI output for split-per-abi builds`( + @TempDir tempDir: Path + ) { + val env = setupTestProjectEnvironment(tempDir) + every { env.project.findProperty("split-per-abi") } returns "true" + val arm32 = mockVariantOutput(abi = "armeabi-v7a") + val arm64 = mockVariantOutput(abi = "arm64-v8a") + val x64 = mockVariantOutput(abi = "x86_64") + val universal = mockVariantOutput(abi = null) + + val onVariant = applyPluginCapturingVariantCallback(env, defaultConfigVersionCode = 42) + onVariant(mockApplicationVariant(outputs = listOf(arm32, arm64, x64, universal).map { it.output })) + + verify { arm32.versionCode.set(1042) } + verify { arm64.versionCode.set(2042) } + verify { x64.versionCode.set(4042) } + verify(exactly = 0) { universal.versionCode.set(any()) } + } + + @Test + fun `onVariants offsets the versionCode of the variant's product flavor for split-per-abi builds`( + @TempDir tempDir: Path + ) { + val env = setupTestProjectEnvironment(tempDir) + every { env.project.findProperty("split-per-abi") } returns "true" + val arm64 = mockVariantOutput(abi = "arm64-v8a") + + val onVariant = + applyPluginCapturingVariantCallback( + env, + defaultConfigVersionCode = 42, + productFlavorVersionCodes = mapOf("free" to 7, "paid" to 9) + ) + onVariant( + mockApplicationVariant( + name = "paidDebug", + flavorName = "paid", + productFlavors = listOf("tier" to "paid"), + outputs = listOf(arm64.output) + ) + ) + + verify { arm64.versionCode.set(2009) } + } + + @Test + fun `onVariants warns and leaves versionCodes unchanged when the DSL declares no versionCode`( + @TempDir tempDir: Path + ) { + val env = setupTestProjectEnvironment(tempDir) + every { env.project.findProperty("split-per-abi") } returns "true" + val arm64 = mockVariantOutput(abi = "arm64-v8a") + + val onVariant = applyPluginCapturingVariantCallback(env, defaultConfigVersionCode = null) + onVariant(mockApplicationVariant(outputs = listOf(arm64.output))) + + verify(exactly = 0) { arm64.versionCode.set(any()) } + val logger = env.project.logger + verify { + logger.error( + match { + it.contains("variant 'debug'") && + it.contains("declares no versionCode") && + it.contains("versionCode = flutter.versionCode") + } + ) + } + verify(exactly = 0) { logger.warn(any()) } + } + + @Test + fun `onVariants leaves versionCodes unchanged without split-per-abi`( + @TempDir tempDir: Path + ) { + val env = setupTestProjectEnvironment(tempDir) + val arm64 = mockVariantOutput(abi = "arm64-v8a") + + val onVariant = applyPluginCapturingVariantCallback(env, defaultConfigVersionCode = 42) + onVariant(mockApplicationVariant(outputs = listOf(arm64.output))) + + verify(exactly = 0) { arm64.versionCode.set(any()) } + } + + @Test + fun `onVariants leaves versionCodes unchanged when force-version-code-ignoring-abi is set`( + @TempDir tempDir: Path + ) { + val env = setupTestProjectEnvironment(tempDir) + every { env.project.findProperty("split-per-abi") } returns "true" + every { env.project.findProperty("force-version-code-ignoring-abi") } returns "true" + val arm64 = mockVariantOutput(abi = "arm64-v8a") + + val onVariant = applyPluginCapturingVariantCallback(env, defaultConfigVersionCode = 42) + onVariant(mockApplicationVariant(outputs = listOf(arm64.output))) + + verify(exactly = 0) { arm64.versionCode.set(any()) } + } + + @Test + fun `onVariants configures the APK copy from the variant artifacts, outputs, flavor, and build mode`( + @TempDir tempDir: Path + ) { + val env = setupTestProjectEnvironment(tempDir) + val project = env.project + val copyApksActionSlot = slot>() + every { + project.tasks.register("copyFlutterApksFreeRelease", CopyFlutterApksTask::class.java, capture(copyApksActionSlot)) + } returns mockk() + val flutterApkDir = mockk>() + every { project.layout.buildDirectory.dir("outputs/flutter-apk") } returns flutterApkDir + val variant = + mockApplicationVariant( + name = "freeRelease", + buildType = "release", + debuggable = false, + flavorName = "free", + outputs = + listOf( + mockVariantOutput(abi = "armeabi-v7a").output, + mockVariantOutput(abi = null).output + ) + ) + val apkDir = mockk>() + every { variant.artifacts.get(SingleArtifact.APK) } returns apkDir + val loader = mockk() + every { variant.artifacts.getBuiltArtifactsLoader() } returns loader + + val onVariant = applyPluginCapturingVariantCallback(env) + onVariant(variant) + + val mockCopyApksTask = mockk(relaxed = true) + copyApksActionSlot.captured.execute(mockCopyApksTask) + + verify { mockCopyApksTask.apkDirectory.set(apkDir) } + verify { mockCopyApksTask.builtArtifactsLoader.set(loader) } + verify { mockCopyApksTask.destinationDir.set(flutterApkDir) } + verify { mockCopyApksTask.outputAbis.set(listOf("armeabi-v7a", CopyFlutterApksTask.NO_ABI)) } + verify { mockCopyApksTask.flavorName.set("free") } + verify { mockCopyApksTask.buildMode.set("release") } + } + + @Test + fun `onVariants names the APK copy by Flutter build mode for profile and custom build types`( + @TempDir tempDir: Path + ) { + val env = setupTestProjectEnvironment(tempDir) + val project = env.project + val profileActionSlot = slot>() + every { + project.tasks.register("copyFlutterApksProfile", CopyFlutterApksTask::class.java, capture(profileActionSlot)) + } returns mockk() + val stagingActionSlot = slot>() + every { + project.tasks.register("copyFlutterApksStaging", CopyFlutterApksTask::class.java, capture(stagingActionSlot)) + } returns mockk() + + val onVariant = applyPluginCapturingVariantCallback(env) + onVariant(mockApplicationVariant(name = "profile", buildType = "profile", debuggable = false)) + onVariant(mockApplicationVariant(name = "staging", buildType = "staging", debuggable = true)) + + val profileCopyTask = mockk(relaxed = true) + profileActionSlot.captured.execute(profileCopyTask) + val stagingCopyTask = mockk(relaxed = true) + stagingActionSlot.captured.execute(stagingCopyTask) + + verify { profileCopyTask.buildMode.set("profile") } + // A debuggable custom build type uses debug artifacts, so its APK is named as debug. + verify { stagingCopyTask.buildMode.set("debug") } + } + + @Test + fun `onVariants makes only the variant's assemble task depend on its APK copy`( + @TempDir tempDir: Path + ) { + val env = setupTestProjectEnvironment(tempDir) + val project = env.project + val mockCopyApksTaskProvider = mockk>() + every { + project.tasks.register("copyFlutterApksDebug", CopyFlutterApksTask::class.java, any()) + } returns mockCopyApksTaskProvider + val configureEachSlot = slot>() + every { project.tasks.configureEach(capture(configureEachSlot)) } returns Unit + + val onVariant = applyPluginCapturingVariantCallback(env) + onVariant(mockApplicationVariant()) + + val assembleDebug = mockk() + every { assembleDebug.name } returns "assembleDebug" + every { assembleDebug.dependsOn(*anyVararg()) } returns assembleDebug + val assembleRelease = mockk() + every { assembleRelease.name } returns "assembleRelease" + configureEachSlot.captured.execute(assembleDebug) + configureEachSlot.captured.execute(assembleRelease) + + verify { assembleDebug.dependsOn(mockCopyApksTaskProvider) } + verify(exactly = 0) { assembleRelease.dependsOn(*anyVararg()) } + } + + // How each Gradle property is parsed is covered by FlutterCompileOptionsTest. This covers the + // mapping of those options, and of the variant's own values, onto the FlutterTask. + @Test + fun `registerFlutterCompileTask applies compile options and variant values to the task`( + @TempDir tempDir: Path + ) { + val env = setupTestProjectEnvironment(tempDir) + val project = env.project + every { project.findProperty("filesystem-scheme") } returns "custom-scheme" + every { project.findProperty("track-widget-creation") } returns "false" + every { project.findProperty("frontend-server-starter-path") } returns "starter.dart" + every { project.findProperty("extra-front-end-options") } returns "--opt1" + every { project.findProperty("extra-gen-snapshot-options") } returns "--opt2" + every { project.findProperty("split-debug-info") } returns "debug/info" + every { project.findProperty("tree-shake-icons") } returns "true" + every { project.findProperty("dart-obfuscation") } returns "true" + every { project.findProperty("dart-defines") } returns "key=val" + every { project.findProperty("performance-measurement-file") } returns "perf.json" + every { project.findProperty("code-size-directory") } returns "code/size" + every { project.findProperty("deferred-components") } returns "true" + every { project.findProperty("validate-deferred-components") } returns "false" + + val compileActionSlot = slot>() + every { + project.tasks.register("compileFlutterBuildDebug", FlutterTask::class.java, capture(compileActionSlot)) + } returns mockk(relaxed = true) + + val onVariant = applyPluginCapturingVariantCallback(env) + onVariant(mockApplicationVariant(flavorName = "free", minSdkApiLevel = 24)) + + val mockFlutterTask = mockk(relaxed = true) + compileActionSlot.captured.execute(mockFlutterTask) + + verify { mockFlutterTask.buildMode = "debug" } + verify { mockFlutterTask.minSdkVersion = 24 } + verify { mockFlutterTask.flavor = "free" } + verify { mockFlutterTask.trackWidgetCreation = false } + verify { mockFlutterTask.dartObfuscation = true } + verify { mockFlutterTask.treeShakeIcons = true } + verify { mockFlutterTask.deferredComponents = true } + verify { mockFlutterTask.validateDeferredComponents = false } + verify { mockFlutterTask.fileSystemScheme = "custom-scheme" } + verify { mockFlutterTask.frontendServerStarterPath = "starter.dart" } + verify { mockFlutterTask.extraFrontEndOptions = "--opt1" } + verify { mockFlutterTask.extraGenSnapshotOptions = "--opt2" } + verify { mockFlutterTask.splitDebugInfo = "debug/info" } + verify { mockFlutterTask.dartDefines = "key=val" } + verify { mockFlutterTask.performanceMeasurementFile = "perf.json" } + verify { mockFlutterTask.codeSizeDirectory = "code/size" } + } + + /** + * Applies the plugin to [env]'s project, runs its `finalizeDsl` callbacks as AGP would, and + * returns its `onVariants` callback. + */ + private fun applyPluginCapturingVariantCallback( + env: TestProjectEnvironment, + defaultConfigVersionCode: Int? = null, + productFlavorVersionCodes: Map = emptyMap() + ): (Variant) -> Unit { + val appExtension = setupMockApplicationExtension(env.project) + val defaultConfig = appExtension.defaultConfig + every { defaultConfig.versionCode } returns defaultConfigVersionCode + val productFlavors = mockProductFlavors(productFlavorVersionCodes) + every { appExtension.productFlavors } returns productFlavors + val mockComponentsExtension = setupMockComponentsExtension(env.project) + setupMockNativePluginLoader(env.project, env.flutterExtension) + + val finalizeDslCallbacks = mutableListOf<(Any) -> Unit>() + every { mockComponentsExtension.finalizeDsl(capture(finalizeDslCallbacks)) } returns Unit + val onVariantSlot = slot<(Variant) -> Unit>() + every { mockComponentsExtension.onVariants(any(), capture(onVariantSlot)) } returns Unit + + FlutterPlugin().apply(env.project) + finalizeDslCallbacks.forEach { callback -> callback(appExtension) } + return onVariantSlot.captured + } + + /** [applyPluginCapturingVariantCallback] for an add-to-app module (Android library plugin). */ + private fun applyPluginToModuleCapturingVariantCallback(env: TestProjectEnvironment): (Variant) -> Unit { + val project = env.project + every { project.plugins.hasPlugin("com.android.application") } returns false + every { project.extensions.findByType(ApplicationExtension::class.java) } returns null + + val mockLibraryExtension = mockk(relaxed = true) + every { project.extensions.findByName("android") } returns mockLibraryExtension + every { project.extensions.getByType(LibraryExtension::class.java) } returns mockLibraryExtension + val mockDebugBuildType = + mockk(relaxed = true) { + every { name } returns "debug" + } + val mockReleaseBuildType = + mockk(relaxed = true) { + every { name } returns "release" + } + val container = mockk>(relaxed = true) + every { container.getByName("debug") } returns mockDebugBuildType + every { container.getByName("release") } returns mockReleaseBuildType + every { container.all(any>()) } answers { + val action = firstArg>() + action.execute(mockDebugBuildType) + action.execute(mockReleaseBuildType) + } + every { mockLibraryExtension.buildTypes } returns container + + val mockComponentsExtension = setupMockComponentsExtension(project) + setupMockNativePluginLoader(project, env.flutterExtension) + val onVariantSlot = slot<(Variant) -> Unit>() + every { mockComponentsExtension.onVariants(any(), capture(onVariantSlot)) } returns Unit + + FlutterPlugin().apply(project) + return onVariantSlot.captured + } + + /** + * An [ApplicationVariant] that answers everything the plugin reads while configuring a + * variant. Pass the values a test is about; the rest are stubbed so the plugin can run. + */ + private fun mockApplicationVariant( + name: String = "debug", + buildType: String = "debug", + debuggable: Boolean = true, + flavorName: String? = null, + productFlavors: List> = emptyList(), + minSdkApiLevel: Int = 21, + assetsSource: SourceDirectories.Layered? = mockk(relaxed = true), + outputs: List = emptyList() + ): ApplicationVariant { + val mockVariant = mockk(relaxed = true) + val mockSources = mockk(relaxed = true) + every { mockVariant.name } returns name + every { mockVariant.buildType } returns buildType + every { mockVariant.debuggable } returns debuggable + every { mockVariant.flavorName } returns flavorName + every { mockVariant.productFlavors } returns productFlavors + every { mockVariant.minSdk.apiLevel } returns minSdkApiLevel + every { mockVariant.sources } returns mockSources + every { mockSources.assets } returns assetsSource + every { mockVariant.outputs } returns outputs + return mockVariant + } + + /** [mockApplicationVariant] for an add-to-app module. */ + private fun mockLibraryVariant( + name: String = "debug", + buildType: String = "debug", + debuggable: Boolean = true, + minSdkApiLevel: Int = 21, + assetsSource: SourceDirectories.Layered? = mockk(relaxed = true), + jniLibsSource: SourceDirectories.Layered? = mockk(relaxed = true) + ): LibraryVariant { + val mockVariant = mockk(relaxed = true) + val mockSources = mockk(relaxed = true) + every { mockVariant.name } returns name + every { mockVariant.buildType } returns buildType + every { mockVariant.debuggable } returns debuggable + every { mockVariant.flavorName } returns null + every { mockVariant.productFlavors } returns emptyList() + every { mockVariant.minSdk.apiLevel } returns minSdkApiLevel + every { mockVariant.sources } returns mockSources + every { mockSources.assets } returns assetsSource + every { mockSources.jniLibs } returns jniLibsSource + return mockVariant + } + + /** A mocked [VariantOutput] and the mocked `versionCode` property it returns. */ + private data class MockVariantOutput( + val output: VariantOutput, + val versionCode: Property + ) + + /** Stubs registering task [name] to return a relaxed provider, which is returned. */ + private inline fun Project.stubTaskRegistration(name: String): TaskProvider { + val provider = mockk>(relaxed = true) + every { tasks.register(name, T::class.java, any()) } returns provider + return provider + } + + /** + * Captures the configuration action registered for task [name]. The returned function runs + * that action on a relaxed mock task and returns the task, for verifying what it set. + */ + private inline fun Project.captureTaskConfiguration(name: String): () -> T { + val action = slot>() + every { tasks.register(name, T::class.java, capture(action)) } returns mockk(relaxed = true) + return { mockk(relaxed = true).also { action.captured.execute(it) } } + } + + private fun Project.setCommandLineTasks(vararg taskNames: String) { + every { gradle.startParameter.taskNames } returns taskNames.toList() + } + + /** + * A [VariantOutput] with an ABI filter for [abi] (none if null). Only `set` is stubbed on its + * versionCode, so any read fails the test, as AGP's strict mode would. + */ + private fun mockVariantOutput(abi: String?): MockVariantOutput { + val versionCodeProperty = mockk>() + every { versionCodeProperty.set(any()) } just Runs + val output = mockk() + every { output.filters } returns mockAbiFilters(abi) + every { output.versionCode } returns versionCodeProperty + return MockVariantOutput(output, versionCodeProperty) + } + + private data class TestProjectEnvironment( + val projectDir: File, + val fakeFlutterSdkDir: File, + val project: Project, + val flutterExtension: FlutterExtension + ) + + private fun setupTestProjectEnvironment( + tempDir: Path, + engineStamp: String = FAKE_ENGINE_STAMP, + engineRealm: String = FAKE_ENGINE_REALM + ): TestProjectEnvironment { val projectDir = tempDir.resolve("project-dir").resolve("android").resolve("app") projectDir.toFile().mkdirs() val settingsFile = projectDir.parent.resolve("settings.gradle") @@ -147,192 +756,110 @@ class FlutterPluginTest { val fakeCacheDir = fakeFlutterSdkDir.resolve("bin").resolve("cache") fakeCacheDir.toFile().mkdirs() val fakeEngineStampFile = fakeCacheDir.resolve("engine.stamp") - fakeEngineStampFile.writeText(FAKE_ENGINE_STAMP) + fakeEngineStampFile.writeText(engineStamp) val fakeEngineRealmFile = fakeCacheDir.resolve("engine.realm") - fakeEngineRealmFile.writeText(FAKE_ENGINE_REALM) + fakeEngineRealmFile.writeText(engineRealm) + val project = mockk(relaxed = true) - val mockAbstractAppExtension = - mockk( - moreInterfaces = arrayOf(ApplicationExtension::class), - relaxed = true - ) - every { project.extensions.findByType(AbstractAppExtension::class.java) } returns mockAbstractAppExtension - every { project.extensions.getByType(AbstractAppExtension::class.java) } returns mockAbstractAppExtension - every { project.extensions.findByName("android") } returns mockAbstractAppExtension - val mockAndroidComponentsExtension = mockk>(relaxed = true) - every { project.extensions.getByType(AndroidComponentsExtension::class.java) } returns mockAndroidComponentsExtension - every { project.extensions.findByType(AndroidComponentsExtension::class.java) } returns mockAndroidComponentsExtension - val mockSelector = mockk(relaxed = true) - every { mockAndroidComponentsExtension.selector() } returns mockSelector - every { mockSelector.all() } returns mockSelector - every { mockSelector.withName(any()) } returns mockSelector every { project.projectDir } returns projectDir.toFile() every { project.findProperty("flutter.sdk") } returns fakeFlutterSdkDir.toString() every { project.file(fakeFlutterSdkDir.toString()) } returns fakeFlutterSdkDir.toFile() + every { project.plugins.hasPlugin("com.android.application") } returns true + every { project.rootProject } returns project + every { project.state.failure as Throwable? } returns null + every { project.configurations.named("api") } returns mockk() + val flutterExtension = FlutterExtension() every { project.extensions.create("flutter", any>()) } returns flutterExtension every { project.extensions.findByType(FlutterExtension::class.java) } returns flutterExtension - val mockBaseExtension = mockk(relaxed = true) - val mockCommonExtension = mockk>(relaxed = true) - val mockDebugBuildType = mockk(relaxed = true) - val mockReleaseBuildType = mockk(relaxed = true) - - // Cast our multi-interface mock instead of creating a brand new one - val mockApplicationExtension = mockAbstractAppExtension as ApplicationExtension - // Mock buildTypes on our new dual-purpose mock so AgpCommonExtensionWrapper can read them - every { mockApplicationExtension.buildTypes.getByName("debug") } returns mockDebugBuildType - every { mockApplicationExtension.buildTypes.getByName("release") } returns mockReleaseBuildType - - // Keep the CommonExtension mocks just in case other parts of the plugin look for it - every { mockCommonExtension.buildTypes.getByName("debug") } returns mockDebugBuildType - every { mockCommonExtension.buildTypes.getByName("release") } returns mockReleaseBuildType + return TestProjectEnvironment( + projectDir.toFile(), + fakeFlutterSdkDir.toFile(), + project, + flutterExtension + ) + } - every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension - every { project.extensions.findByType(CommonExtension::class.java) } returns mockCommonExtension + private fun setupMockApplicationExtension( + project: Project, + mockDebugBuildType: ApplicationBuildType = + mockk(relaxed = true) { + every { name } returns "debug" + every { isDebuggable } returns true + }, + mockReleaseBuildType: ApplicationBuildType = + mockk(relaxed = true) { + every { name } returns "release" + every { isDebuggable } returns false + } + ): ApplicationExtension { + val mockAbstractAppExtension = + mockk( + moreInterfaces = arrayOf(ApplicationExtension::class), + relaxed = true + ) + val mockApplicationExtension = mockAbstractAppExtension as ApplicationExtension + val mockLibraryExtension = mockk(relaxed = true) + every { project.extensions.findByType(AbstractAppExtension::class.java) } returns mockAbstractAppExtension + every { project.extensions.getByType(AbstractAppExtension::class.java) } returns mockAbstractAppExtension + every { project.extensions.getByType(LibraryExtension::class.java) } returns mockLibraryExtension + every { project.extensions.findByName("android") } returns mockAbstractAppExtension - // Pass the dual-purpose mock for any ApplicationExtension lookups + every { project.extensions.findByType(BaseExtension::class.java) } returns mockk(relaxed = true) every { project.extensions.findByType(ApplicationExtension::class.java) } returns mockApplicationExtension every { project.extensions.getByType(ApplicationExtension::class.java) } returns mockApplicationExtension + val container = mockk>(relaxed = true) + every { container.getByName("debug") } returns mockDebugBuildType + every { container.getByName("release") } returns mockReleaseBuildType + every { container.all(any>()) } answers { + val action = firstArg>() + action.execute(mockDebugBuildType) + action.execute(mockReleaseBuildType) + } + every { mockApplicationExtension.buildTypes } returns container + val mockApplicationDefaultConfig = mockk( moreInterfaces = arrayOf(ApplicationDefaultConfig::class), relaxed = true ) every { mockApplicationExtension.defaultConfig } returns mockApplicationDefaultConfig - every { project.rootProject } returns project - every { project.state.failure as Throwable? } returns null val mockDirectory = mockk(relaxed = true) every { project.layout.buildDirectory.get() } returns mockDirectory val mockAndroidSourceSet = mockk(relaxed = true) val mockAndroidSourceDirectorySet = mockk(relaxed = true) every { mockAndroidSourceSet.jniLibs.srcDir(any()) } returns mockAndroidSourceDirectorySet every { mockAbstractAppExtension.sourceSets.getByName("main") } returns mockAndroidSourceSet - // mock return of NativePluginLoaderReflectionBridge.getPlugins - mockkObject(NativePluginLoaderReflectionBridge) - every { NativePluginLoaderReflectionBridge.getPlugins(any(), any()) } returns - listOf() - // mock method calls that are invoked by the args to NativePluginLoaderReflectionBridge - every { project.extraProperties } returns mockk() - every { project.file(flutterExtension.source!!) } returns mockk() - // Set up the task container and our task capture - val taskContainer = mockk(relaxed = true) - every { project.tasks } returns taskContainer - val copyTaskActionCaptor = slot>() - val copyTask = mockk(relaxed = true) - val mockVariant = mockk(relaxed = true) - every { mockVariant.name } returns "debug" - every { mockVariant.buildType.name } returns "debug" - every { mockVariant.flavorName } returns "" - val mergedFlavor = mockk(relaxed = true) - every { mockVariant.mergedFlavor } returns mergedFlavor - val apiLevel = mockk(relaxed = true) - every { apiLevel.apiLevel } returns 21 - every { mergedFlavor.minSdkVersion } returns apiLevel - val variantOutput = mockk(relaxed = true) - val outputsIterator = mockk>() - every { outputsIterator.hasNext() } returns true andThen false - every { outputsIterator.next() } returns variantOutput - val variantOutputCollection = mockk>() - every { variantOutputCollection.iterator() } returns outputsIterator - every { mockVariant.outputs } returns variantOutputCollection - val processResourcesProvider = mockk>(relaxed = true) - every { processResourcesProvider.hint(ProcessAndroidResources::class).get() } returns mockk(relaxed = true) - every { variantOutput.processResourcesProvider } returns processResourcesProvider - val assembleTask = mockk(relaxed = true) - val assembleTaskProvider = mockk>(relaxed = true) - every { assembleTaskProvider.get() } returns assembleTask - every { mockVariant.assembleProvider } returns assembleTaskProvider - val variants = listOf(mockVariant) - val variantsIterator = mockk>() - every { variantsIterator.hasNext() } returns true andThen false - every { variantsIterator.next() } returns mockVariant - val variantCollection = mockk>() - every { mockAbstractAppExtension.applicationVariants } returns variantCollection - every { variantCollection.iterator() } returns variantsIterator - every { - variantCollection.configureEach(any>()) - } answers { - variants.forEach { firstArg>().execute(it) } - } - every { mockVariant.mergeAssetsProvider.hint(MergeSourceSetFolders::class).get() } returns - mockk(relaxed = true) - val flutterTask = mockk(relaxed = true) - val copySpec = mockk(relaxed = true) - every { - (flutterTask).assets - } returns copySpec - val flutterTaskProvider = mockk>(relaxed = true) - every { - flutterTaskProvider.hint(FlutterTask::class).get() - } returns flutterTask - every { - taskContainer.register( - match { it.contains("compileFlutterBuild") }, - any>(), - any() - ) - } answers { - flutterTaskProvider - } - // Actual task that should be captured to test if permissions have been set - val mockCopyTaskProvider = mockk>(relaxed = true) - every { mockCopyTaskProvider.hint(Copy::class).get() } returns copyTask - every { - taskContainer.register( - match { it.startsWith("copyFlutterAssets") }, - eq(Copy::class.java), - capture(copyTaskActionCaptor) - ) - } answers { - mockCopyTaskProvider - } - val mockJarTaskProvider = mockk>(relaxed = true) - every { mockJarTaskProvider.hint(org.gradle.api.tasks.bundling.Jar::class).get() } returns - mockk(relaxed = true) + + return mockApplicationExtension + } + + private fun setupMockComponentsExtension(project: Project): AndroidComponentsExtension { + val mockAndroidComponentsExtension = + mockk>(relaxed = true) every { - taskContainer.register( - match { it.contains("packJniLibs") }, - eq(org.gradle.api.tasks.bundling.Jar::class.java), - any() - ) - } answers { - mockJarTaskProvider - } - val mockTaskProvider = mockk>(relaxed = true) - every { mockTaskProvider.hint(Task::class).get() } returns mockk(relaxed = true) + project.extensions.getByType(AndroidComponentsExtension::class.java) + } returns (mockAndroidComponentsExtension as AndroidComponentsExtension<*, *, *>) every { - taskContainer.named(any()) - } returns mockTaskProvider - val flutterPlugin = FlutterPlugin() - flutterPlugin.apply(project) + project.extensions.findByType(AndroidComponentsExtension::class.java) + } returns (mockAndroidComponentsExtension as AndroidComponentsExtension<*, *, *>) + val mockSelector = mockk(relaxed = true) + every { mockAndroidComponentsExtension.selector() } returns mockSelector + every { mockSelector.all() } returns mockSelector + every { mockSelector.withName(any()) } returns mockSelector + return mockAndroidComponentsExtension + } - copyTaskActionCaptor.captured.execute(copyTask) - val filePermissionsActionCaptor = slot>() - verify { - copyTask.filePermissions(capture(filePermissionsActionCaptor)) - } - if (filePermissionsActionCaptor.isCaptured) { - val mockFilePermissionSet = mockk(relaxed = true) - filePermissionsActionCaptor.captured.execute(mockFilePermissionSet) - val userPermissionsActionCaptor = slot>() - verify { - mockFilePermissionSet.user(capture(userPermissionsActionCaptor)) - } - if (userPermissionsActionCaptor.isCaptured) { - val mockUserPermission = mockk(relaxed = true) - userPermissionsActionCaptor.captured.execute(mockUserPermission) - verify { - mockUserPermission.read = true - mockUserPermission.write = true - } - } else { - fail("User permissions configuration action was not captured") - } - } else { - fail("FilePermissions configuration action was not captured") - } + private fun setupMockNativePluginLoader( + project: Project, + flutterExtension: FlutterExtension + ) { + mockkObject(NativePluginLoaderReflectionBridge) + every { NativePluginLoaderReflectionBridge.getPlugins(any(), any()) } returns listOf() + every { project.extraProperties } returns mockk() + every { project.file(flutterExtension.source!!) } returns mockk() } companion object { diff --git a/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt index 2e50f417..1f4157d1 100644 --- a/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt +++ b/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt @@ -5,14 +5,13 @@ package com.flutter.gradle import com.android.build.api.AndroidPluginVersion +import com.android.build.api.dsl.ApplicationBuildType import com.android.build.api.dsl.ApplicationExtension +import com.android.build.api.dsl.DynamicFeatureBuildType +import com.android.build.api.dsl.LibraryBuildType import com.android.build.api.variant.AndroidComponentsExtension import com.android.build.api.variant.Variant import com.android.build.api.variant.VariantBuilder -import com.android.build.gradle.BaseExtension -import com.android.build.gradle.internal.dsl.CmakeOptions -import com.android.build.gradle.internal.dsl.DefaultConfig -import com.android.builder.model.BuildType import com.flutter.gradle.FlutterPluginUtils.BUILT_IN_KOTLIN_DOCS import com.flutter.gradle.FlutterPluginUtils.BUILT_IN_KOTLIN_DOCS_FOR_APPS import com.flutter.gradle.FlutterPluginUtils.BUILT_IN_KOTLIN_DOCS_FOR_PLUGINS @@ -21,7 +20,7 @@ import com.flutter.gradle.FlutterPluginUtils.detectApplyingKotlinGradlePlugin import com.flutter.gradle.plugins.PluginHandler import com.flutter.gradle.tasks.EnableHcppManifestTask import com.flutter.gradle.tasks.PrintTask -import io.mockk.called +import com.flutter.gradle.testing.setUpMockExternalNativeBuild import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject @@ -31,10 +30,7 @@ import io.mockk.verify import org.gradle.api.Action import org.gradle.api.GradleException import org.gradle.api.Project -import org.gradle.api.Task import org.gradle.api.UnknownTaskException -import org.gradle.api.file.Directory -import org.gradle.api.file.DirectoryProperty import org.gradle.api.internal.project.ProjectInternal import org.gradle.api.invocation.Gradle import org.gradle.api.logging.Logger @@ -384,26 +380,63 @@ class FlutterPluginUtilsTest { @Test fun `shouldConfigureFlutterTask returns true for assemble task`() { val project = mockk() - val assembleTask = mockk() every { project.gradle.startParameter.taskNames } returns listOf("assemble") - val result = FlutterPluginUtils.shouldConfigureFlutterTask(project, assembleTask) + val result = FlutterPluginUtils.shouldConfigureFlutterTask(project, "assemble") assertEquals(true, result) } @Test fun `shouldConfigureFlutterTask returns true when taskname and assembleTask end with Release`() { val project = mockk() - val assembleTask = mockk() every { project.gradle.startParameter.taskNames } returns listOf("assembleRelease") - every { assembleTask.name } returns "assembleSomethingElseRelease" - val result = FlutterPluginUtils.shouldConfigureFlutterTask(project, assembleTask) + val result = FlutterPluginUtils.shouldConfigureFlutterTask(project, "assembleSomethingElseRelease") assertEquals(true, result) } + @Test + fun `shouldConfigureFlutterTask with assembleTaskName string parameter`() { + val project = mockk() + + every { project.gradle.startParameter.taskNames } returns listOf("assembleDebug") + + val result = FlutterPluginUtils.shouldConfigureFlutterTask(project, "assembleDebug") + assertEquals(true, result) + } + + @Test + fun `shouldConfigureFlutterTask returns true when taskname and assembleTask end with Profile`() { + val project = mockk() + + every { project.gradle.startParameter.taskNames } returns listOf("assembleProfile") + + val result = FlutterPluginUtils.shouldConfigureFlutterTask(project, "assembleSomethingElseProfile") + assertEquals(true, result) + } + + @Test + fun `shouldConfigureFlutterTask with assembleTaskName string parameter for Profile`() { + val project = mockk() + + every { project.gradle.startParameter.taskNames } returns listOf("assembleProfile") + + val result = FlutterPluginUtils.shouldConfigureFlutterTask(project, "assembleProfile") + assertEquals(true, result) + } + + @Test + fun `shouldConfigureFlutterTask returns false when taskname is Debug and assembleTask is Profile`() { + val project = mockk() + + every { project.gradle.startParameter.taskNames } returns listOf("assembleDebug") + + val result = FlutterPluginUtils.shouldConfigureFlutterTask(project, "assembleSomethingElseProfile") + assertEquals(false, result) + } + // getFlutterSourceDirectory @Test fun `getFlutterSourceDirectory returns the flutter source directory`() { @@ -501,32 +534,46 @@ class FlutterPluginUtilsTest { // buildModeFor @Test - fun `buildModeFor returns profile if the BuildType has name profile`() { - val buildType = mockk() - every { buildType.name } returns "profile" + fun `buildModeFor with a name and debuggable flag prefers the profile name over debuggability`() { + assertEquals("profile", FlutterPluginUtils.buildModeFor("profile", isDebuggable = true)) + assertEquals("debug", FlutterPluginUtils.buildModeFor("staging", isDebuggable = true)) + assertEquals("release", FlutterPluginUtils.buildModeFor("staging", isDebuggable = false)) + } + + @Test + fun `buildModeFor reads isDebuggable from new-DSL application build types`() { + val buildType = mockk() + every { buildType.name } returns "staging" + every { buildType.isDebuggable } returns true - val result = FlutterPluginUtils.buildModeFor(buildType) - assertEquals("profile", result) + assertEquals("debug", FlutterPluginUtils.buildModeFor(buildType)) } @Test - fun `buildModeFor returns debug if the BuildType is debuggable`() { - val buildType = mockk() - every { buildType.name } returns "something random" + fun `buildModeFor reads isDebuggable from new-DSL dynamic feature build types`() { + val buildType = mockk() + every { buildType.name } returns "staging" every { buildType.isDebuggable } returns true - val result = FlutterPluginUtils.buildModeFor(buildType) - assertEquals("debug", result) + assertEquals("debug", FlutterPluginUtils.buildModeFor(buildType)) } @Test - fun `buildModeFor returns release if the BuildType is not debuggable and not named profile`() { - val buildType = mockk() - every { buildType.isDebuggable } returns false - every { buildType.name } returns "something random" + fun `buildModeFor falls back to the conventional debug name for new-DSL library build types`() { + // LibraryBuildType has no public isDebuggable flag at DSL scope, so the name is the only signal. + // For custom build types (e.g. host app 'staging'), DSL-scope loops fall back to "release" + // until unified with Component.debuggable at variant scope in PR 8. + val profileBuildType = mockk() + every { profileBuildType.name } returns "profile" + assertEquals("profile", FlutterPluginUtils.buildModeFor(profileBuildType)) + + val debugBuildType = mockk() + every { debugBuildType.name } returns "debug" + assertEquals("debug", FlutterPluginUtils.buildModeFor(debugBuildType)) - val result = FlutterPluginUtils.buildModeFor(buildType) - assertEquals("release", result) + val customBuildType = mockk() + every { customBuildType.name } returns "staging" + assertEquals("release", FlutterPluginUtils.buildModeFor(customBuildType)) } // supportsBuildMode @@ -617,9 +664,58 @@ class FlutterPluginUtilsTest { @Test fun `getCompileSdkFromProject returns the compileSdk from the project`() { val project = mockk() - every { project.extensions.findByType(BaseExtension::class.java)!!.compileSdkVersion } returns "android-35" + val androidExtension = mockk() + every { project.extensions.findByName("android") } returns androidExtension + every { androidExtension.compileSdk } returns 35 + every { androidExtension.compileSdkPreview } returns null + val result = FlutterPluginUtils.getCompileSdkFromProject(project) + assertEquals(CompileSdkVersion(apiLevel = 35, previewCodename = null), result) + assertEquals("35", result.toString()) + } + + @Test + fun `getCompileSdkFromProject returns the preview codename from the project`() { + val project = mockk() + val androidExtension = mockk() + every { project.extensions.findByName("android") } returns androidExtension + every { androidExtension.compileSdk } returns null + every { androidExtension.compileSdkPreview } returns "Baklava" + val result = FlutterPluginUtils.getCompileSdkFromProject(project) + assertEquals(CompileSdkVersion(apiLevel = null, previewCodename = "Baklava"), result) + assertEquals("Baklava", result.toString()) + } + + // Regression test for https://github.com/flutter/flutter/issues/191081: a plugin that also + // sets compileSdkExtension (required by AARs with a minCompileSdkExtension AAR metadata + // constraint, e.g. androidx.health.connect:connect-client) must not affect the reported + // compile SDK. compileSdk and compileSdkExtension are distinct typed AGP properties, so the + // extension level plays no part in this apiLevel/previewCodename result or in any downstream + // isHigherThan comparison. + @Test + fun `getCompileSdkFromProject ignores compileSdkExtension`() { + val project = mockk() + val androidExtension = mockk() + every { project.extensions.findByName("android") } returns androidExtension + every { androidExtension.compileSdk } returns 36 + every { androidExtension.compileSdkPreview } returns null + every { androidExtension.compileSdkExtension } returns 19 val result = FlutterPluginUtils.getCompileSdkFromProject(project) - assertEquals("35", result) + assertEquals(CompileSdkVersion(apiLevel = 36, previewCodename = null), result) + assertEquals("36", result.toString()) + assertFalse(result.isHigherThan(CompileSdkVersion(apiLevel = 37, previewCodename = null))) + assertFalse(result.isHigherThan(CompileSdkVersion(apiLevel = 36, previewCodename = null))) + } + + @Test + fun `getCompileSdkFromProject prioritizes preview and sanitizes when both compileSdk and compileSdkPreview are set`() { + val project = mockk() + val androidExtension = mockk() + every { project.extensions.findByName("android") } returns androidExtension + every { androidExtension.compileSdk } returns 35 + every { androidExtension.compileSdkPreview } returns "Baklava" + val result = FlutterPluginUtils.getCompileSdkFromProject(project) + assertEquals(CompileSdkVersion(apiLevel = null, previewCodename = "Baklava"), result) + assertEquals("Baklava", result.toString()) } @Test @@ -1026,14 +1122,14 @@ class FlutterPluginUtilsTest { @TempDir tempDir: Path ) { val subproject = mockk() - val mockBuildFile = mockk() + // A directory exists but cannot be read as text, so readText() throws an + // IOException (FileNotFoundException) on every platform. This avoids mocking + // java.io.File, whose internal call order differs across JDK versions. + val unreadableBuildFile = tempDir.resolve("build.gradle").toFile() + assertTrue(unreadableBuildFile.mkdir()) val mockLogger = mockk(relaxed = true) - every { subproject.buildFile } returns mockBuildFile - every { mockBuildFile.exists() } returns true - every { mockBuildFile.absolutePath } returns "/some/path/build.gradle" - every { mockBuildFile.extension } returns "gradle" - every { mockBuildFile.path } throws IOException("Simulated I/O error") + every { subproject.buildFile } returns unreadableBuildFile every { subproject.projectDir } returns tempDir.toFile() every { subproject.logger } returns mockLogger @@ -1042,7 +1138,7 @@ class FlutterPluginUtilsTest { assertNull(result) verify(exactly = 1) { mockLogger.error( - "Failed to read build file: /some/path/build.gradle", + "Failed to read build file: ${unreadableBuildFile.absolutePath}", any() ) } @@ -1189,9 +1285,10 @@ class FlutterPluginUtilsTest { // This AGP version will should match the Flutter create template values. // In //packages/flutter_tools/lib/src/android/gradle_utils.dart - private val templateAgpVersion = AndroidPluginVersion(9, 1, 0) + private val templateAgpVersion = AndroidPluginVersion(9, 3, 1) - private val errorAgpVersion = DependencyVersionChecker.errorAGPVersion + // Any AGP version below 9, which does not support built-in Kotlin. + private val agp8Version = AndroidPluginVersion(8, 13, 0) private fun mockBuiltInKotlinProperty(value: String?) { val mockProvider = mockk>() @@ -1225,7 +1322,7 @@ class FlutterPluginUtilsTest { fun `returns false when AGP is less than 9 and builtInKotlin is set to true`() { val subproject = setupProjectWithProperty("true") - val result = FlutterPluginUtils.isBuiltInKotlinEnabled(subproject, errorAgpVersion) + val result = FlutterPluginUtils.isBuiltInKotlinEnabled(subproject, agp8Version) assertFalse(result) } @@ -1234,7 +1331,7 @@ class FlutterPluginUtilsTest { fun `returns false when AGP is less than 9 and builtInKotlin is set to TRUE`() { val subproject = setupProjectWithProperty("TRUE") - val result = FlutterPluginUtils.isBuiltInKotlinEnabled(subproject, errorAgpVersion) + val result = FlutterPluginUtils.isBuiltInKotlinEnabled(subproject, agp8Version) assertFalse(result) } @@ -1243,7 +1340,7 @@ class FlutterPluginUtilsTest { fun `returns false when AGP is less than 9 and builtInKotlin is set to false`() { val subproject = setupProjectWithProperty("false") - val result = FlutterPluginUtils.isBuiltInKotlinEnabled(subproject, errorAgpVersion) + val result = FlutterPluginUtils.isBuiltInKotlinEnabled(subproject, agp8Version) assertFalse(result) } @@ -1252,7 +1349,7 @@ class FlutterPluginUtilsTest { fun `returns false when AGP is less than 9 and builtInKotlin is set to FALSE`() { val subproject = setupProjectWithProperty("FALSE") - val result = FlutterPluginUtils.isBuiltInKotlinEnabled(subproject, errorAgpVersion) + val result = FlutterPluginUtils.isBuiltInKotlinEnabled(subproject, agp8Version) assertFalse(result) } @@ -1751,7 +1848,7 @@ class FlutterPluginUtilsTest { val testProject = setupTest( tempDir = tempDir, - agpVersion = errorAgpVersion, + agpVersion = agp8Version, builtInKotlin = "false", appConfig = SubprojectConfig("app", declarativelyAppliedPlugins = listOf("com.android.application")), pluginConfigs = @@ -1780,7 +1877,7 @@ class FlutterPluginUtilsTest { val testProject = setupTest( tempDir = tempDir, - agpVersion = errorAgpVersion, + agpVersion = agp8Version, builtInKotlin = "false", appConfig = SubprojectConfig( @@ -1814,7 +1911,7 @@ class FlutterPluginUtilsTest { val testProject = setupTest( tempDir = tempDir, - agpVersion = errorAgpVersion, + agpVersion = agp8Version, builtInKotlin = "false", appConfig = SubprojectConfig( @@ -1872,76 +1969,41 @@ class FlutterPluginUtilsTest { fun `forceNdkDownload skips projects which are already configuring a native build`( @TempDir tempDir: Path ) { - val fakeCmakeFile = tempDir.resolve("CMakeLists.txt").toFile() - fakeCmakeFile.createNewFile() + val fakeCmakeFile = tempDir.resolve("CMakeLists.txt").toFile().apply { createNewFile() } val project = mockk() - val mockCmakeOptions = mockk() - val mockNdkBuildOptions = mockk() - val mockDefaultConfig = mockk() - every { project.extensions.findByType(ApplicationExtension::class.java) } returns null - every { - project.extensions - .findByType(BaseExtension::class.java)!! - .externalNativeBuild.cmake - } returns mockCmakeOptions - every { - project.extensions - .findByType(BaseExtension::class.java)!! - .externalNativeBuild.ndkBuild - } returns mockNdkBuildOptions - every { project.extensions.findByType(BaseExtension::class.java)!!.defaultConfig } returns mockDefaultConfig - - every { mockCmakeOptions.path } returns fakeCmakeFile - every { mockNdkBuildOptions.path } returns null + val (mockAndroidExtension, mockCmake) = setUpMockExternalNativeBuild(project, cmakePath = fakeCmakeFile) FlutterPluginUtils.forceNdkDownload(project, "ignored") - verify(exactly = 1) { - mockCmakeOptions.path - } - verify(exactly = 0) { mockCmakeOptions.setPath(any()) } - verify { mockDefaultConfig wasNot called } + // Verifies the guard checked the cmake.path getter + verify(exactly = 1) { mockCmake.path } + // Verifies synthetic fallback was not configured (setter never invoked, buildTypes untouched) + verify(exactly = 0) { mockCmake.path = any() } + verify(exactly = 0) { mockCmake.buildStagingDirectory = any() } + verify(exactly = 0) { mockAndroidExtension.buildTypes } } @Test fun `forceNdkDownload skips projects which are already configuring an ndk-build`( @TempDir tempDir: Path ) { - val fakeAndroidMkFile = tempDir.resolve("Android.mk").toFile() - fakeAndroidMkFile.createNewFile() + val fakeAndroidMkFile = tempDir.resolve("Android.mk").toFile().apply { createNewFile() } val project = mockk() - val mockCmakeOptions = mockk() - val mockNdkBuildOptions = mockk() - val mockDefaultConfig = mockk() - every { project.extensions.findByType(ApplicationExtension::class.java) } returns null - every { - project.extensions - .findByType(BaseExtension::class.java)!! - .externalNativeBuild.cmake - } returns mockCmakeOptions - every { - project.extensions - .findByType(BaseExtension::class.java)!! - .externalNativeBuild.ndkBuild - } returns mockNdkBuildOptions - every { project.extensions.findByType(BaseExtension::class.java)!!.defaultConfig } returns mockDefaultConfig - - every { mockCmakeOptions.path } returns null - every { mockNdkBuildOptions.path } returns fakeAndroidMkFile + val (mockAndroidExtension, mockCmake, mockNdkBuild) = + setUpMockExternalNativeBuild(project, ndkBuildPath = fakeAndroidMkFile) FlutterPluginUtils.forceNdkDownload(project, "ignored") - verify(exactly = 1) { - mockCmakeOptions.path - } - verify(exactly = 1) { - mockNdkBuildOptions.path - } - verify(exactly = 0) { mockCmakeOptions.path(any()) } - verify(exactly = 0) { mockCmakeOptions.buildStagingDirectory(any()) } - verify { mockDefaultConfig wasNot called } + // Verifies the guard checked both cmake.path and ndkBuild.path getters + verify(exactly = 1) { mockCmake.path } + verify(exactly = 1) { mockNdkBuild.path } + // Verifies synthetic fallback was not configured + verify(exactly = 0) { mockCmake.path = any() } + verify(exactly = 0) { mockCmake.buildStagingDirectory = any() } + verify(exactly = 0) { mockAndroidExtension.buildTypes } } + @Test fun `forceNdkDownload installs a missing ndk when tool properties are provided`( @TempDir tempDir: Path ) { @@ -1951,23 +2013,12 @@ class FlutterPluginUtilsTest { val mockExecSpec = mockk() val mockExecResult = mockk() val mockExecOperations = mockk() - val mockCmakeOptions = mockk() - val mockNdkBuildOptions = mockk() - val mockDefaultConfig = mockk() - val mockBaseExtension = mockk() - every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension - every { mockBaseExtension.externalNativeBuild.cmake } returns mockCmakeOptions - every { mockBaseExtension.externalNativeBuild.ndkBuild } returns mockNdkBuildOptions - every { mockNdkBuildOptions.path } returns null - every { mockBaseExtension.defaultConfig } returns mockDefaultConfig - every { mockBaseExtension.ndkVersion } returns "29.0.13846066" - every { mockCmakeOptions.path } returns null + val (mockAndroidExtension, mockCmake) = setUpMockExternalNativeBuild(project, ndkVersion = "29.0.13846066") every { project.findProperty(FlutterPluginUtils.PROP_SDK_MANAGER_PATH) } returns "/sdkmanager" every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns tempDir.toString() every { project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) } returns "" every { project.gradle.startParameter.taskNames } returns emptyList() every { project.gradle.startParameter.isOffline } returns false - every { project.extensions.findByType(ApplicationExtension::class.java) } returns null every { project.serviceOf() } returns mockExecOperations every { mockExecOperations.exec(capture(execActionSlot)) } answers { File(tempDir.toFile(), "ndk/29.0.13846066/source.properties").apply { @@ -1994,36 +2045,30 @@ class FlutterPluginUtilsTest { ) ) } - verify(exactly = 0) { mockCmakeOptions.path(any()) } - verify { mockDefaultConfig wasNot called } + verify(exactly = 0) { mockCmake.path = any() } + verify(exactly = 0) { mockCmake.buildStagingDirectory = any() } + verify(exactly = 0) { mockAndroidExtension.buildTypes } } @Test fun `forceNdkDownload skips sdkmanager install when the requested ndk is already installed`() { - val project = mockk() + val project = mockk() val finalizeDslSlot = captureFinalizeDslAction(project) - val mockCmakeOptions = mockk() - val mockNdkBuildOptions = mockk() - val mockDefaultConfig = mockk() - val mockBaseExtension = mockk() - every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension - every { mockBaseExtension.externalNativeBuild.cmake } returns mockCmakeOptions - every { mockBaseExtension.externalNativeBuild.ndkBuild } returns mockNdkBuildOptions - every { mockNdkBuildOptions.path } returns null - every { mockBaseExtension.defaultConfig } returns mockDefaultConfig - every { mockBaseExtension.ndkVersion } returns "29.0.13846066" - every { mockCmakeOptions.path } returns null + val mockExecOperations = mockk() + val (mockAndroidExtension, mockCmake) = setUpMockExternalNativeBuild(project, ndkVersion = "29.0.13846066") every { project.findProperty(FlutterPluginUtils.PROP_SDK_MANAGER_PATH) } returns "/sdkmanager" every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns "/sdk/root" every { project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) } returns "29.0.13846066" every { project.gradle.startParameter.taskNames } returns emptyList() - every { project.extensions.findByType(ApplicationExtension::class.java) } returns null + every { project.serviceOf() } returns mockExecOperations FlutterPluginUtils.forceNdkDownload(project, "/base/path") finalizeDslSlot.captured.invoke(Any()) - verify(exactly = 0) { mockCmakeOptions.path(any()) } - verify { mockDefaultConfig wasNot called } + verify(exactly = 0) { mockExecOperations.exec(any>()) } + verify(exactly = 0) { mockCmake.path = any() } + verify(exactly = 0) { mockCmake.buildStagingDirectory = any() } + verify(exactly = 0) { mockAndroidExtension.buildTypes } } @Test @@ -2032,48 +2077,21 @@ class FlutterPluginUtilsTest { ) { val project = mockk() val finalizeDslSlot = captureFinalizeDslAction(project) - val mockCmakeOptions = mockk() - val mockNdkBuildOptions = mockk() - val mockDefaultConfig = mockk() - val mockDirectoryProperty = mockk() - val mockDirectory = mockk() - val mockBaseExtension = mockk() var cmakePath: File? = null - every { project.extensions.findByType(ApplicationExtension::class.java) } returns null - every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension - every { mockBaseExtension.externalNativeBuild.cmake } returns mockCmakeOptions - every { mockBaseExtension.externalNativeBuild.ndkBuild } returns mockNdkBuildOptions - every { mockNdkBuildOptions.path } returns null - every { mockBaseExtension.defaultConfig } returns mockDefaultConfig - every { mockBaseExtension.ndkVersion } returns "29.0.13846066" - every { mockCmakeOptions.path } answers { cmakePath } - every { mockCmakeOptions.path(any()) } returns Unit - every { mockCmakeOptions.buildStagingDirectory(any()) } returns Unit + val (mockAndroidExtension, mockCmake) = setUpMockExternalNativeBuild(project, ndkVersion = "29.0.13846066") + every { mockCmake.path } answers { cmakePath } every { project.findProperty(FlutterPluginUtils.PROP_SDK_MANAGER_PATH) } returns null every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns "/sdk/root" every { project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) } returns "" every { project.gradle.startParameter.taskNames } returns emptyList() - every { project.layout.buildDirectory } returns mockDirectoryProperty - every { mockDirectoryProperty.dir(any()) } returns mockDirectoryProperty - every { mockDirectoryProperty.get() } returns mockDirectory - every { mockDirectory.asFile.path } returns "/randomapp/build/app/" - - val mockBuildType = mockk() - every { mockBaseExtension.buildTypes.iterator() } returns mutableListOf(mockBuildType).iterator() - every { mockBuildType.name } returns "Debug" - every { mockBuildType.externalNativeBuild.cmake.arguments(any(), any(), any()) } returns Unit FlutterPluginUtils.forceNdkDownload(project, "/base/path") cmakePath = tempDir.resolve("CMakeLists.txt").toFile() finalizeDslSlot.captured.invoke(Any()) - verify(exactly = 0) { - mockCmakeOptions.path( - "/base/path/packages/flutter_tools/gradle/src/main/scripts/CMakeLists.txt" - ) - } - verify(exactly = 0) { mockCmakeOptions.buildStagingDirectory(any()) } - verify { mockDefaultConfig wasNot called } + verify(exactly = 0) { mockCmake.path = any() } + verify(exactly = 0) { mockCmake.buildStagingDirectory = any() } + verify(exactly = 0) { mockAndroidExtension.buildTypes } } @Test @@ -2086,88 +2104,9 @@ class FlutterPluginUtilsTest { val mockExecSpec = mockk() val mockExecResult = mockk() val mockExecOperations = mockk() - val mockCmakeOptions = mockk() - val mockNdkBuildOptions = mockk() - val mockDefaultConfig = mockk() - val mockBaseExtension = mockk() - var configuredNdkVersion = "26.3.11579264" - every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension - every { mockBaseExtension.externalNativeBuild.cmake } returns mockCmakeOptions - every { mockBaseExtension.externalNativeBuild.ndkBuild } returns mockNdkBuildOptions - every { mockNdkBuildOptions.path } returns null - every { mockBaseExtension.defaultConfig } returns mockDefaultConfig - every { mockBaseExtension.ndkVersion } answers { configuredNdkVersion } - every { mockCmakeOptions.path } returns null - every { project.findProperty(FlutterPluginUtils.PROP_SDK_MANAGER_PATH) } returns "/sdkmanager" - every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns tempDir.toString() - every { - project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) - } returns "26.3.11579264" - every { project.gradle.startParameter.taskNames } returns emptyList() - every { project.gradle.startParameter.isOffline } returns false - every { project.extensions.findByType(ApplicationExtension::class.java) } returns null - every { project.serviceOf() } returns mockExecOperations - every { mockExecOperations.exec(capture(execActionSlot)) } answers { - File(tempDir.toFile(), "ndk/27.3.13750724/source.properties").apply { - parentFile.mkdirs() - createNewFile() - } - mockExecResult - } - every { mockExecResult.assertNormalExitValue() } returns mockExecResult - every { mockExecSpec.commandLine(any>()) } returns mockExecSpec - - FlutterPluginUtils.forceNdkDownload(project, "/base/path") - configuredNdkVersion = "27.3.13750724" - finalizeDslSlot.captured.invoke(Any()) - execActionSlot.captured.execute(mockExecSpec) - - verify(exactly = 1) { mockExecOperations.exec(any>()) } - verify { - mockExecSpec.commandLine( - listOf( - "/sdkmanager", - "--sdk_root=$tempDir", - "--install", - "ndk;27.3.13750724" - ) - ) - } - verify(exactly = 0) { mockCmakeOptions.path(any()) } - verify { mockDefaultConfig wasNot called } - } - - @Test - fun `forceNdkDownload waits for finalized ApplicationExtension ndkVersion before checking installed versions`( - @TempDir tempDir: Path - ) { - val project = mockk() - val finalizeDslSlot = captureFinalizeDslAction(project) - val execActionSlot = slot>() - val mockExecSpec = mockk() - val mockExecResult = mockk() - val mockExecOperations = mockk() - val mockCmakeOptions = mockk() - val mockNdkBuildOptions = mockk() - val mockDefaultConfig = mockk() - val mockBaseExtension = mockk() - val mockApplicationExtension = mockk() var configuredNdkVersion = "26.3.11579264" - every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension - every { - project.extensions.findByType(ApplicationExtension::class.java) - } returns mockApplicationExtension - every { mockBaseExtension.externalNativeBuild.cmake } returns mockCmakeOptions - every { mockBaseExtension.externalNativeBuild.ndkBuild } returns mockNdkBuildOptions - every { mockNdkBuildOptions.path } returns null - every { mockBaseExtension.defaultConfig } returns mockDefaultConfig - every { mockBaseExtension.ndkVersion } answers { - throw AssertionError( - "legacy ndkVersion should not be read when ApplicationExtension is available" - ) - } - every { mockApplicationExtension.ndkVersion } answers { configuredNdkVersion } - every { mockCmakeOptions.path } returns null + val (mockAndroidExtension, mockCmake) = setUpMockExternalNativeBuild(project, ndkVersion = null) + every { mockAndroidExtension.ndkVersion } answers { configuredNdkVersion } every { project.findProperty(FlutterPluginUtils.PROP_SDK_MANAGER_PATH) } returns "/sdkmanager" every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns tempDir.toString() every { @@ -2202,26 +2141,16 @@ class FlutterPluginUtilsTest { ) ) } - verify(exactly = 0) { mockCmakeOptions.path(any()) } - verify { mockDefaultConfig wasNot called } + verify(exactly = 0) { mockCmake.path = any() } + verify(exactly = 0) { mockCmake.buildStagingDirectory = any() } + verify(exactly = 0) { mockAndroidExtension.buildTypes } } @Test fun `forceNdkDownload skips fallback when sdkmanager is unavailable but the requested ndk is already installed`() { val project = mockk() val finalizeDslSlot = captureFinalizeDslAction(project) - val mockCmakeOptions = mockk() - val mockNdkBuildOptions = mockk() - val mockDefaultConfig = mockk() - val mockBaseExtension = mockk() - every { project.extensions.findByType(ApplicationExtension::class.java) } returns null - every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension - every { mockBaseExtension.externalNativeBuild.cmake } returns mockCmakeOptions - every { mockBaseExtension.externalNativeBuild.ndkBuild } returns mockNdkBuildOptions - every { mockNdkBuildOptions.path } returns null - every { mockBaseExtension.defaultConfig } returns mockDefaultConfig - every { mockBaseExtension.ndkVersion } returns "29.0.13846066" - every { mockCmakeOptions.path } returns null + val (mockAndroidExtension, mockCmake) = setUpMockExternalNativeBuild(project, ndkVersion = "29.0.13846066") every { project.findProperty(FlutterPluginUtils.PROP_SDK_MANAGER_PATH) } returns null every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns "/sdk/root" every { project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) } returns "29.0.13846066" @@ -2230,40 +2159,9 @@ class FlutterPluginUtilsTest { FlutterPluginUtils.forceNdkDownload(project, "/base/path") finalizeDslSlot.captured.invoke(Any()) - verify(exactly = 0) { mockCmakeOptions.path(any()) } - verify { mockDefaultConfig wasNot called } - } - - @Test - fun `forceNdkDownload reads ndkVersion from ApplicationExtension when legacy extension does not expose it`() { - val project = mockk() - val finalizeDslSlot = captureFinalizeDslAction(project) - val mockCmakeOptions = mockk() - val mockNdkBuildOptions = mockk() - val mockDefaultConfig = mockk() - val mockBaseExtension = mockk() - val mockApplicationExtension = mockk() - every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension - every { project.extensions.findByType(ApplicationExtension::class.java) } returns mockApplicationExtension - every { mockBaseExtension.externalNativeBuild.cmake } returns mockCmakeOptions - every { mockBaseExtension.externalNativeBuild.ndkBuild } returns mockNdkBuildOptions - every { mockNdkBuildOptions.path } returns null - every { mockBaseExtension.defaultConfig } returns mockDefaultConfig - every { mockBaseExtension.ndkVersion } answers { - throw AssertionError("legacy ndkVersion should not be read when ApplicationExtension is available") - } - every { mockApplicationExtension.ndkVersion } returns "29.0.13846066" - every { mockCmakeOptions.path } returns null - every { project.findProperty(FlutterPluginUtils.PROP_SDK_MANAGER_PATH) } returns "/sdkmanager" - every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns "/sdk/root" - every { project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) } returns "29.0.13846066" - every { project.gradle.startParameter.taskNames } returns emptyList() - - FlutterPluginUtils.forceNdkDownload(project, "/base/path") - finalizeDslSlot.captured.invoke(Any()) - - verify(exactly = 0) { mockCmakeOptions.path(any()) } - verify { mockDefaultConfig wasNot called } + verify(exactly = 0) { mockCmake.path = any() } + verify(exactly = 0) { mockCmake.buildStagingDirectory = any() } + verify(exactly = 0) { mockAndroidExtension.buildTypes } } @Test @@ -2274,23 +2172,12 @@ class FlutterPluginUtilsTest { val finalizeDslSlot = captureFinalizeDslAction(project) val mockExecResult = mockk() val mockExecOperations = mockk() - val mockCmakeOptions = mockk() - val mockNdkBuildOptions = mockk() - val mockDefaultConfig = mockk() - val mockBaseExtension = mockk() - every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension - every { mockBaseExtension.externalNativeBuild.cmake } returns mockCmakeOptions - every { mockBaseExtension.externalNativeBuild.ndkBuild } returns mockNdkBuildOptions - every { mockNdkBuildOptions.path } returns null - every { mockBaseExtension.defaultConfig } returns mockDefaultConfig - every { mockBaseExtension.ndkVersion } returns "29.0.13846066" - every { mockCmakeOptions.path } returns null + val (mockAndroidExtension, mockCmake) = setUpMockExternalNativeBuild(project, ndkVersion = "29.0.13846066") every { project.findProperty(FlutterPluginUtils.PROP_SDK_MANAGER_PATH) } returns "/sdkmanager" every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns tempDir.toString() every { project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) } returns "" every { project.gradle.startParameter.taskNames } returns emptyList() every { project.gradle.startParameter.isOffline } returns false - every { project.extensions.findByType(ApplicationExtension::class.java) } returns null every { project.serviceOf() } returns mockExecOperations every { mockExecOperations.exec(any>()) } returns mockExecResult every { mockExecResult.assertNormalExitValue() } returns mockExecResult @@ -2301,195 +2188,157 @@ class FlutterPluginUtilsTest { finalizeDslSlot.captured.invoke(Any()) } - verify(exactly = 0) { mockCmakeOptions.path(any()) } - verify { mockDefaultConfig wasNot called } + verify(exactly = 0) { mockCmake.path = any() } + verify(exactly = 0) { mockCmake.buildStagingDirectory = any() } + verify(exactly = 0) { mockAndroidExtension.buildTypes } } @Test fun `forceNdkDownload skips when invoking the ndk metadata task`() { val project = mockk() - val mockCmakeOptions = mockk() - val mockDefaultConfig = mockk() - val mockBaseExtension = mockk() - every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension - every { mockBaseExtension.externalNativeBuild.cmake } returns mockCmakeOptions - every { mockBaseExtension.defaultConfig } returns mockDefaultConfig - every { mockCmakeOptions.path } returns null + val (mockAndroidExtension, mockCmake) = setUpMockExternalNativeBuild(project) every { project.findProperty(FlutterPluginUtils.PROP_SDK_MANAGER_PATH) } returns null every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns null every { project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) } returns null every { project.gradle.startParameter.taskNames } returns listOf(FlutterPluginUtils.TASK_PRINT_NDK_VERSION) - every { project.extensions.findByType(ApplicationExtension::class.java) } returns mockk(relaxed = true) FlutterPluginUtils.forceNdkDownload(project, "/base/path") - verify(exactly = 0) { mockCmakeOptions.path(any()) } - verify { mockDefaultConfig wasNot called } + verify(exactly = 0) { mockCmake.path = any() } + verify(exactly = 0) { mockCmake.buildStagingDirectory = any() } + verify(exactly = 0) { mockAndroidExtension.buildTypes } } @Test - fun `forceNdkDownload falls back when tool properties are present but sdkmanager is unavailable`() { + fun `forceNdkDownload configures synthetic cmake fallback when AndroidComponentsExtension is missing`() { + val project = mockk() + val (_, mockCmake, _, cmakeArguments) = setUpMockExternalNativeBuild(project, ndkVersion = "29.0.13846066") + every { project.findProperty(FlutterPluginUtils.PROP_SDK_MANAGER_PATH) } returns "/sdkmanager" + every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns "/sdk/root" + every { project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) } returns "" + every { project.gradle.startParameter.taskNames } returns emptyList() + every { project.extensions.findByType(AndroidComponentsExtension::class.java) } returns null + val basePath = "/base/path" + + FlutterPluginUtils.forceNdkDownload(project, basePath) + + verify(exactly = 1) { + mockCmake.path = File("$basePath/packages/flutter_tools/gradle/src/main/scripts/CMakeLists.txt") + } + verify(exactly = 1) { mockCmake.buildStagingDirectory = any() } + assertEquals( + listOf("-Wno-dev", "--no-warn-unused-cli", "-DCMAKE_BUILD_TYPE=Debug"), + cmakeArguments + ) + } + + @Test + fun `forceNdkDownload configures synthetic cmake fallback when tool properties are present but sdkmanager is unavailable`() { val project = mockk() val finalizeDslSlot = captureFinalizeDslAction(project) - val mockCmakeOptions = mockk() - val mockNdkBuildOptions = mockk() - val mockDefaultConfig = mockk() - val mockDirectoryProperty = mockk() - val mockDirectory = mockk() - val mockBaseExtension = mockk() - every { project.extensions.findByType(ApplicationExtension::class.java) } returns null - every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension - every { mockBaseExtension.externalNativeBuild.cmake } returns mockCmakeOptions - every { mockBaseExtension.externalNativeBuild.ndkBuild } returns mockNdkBuildOptions - every { mockNdkBuildOptions.path } returns null - every { mockBaseExtension.defaultConfig } returns mockDefaultConfig - every { mockBaseExtension.ndkVersion } returns "29.0.13846066" - every { mockCmakeOptions.path } returns null - every { mockCmakeOptions.path(any()) } returns Unit - every { mockCmakeOptions.buildStagingDirectory(any()) } returns Unit + val (_, mockCmake, _, cmakeArguments) = setUpMockExternalNativeBuild(project, ndkVersion = "29.0.13846066") every { project.findProperty(FlutterPluginUtils.PROP_SDK_MANAGER_PATH) } returns null every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns "/sdk/root" every { project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) } returns "" every { project.gradle.startParameter.taskNames } returns emptyList() - every { project.layout.buildDirectory } returns mockDirectoryProperty - every { mockDirectoryProperty.dir(any()) } returns mockDirectoryProperty - every { mockDirectoryProperty.get() } returns mockDirectory - every { mockDirectory.asFile.path } returns "/randomapp/build/app/" + every { project.gradle.startParameter.isOffline } returns false val basePath = "/base/path" - val mockBuildType = mockk() - every { mockBaseExtension.buildTypes.iterator() } returns mutableListOf(mockBuildType).iterator() - every { mockBuildType.name } returns "Debug" - every { mockBuildType.externalNativeBuild.cmake.arguments(any(), any(), any()) } returns Unit - FlutterPluginUtils.forceNdkDownload(project, basePath) finalizeDslSlot.captured.invoke(Any()) verify(exactly = 1) { - mockCmakeOptions.path("$basePath/packages/flutter_tools/gradle/src/main/scripts/CMakeLists.txt") - } - verify(exactly = 1) { mockCmakeOptions.buildStagingDirectory(any()) } - verify(exactly = 1) { - mockBuildType.externalNativeBuild.cmake.arguments( - "-Wno-dev", - "--no-warn-unused-cli", - "-DCMAKE_BUILD_TYPE=Debug" - ) + mockCmake.path = File("$basePath/packages/flutter_tools/gradle/src/main/scripts/CMakeLists.txt") } + verify(exactly = 1) { mockCmake.buildStagingDirectory = any() } + assertEquals( + listOf("-Wno-dev", "--no-warn-unused-cli", "-DCMAKE_BUILD_TYPE=Debug"), + cmakeArguments + ) } @Test - fun `forceNdkDownload falls back when Gradle is offline`() { + fun `forceNdkDownload configures synthetic cmake fallback when Gradle is offline`() { val project = mockk() val finalizeDslSlot = captureFinalizeDslAction(project) - val mockCmakeOptions = mockk() - val mockNdkBuildOptions = mockk() - val mockDefaultConfig = mockk() - val mockDirectoryProperty = mockk() - val mockDirectory = mockk() - val mockBaseExtension = mockk() - every { project.extensions.findByType(ApplicationExtension::class.java) } returns null - every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension - every { mockBaseExtension.externalNativeBuild.cmake } returns mockCmakeOptions - every { mockBaseExtension.externalNativeBuild.ndkBuild } returns mockNdkBuildOptions - every { mockNdkBuildOptions.path } returns null - every { mockBaseExtension.defaultConfig } returns mockDefaultConfig - every { mockBaseExtension.ndkVersion } returns "29.0.13846066" - every { mockCmakeOptions.path } returns null - every { mockCmakeOptions.path(any()) } returns Unit - every { mockCmakeOptions.buildStagingDirectory(any()) } returns Unit + val (_, mockCmake, _, cmakeArguments) = setUpMockExternalNativeBuild(project, ndkVersion = "29.0.13846066") every { project.findProperty(FlutterPluginUtils.PROP_SDK_MANAGER_PATH) } returns "/sdkmanager" every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns "/sdk/root" every { project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) } returns "" every { project.gradle.startParameter.taskNames } returns emptyList() every { project.gradle.startParameter.isOffline } returns true - every { project.layout.buildDirectory } returns mockDirectoryProperty - every { mockDirectoryProperty.dir(any()) } returns mockDirectoryProperty - every { mockDirectoryProperty.get() } returns mockDirectory - every { mockDirectory.asFile.path } returns "/randomapp/build/app/" val basePath = "/base/path" - val mockBuildType = mockk() - every { mockBaseExtension.buildTypes.iterator() } returns mutableListOf(mockBuildType).iterator() - every { mockBuildType.name } returns "Debug" - every { mockBuildType.externalNativeBuild.cmake.arguments(any(), any(), any()) } returns Unit - FlutterPluginUtils.forceNdkDownload(project, basePath) finalizeDslSlot.captured.invoke(Any()) verify(exactly = 1) { - mockCmakeOptions.path("$basePath/packages/flutter_tools/gradle/src/main/scripts/CMakeLists.txt") - } - verify(exactly = 1) { mockCmakeOptions.buildStagingDirectory(any()) } - verify(exactly = 1) { - mockBuildType.externalNativeBuild.cmake.arguments( - "-Wno-dev", - "--no-warn-unused-cli", - "-DCMAKE_BUILD_TYPE=Debug" - ) + mockCmake.path = File("$basePath/packages/flutter_tools/gradle/src/main/scripts/CMakeLists.txt") } + verify(exactly = 1) { mockCmake.buildStagingDirectory = any() } + assertEquals( + listOf("-Wno-dev", "--no-warn-unused-cli", "-DCMAKE_BUILD_TYPE=Debug"), + cmakeArguments + ) } @Test fun `forceNdkDownload sets externalNativeBuild properties`() { val project = mockk() - val mockCmakeOptions = mockk() - val mockNdkBuildOptions = mockk() - val mockDefaultConfig = mockk() - val mockDirectoryProperty = mockk() - val mockDirectory = mockk() - every { project.extensions.findByType(ApplicationExtension::class.java) } returns null every { project.findProperty(FlutterPluginUtils.PROP_SDK_MANAGER_PATH) } returns null every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns null every { project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) } returns null - every { - project.extensions - .findByType(BaseExtension::class.java)!! - .externalNativeBuild.cmake - } returns mockCmakeOptions - every { - project.extensions - .findByType(BaseExtension::class.java)!! - .externalNativeBuild.ndkBuild - } returns mockNdkBuildOptions - every { project.extensions.findByType(BaseExtension::class.java)!!.defaultConfig } returns mockDefaultConfig + val (_, mockCmake, _, cmakeArguments) = setUpMockExternalNativeBuild(project) val basePath = "/base/path" - val fakeBuildPath = "/randomapp/build/app/" - every { mockCmakeOptions.path } returns null - every { mockNdkBuildOptions.path } returns null - every { mockCmakeOptions.path(any()) } returns Unit - every { mockCmakeOptions.buildStagingDirectory(any()) } returns Unit - every { project.layout.buildDirectory } returns mockDirectoryProperty - every { mockDirectoryProperty.dir(any()) } returns mockDirectoryProperty - every { mockDirectoryProperty.get() } returns mockDirectory - val realFile = File(fakeBuildPath) - every { mockDirectory.asFile } returns realFile - - val mockBuildType = mockk() - every { - project.extensions - .findByType(BaseExtension::class.java)!! - .buildTypes - .iterator() - } returns mutableListOf(mockBuildType).iterator() - every { mockBuildType.name } returns "Debug" - every { mockBuildType.externalNativeBuild.cmake.arguments(any(), any(), any()) } returns Unit FlutterPluginUtils.forceNdkDownload(project, basePath) + verify(exactly = 1) { mockCmake.path } verify(exactly = 1) { - mockCmakeOptions.path + mockCmake.path = File("$basePath/packages/flutter_tools/gradle/src/main/scripts/CMakeLists.txt") + } + verify(exactly = 1) { mockCmake.buildStagingDirectory = any() } + assertEquals( + listOf("-Wno-dev", "--no-warn-unused-cli", "-DCMAKE_BUILD_TYPE=Debug"), + cmakeArguments + ) + } + + @Test + fun `forceNdkDownload configures cmake arguments for all build types`() { + val project = mockk() + every { project.findProperty(FlutterPluginUtils.PROP_SDK_MANAGER_PATH) } returns null + every { project.findProperty(FlutterPluginUtils.PROP_ANDROID_SDK_ROOT) } returns null + every { project.findProperty(FlutterPluginUtils.PROP_INSTALLED_NDK_VERSIONS) } returns null + + val debugArgs = mutableListOf() + val releaseArgs = mutableListOf() + val debugBuildType = + mockk(relaxed = true) { + every { name } returns "debug" + every { externalNativeBuild.cmake.arguments } returns debugArgs + } + val releaseBuildType = + mockk(relaxed = true) { + every { name } returns "release" + every { externalNativeBuild.cmake.arguments } returns releaseArgs + } + + val (mockAndroidExtension, mockCmake) = setUpMockExternalNativeBuild(project) + every { mockAndroidExtension.buildTypes.iterator() } answers { + mutableListOf(debugBuildType, releaseBuildType).iterator() } - verify(exactly = 1) { mockCmakeOptions.path("$basePath/packages/flutter_tools/gradle/src/main/scripts/CMakeLists.txt") } - verify(exactly = 1) { mockCmakeOptions.buildStagingDirectory(any()) } + val basePath = "/base/path" + + FlutterPluginUtils.forceNdkDownload(project, basePath) + verify(exactly = 1) { - mockBuildType.externalNativeBuild.cmake.arguments( - "-Wno-dev", - "--no-warn-unused-cli", - "-DCMAKE_BUILD_TYPE=Debug" - ) + mockCmake.path = File("$basePath/packages/flutter_tools/gradle/src/main/scripts/CMakeLists.txt") } + verify(exactly = 1) { mockCmake.buildStagingDirectory = any() } + assertEquals(listOf("-Wno-dev", "--no-warn-unused-cli", "-DCMAKE_BUILD_TYPE=debug"), debugArgs) + assertEquals(listOf("-Wno-dev", "--no-warn-unused-cli", "-DCMAKE_BUILD_TYPE=release"), releaseArgs) } @Test @@ -2497,7 +2346,7 @@ class FlutterPluginUtilsTest { val project = mockk(relaxed = true) val androidExtension = mockk() every { androidExtension.ndkVersion } returns "29.0.13846066" - every { project.extensions.findByType(ApplicationExtension::class.java) } returns androidExtension + every { project.extensions.findByName("android") } returns androidExtension every { project.tasks.register(any(), eq(PrintTask::class.java), any()) } returns mockk() val captureSlot = slot>() @@ -2524,7 +2373,7 @@ class FlutterPluginUtilsTest { val pluginHandler = PluginHandler(project) mockkObject(NativePluginLoaderReflectionBridge) every { NativePluginLoaderReflectionBridge.getPlugins(any(), any()) } returns pluginListWithoutDevDependency - val buildType: BuildType = mockk() + val buildType = mockk() every { buildType.name } returns "debug" every { buildType.isDebuggable } returns true every { project.hasProperty("local-engine-repo") } returns true @@ -2556,7 +2405,7 @@ class FlutterPluginUtilsTest { val pluginHandler = PluginHandler(project) mockkObject(NativePluginLoaderReflectionBridge) every { NativePluginLoaderReflectionBridge.getPlugins(any(), any()) } returns pluginListWithoutDevDependency - val buildType: BuildType = mockk() + val buildType = mockk() val engineVersion = EXAMPLE_ENGINE_VERSION every { buildType.name } returns "debug" every { buildType.isDebuggable } returns true @@ -2594,7 +2443,7 @@ class FlutterPluginUtilsTest { val pluginHandler = PluginHandler(project) mockkObject(NativePluginLoaderReflectionBridge) every { NativePluginLoaderReflectionBridge.getPlugins(any(), any()) } returns pluginListWithSingleDevDependency - val buildType: BuildType = mockk() + val buildType = mockk() val engineVersion = EXAMPLE_ENGINE_VERSION every { buildType.name } returns "release" every { buildType.isDebuggable } returns false @@ -2648,7 +2497,7 @@ class FlutterPluginUtilsTest { val pluginHandler = PluginHandler(project) mockkObject(NativePluginLoaderReflectionBridge) every { NativePluginLoaderReflectionBridge.getPlugins(any(), any()) } returns pluginListWithSingleDevDependency - val buildType: BuildType = mockk() + val buildType = mockk() val engineVersion = EXAMPLE_ENGINE_VERSION every { buildType.name } returns "debug" every { buildType.isDebuggable } returns true diff --git a/packages/flutter_tools/gradle/src/test/kotlin/VersionFetcherTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/VersionFetcherTest.kt index 307d3275..1e865552 100644 --- a/packages/flutter_tools/gradle/src/test/kotlin/VersionFetcherTest.kt +++ b/packages/flutter_tools/gradle/src/test/kotlin/VersionFetcherTest.kt @@ -6,7 +6,6 @@ package com.flutter.gradle import com.android.build.api.AndroidPluginVersion import com.android.build.api.variant.AndroidComponentsExtension -import com.flutter.gradle.testing.setAgpKotlinVersionToNull import io.mockk.every import io.mockk.mockk import org.gradle.api.Project @@ -47,7 +46,6 @@ class VersionFetcherTest { fun `getKGPVersion returns version when kotlin_version is set`() { val kgpVersion = Version(1, 9, 20) val project = mockk() - setAgpKotlinVersionToNull(project) every { project.hasProperty(eq("kotlin_version")) } returns true every { project.properties["kotlin_version"] } returns kgpVersion.toString() val result = VersionFetcher.getKGPVersion(project) @@ -58,7 +56,6 @@ class VersionFetcherTest { fun `getKGPVersion returns version from KotlinAndroidPluginWrapper`() { val kgpVersion = Version(1, 9, 20) val project = mockk() - setAgpKotlinVersionToNull(project) every { project.hasProperty(eq("kotlin_version")) } returns false every { project.plugins.findPlugin(KotlinAndroidPluginWrapper::class.java) } returns mockk { @@ -67,4 +64,26 @@ class VersionFetcherTest { val result = VersionFetcher.getKGPVersion(project) assertEquals(kgpVersion, result!!) } + + @Test + fun `getKGPVersion returns null when the Kotlin Gradle plugin is absent`() { + // Expected under AGP's built-in Kotlin support, where no standalone KGP is applied. + val project = mockk() + every { project.hasProperty(eq("kotlin_version")) } returns false + every { project.plugins.findPlugin(KotlinAndroidPluginWrapper::class.java) } returns null + val result = VersionFetcher.getKGPVersion(project) + assertEquals(null, result) + } + + @Test + fun `getKGPVersion returns null when pluginVersion is unknown and reflection is unknown`() { + val project = mockk() + every { project.hasProperty(eq("kotlin_version")) } returns false + every { project.plugins.findPlugin(KotlinAndroidPluginWrapper::class.java) } returns + mockk { + every { pluginVersion } returns "unknown" + } + val result = VersionFetcher.getKGPVersion(project) + assertEquals(null, result) + } } diff --git a/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt index 111ef311..4d50aa18 100644 --- a/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt +++ b/packages/flutter_tools/gradle/src/test/kotlin/plugins/PluginHandlerTest.kt @@ -4,15 +4,17 @@ package com.flutter.gradle.plugins -import com.android.build.gradle.BaseExtension +import com.android.build.api.dsl.ApplicationBuildType +import com.android.build.api.dsl.LibraryBuildType import com.flutter.gradle.FlutterExtension -import com.flutter.gradle.FlutterPluginUtils import com.flutter.gradle.FlutterPluginUtilsTest.Companion.EXAMPLE_ENGINE_VERSION import com.flutter.gradle.FlutterPluginUtilsTest.Companion.cameraDependency import com.flutter.gradle.FlutterPluginUtilsTest.Companion.flutterPluginAndroidLifecycleDependency import com.flutter.gradle.FlutterPluginUtilsTest.Companion.pluginListWithDevDependency import com.flutter.gradle.FlutterPluginUtilsTest.Companion.pluginListWithoutDevDependency import com.flutter.gradle.NativePluginLoaderReflectionBridge +import com.flutter.gradle.testing.setUpMockAndroidExtension +import com.flutter.gradle.testing.setUpMockLibraryAndroidExtension import io.mockk.called import io.mockk.every import io.mockk.mockk @@ -40,14 +42,12 @@ class PluginHandlerTest { val project = mockk() val pluginHandler = PluginHandler(project) mockkObject(NativePluginLoaderReflectionBridge) - // mock return of NativePluginLoaderReflectionBridge.getPlugins every { NativePluginLoaderReflectionBridge.getPlugins( any(), any() ) } returns pluginListWithDevDependency - // mock method calls that are invoked by the args to NativePluginLoaderReflectionBridge every { project.extraProperties } returns mockk() every { project.extensions.findByType(FlutterExtension::class.java) } returns FlutterExtension() every { project.file(any()) } returns mockk() @@ -61,14 +61,12 @@ class PluginHandlerTest { val project = mockk() val pluginHandler = PluginHandler(project) mockkObject(NativePluginLoaderReflectionBridge) - // mock return of NativePluginLoaderReflectionBridge.getPlugins every { NativePluginLoaderReflectionBridge.getPlugins( any(), any() ) } returns pluginListWithoutDevDependency - // mock method calls that are invoked by the args to NativePluginLoaderReflectionBridge every { project.extraProperties } returns mockk() every { project.extensions.findByType(FlutterExtension::class.java) } returns FlutterExtension() every { project.file(any()) } returns mockk() @@ -77,8 +75,6 @@ class PluginHandlerTest { assertEquals(pluginListWithoutDevDependency, result) } - // getPluginList skipped as it is a wrapper around a single reflection call - // pluginSupportsAndroidPlatform @Test fun `pluginSupportsAndroidPlatform returns true when android directory exists with gradle build file`( @@ -98,7 +94,7 @@ class PluginHandlerTest { assertTrue { PluginHandler.pluginSupportsAndroidPlatform(mockProject) - } // Replace YourClass with the actual class containing the method + } } @Test @@ -123,29 +119,14 @@ class PluginHandlerTest { @TempDir tempDir: Path ) { val project = mockk() - - // configuration for configureLegacyPluginEachProjects - val projectDir = tempDir.resolve("my-plugin") - projectDir.toFile().mkdirs() - every { project.projectDir } returns projectDir.toFile() - val settingsGradle = File(projectDir.parent.toFile(), "settings.gradle") - settingsGradle.createNewFile() val mockLogger = mockk() every { project.logger } returns mockLogger + setupMockProjectDir(project, tempDir) + val pluginWithoutName: MutableMap = cameraDependency.toMutableMap() pluginWithoutName.remove("name") - - mockkObject(NativePluginLoaderReflectionBridge) - // mock return of NativePluginLoaderReflectionBridge.getPlugins - every { NativePluginLoaderReflectionBridge.getPlugins(any(), any()) } returns - listOf( - pluginWithoutName - ) - // mock method calls that are invoked by the args to NativePluginLoaderReflectionBridge - every { project.extraProperties } returns mockk() - every { project.extensions.findByType(FlutterExtension::class.java) } returns FlutterExtension() - every { project.file(any()) } returns mockk() + setupMockPluginLoader(project, listOf(pluginWithoutName)) val pluginHandler = PluginHandler(project) assertThrows { @@ -160,78 +141,35 @@ class PluginHandlerTest { @TempDir tempDir: Path ) { val project = mockk() - - // configuration for configureLegacyPluginEachProjects - val projectDir = tempDir.resolve("my-plugin") - projectDir.toFile().mkdirs() - every { project.projectDir } returns projectDir.toFile() - val settingsGradle = File(projectDir.parent.toFile(), "settings.gradle") - settingsGradle.createNewFile() + val pluginProject = mockk() + val pluginDependencyProject = mockk() + val mockBuildType = + mockk { + every { name } returns "debug" + every { isDebuggable } returns true + } val mockLogger = mockk() every { project.logger } returns mockLogger - val pluginProject = mockk() - val pluginDependencyProject = mockk() - val mockBuildType = mockk() - every { pluginProject.hasProperty("local-engine-repo") } returns false - every { pluginProject.hasProperty("android") } returns true - val mockPluginContainer = mockk() - every { pluginProject.plugins } returns mockPluginContainer - every { mockPluginContainer.hasPlugin("com.android.application") } returns false - every { mockBuildType.name } returns "debug" - every { mockBuildType.isDebuggable } returns true - every { project.rootProject.findProject(":${cameraDependency["name"]}") } returns pluginProject - every { project.rootProject.findProject(":${flutterPluginAndroidLifecycleDependency["name"]}") } returns pluginDependencyProject - every { pluginProject.extensions.create(any(), any>()) } returns mockk() - val captureActionSlot = slot>() - val capturePluginActionSlot = mutableListOf>() - every { project.afterEvaluate(any>()) } returns Unit - every { pluginProject.afterEvaluate(any>()) } returns Unit + setupMockProjectDir(project, tempDir) + setupMockPluginProject( + project, + pluginProject, + dependencyProject = pluginDependencyProject + ) - val mockProjectBuildTypes = - mockk>() - val mockPluginProjectBuildTypes = - mockk>() - every { project.extensions.findByType(BaseExtension::class.java)!!.buildTypes } returns mockProjectBuildTypes - every { pluginProject.extensions.findByType(BaseExtension::class.java)!!.buildTypes } returns mockPluginProjectBuildTypes - every { mockPluginProjectBuildTypes.addAll(any()) } returns true - every { pluginProject.configurations.named(any()) } returns mockk() - every { pluginProject.dependencies.add(any(), any()) } returns mockk() + setUpMockAndroidExtension(project, compileSdk = 35, buildTypes = listOf(mockBuildType)) + setUpMockLibraryAndroidExtension(pluginProject, compileSdk = 35) - every { - project.extensions - .findByType(BaseExtension::class.java)!! - .buildTypes - .iterator() - } returns - mutableListOf( - mockBuildType - ).iterator() andThen - mutableListOf( // can't return the same iterator as it is stateful - mockBuildType - ).iterator() andThen - mutableListOf( // and again - mockBuildType - ).iterator() - every { project.dependencies.add(any(), any()) } returns mockk() - every { project.extensions.findByType(BaseExtension::class.java)!!.compileSdkVersion } returns "android-35" - every { pluginProject.extensions.findByType(BaseExtension::class.java)!!.compileSdkVersion } returns "android-35" + val captureActionSlot = slot>() + val capturePluginActionSlot = mutableListOf>() - val pluginHandler = PluginHandler(project) - mockkObject(NativePluginLoaderReflectionBridge) - // mock return of NativePluginLoaderReflectionBridge.getPlugins val pluginWithDependencies: MutableMap = cameraDependency.toMutableMap() pluginWithDependencies["dependencies"] = listOf(flutterPluginAndroidLifecycleDependency["name"]) - every { NativePluginLoaderReflectionBridge.getPlugins(any(), any()) } returns - listOf( - pluginWithDependencies - ) - // mock method calls that are invoked by the args to NativePluginLoaderReflectionBridge - every { project.extraProperties } returns mockk() - every { project.extensions.findByType(FlutterExtension::class.java) } returns FlutterExtension() - every { project.file(any()) } returns mockk() + setupMockPluginLoader(project, listOf(pluginWithDependencies)) + val pluginHandler = PluginHandler(project) pluginHandler.configurePlugins( engineVersionValue = EXAMPLE_ENGINE_VERSION ) @@ -241,6 +179,7 @@ class PluginHandlerTest { captureActionSlot.captured.execute(project) capturePluginActionSlot[0].execute(pluginProject) capturePluginActionSlot[1].execute(pluginProject) + verify { pluginProject.extensions.create("flutter", FlutterExtension::class.java) } verify { pluginProject.dependencies.add( @@ -250,9 +189,6 @@ class PluginHandlerTest { } verify { project.dependencies.add("debugApi", pluginProject) } verify { mockLogger wasNot called } - // For library projects, individual build types should be created, not addAll - verify(exactly = 0) { mockPluginProjectBuildTypes.addAll(any()) } - verify { pluginProject.dependencies.add("implementation", pluginDependencyProject) } } @@ -261,70 +197,24 @@ class PluginHandlerTest { @TempDir tempDir: Path ) { val project = mockk() + val pluginProject = mockk() + val mockBuildType = + mockk { + every { name } returns "debug" + every { isDebuggable } returns true + } + every { project.logger } returns mockk(relaxed = true) - // configuration for configureLegacyPluginEachProjects - val projectDir = tempDir.resolve("my-plugin") - projectDir.toFile().mkdirs() - every { project.projectDir } returns projectDir.toFile() - val settingsGradle = File(projectDir.parent.toFile(), "settings.gradle") - settingsGradle.createNewFile() - val mockLogger = mockk() - every { project.logger } returns mockLogger + setupMockProjectDir(project, tempDir) + setupMockPluginProject(project, pluginProject) + setUpMockAndroidExtension(project, compileSdk = 35, buildTypes = listOf(mockBuildType)) + setUpMockLibraryAndroidExtension(pluginProject, compileSdk = 35) - val pluginProject = mockk() - val mockBuildType = mockk() - every { pluginProject.hasProperty("local-engine-repo") } returns false - every { pluginProject.hasProperty("android") } returns true - every { mockBuildType.name } returns "debug" - every { mockBuildType.isDebuggable } returns true val pluginWithNullDependencies: MutableMap = cameraDependency.toMutableMap() pluginWithNullDependencies["dependencies"] = null - every { project.rootProject.findProject(":${pluginWithNullDependencies["name"]}") } returns pluginProject - every { pluginProject.extensions.create(any(), any>()) } returns mockk() - every { project.afterEvaluate(any>()) } returns Unit - every { pluginProject.afterEvaluate(any>()) } returns Unit - - val mockProjectBuildTypes = - mockk>() - val mockPluginProjectBuildTypes = - mockk>() - every { project.extensions.findByType(BaseExtension::class.java)!!.buildTypes } returns mockProjectBuildTypes - every { pluginProject.extensions.findByType(BaseExtension::class.java)!!.buildTypes } returns mockPluginProjectBuildTypes - every { mockPluginProjectBuildTypes.addAll(any()) } returns true - every { pluginProject.configurations.named(any()) } returns mockk() - every { pluginProject.dependencies.add(any(), any()) } returns mockk() - - every { - project.extensions - .findByType(BaseExtension::class.java)!! - .buildTypes - .iterator() - } returns - mutableListOf( - mockBuildType - ).iterator() andThen - mutableListOf( // can't return the same iterator as it is stateful - mockBuildType - ).iterator() andThen - mutableListOf( // and again - mockBuildType - ).iterator() - every { project.dependencies.add(any(), any()) } returns mockk() - every { project.extensions.findByType(BaseExtension::class.java)!!.compileSdkVersion } returns "android-35" - every { pluginProject.extensions.findByType(BaseExtension::class.java)!!.compileSdkVersion } returns "android-35" + setupMockPluginLoader(project, listOf(pluginWithNullDependencies)) val pluginHandler = PluginHandler(project) - mockkObject(NativePluginLoaderReflectionBridge) - // mock return of NativePluginLoaderReflectionBridge.getPlugins - every { NativePluginLoaderReflectionBridge.getPlugins(any(), any()) } returns - listOf( - pluginWithNullDependencies - ) - // mock method calls that are invoked by the args to NativePluginLoaderReflectionBridge - every { project.extraProperties } returns mockk() - every { project.extensions.findByType(FlutterExtension::class.java) } returns FlutterExtension() - every { project.file(any()) } returns mockk() - assertThrows { pluginHandler.configurePlugins( engineVersionValue = EXAMPLE_ENGINE_VERSION @@ -333,134 +223,223 @@ class PluginHandlerTest { } @Test - fun `configurePlugins uses addAll for app plugins`( + fun `configurePlugins copies missing app build types onto library plugin projects using initWith`( @TempDir tempDir: Path ) { val project = mockk() val pluginProject = mockk() + val appBuildType = + mockk { + every { name } returns "staging" + every { isDebuggable } returns true + } + every { project.logger } returns mockk(relaxed = true) + + setupMockProjectDir(project, tempDir) + setupMockPluginProject(project, pluginProject, isAppPlugin = false) + + val pluginProjectBuildTypes = mockk>(relaxed = true) + val mockLibraryExtension = setUpMockLibraryAndroidExtension(pluginProject) + every { mockLibraryExtension.buildTypes } returns pluginProjectBuildTypes + + setUpMockAndroidExtension(project, compileSdk = 35, buildTypes = listOf(appBuildType)) + setupMockPluginLoader(project, listOf(cameraDependency)) - // Setup minimal mocks - setupBasicMocks(project, pluginProject, mockk(), tempDir) - setupPluginMocks(project) - - // Mock isBuiltAsApp to return true (app plugin) - mockkObject(FlutterPluginUtils) - every { FlutterPluginUtils.isBuiltAsApp(pluginProject) } returns true - - val mockProjectBuildTypes = mockk>() - val mockPluginProjectBuildTypes = mockk>() - - every { project.extensions.findByType(BaseExtension::class.java)!!.buildTypes } returns mockProjectBuildTypes - every { pluginProject.extensions.findByType(BaseExtension::class.java)!!.buildTypes } returns mockPluginProjectBuildTypes - every { mockPluginProjectBuildTypes.addAll(any()) } returns true - every { mockProjectBuildTypes.iterator() } returns mutableListOf().iterator() - - // Mock FlutterPluginUtils calls that our logic depends on - mockkObject(FlutterPluginUtils) - every { FlutterPluginUtils.getLegacyAndroidExtension(project) } returns project.extensions.findByType(BaseExtension::class.java)!! - every { FlutterPluginUtils.getLegacyAndroidExtension(pluginProject) } returns - pluginProject.extensions.findByType(BaseExtension::class.java)!! - - // For app plugins, the old addAll behavior should be used - // This is tested implicitly by verifying the absence of individual create calls - // Verify no individual create calls were made (app behavior uses addAll) - verify(exactly = 0) { - mockPluginProjectBuildTypes.create( - any(), - any>() + every { pluginProjectBuildTypes.findByName("staging") } returns null + val createdBuildType = mockk(relaxed = true) + val createActionSlot = slot>() + every { + pluginProjectBuildTypes.create("staging", capture(createActionSlot)) + } returns createdBuildType + + val pluginHandler = PluginHandler(project) + pluginHandler.configurePlugins( + engineVersionValue = EXAMPLE_ENGINE_VERSION + ) + + val capturePluginActionSlot = mutableListOf>() + verify { pluginProject.afterEvaluate(capture(capturePluginActionSlot)) } + capturePluginActionSlot.forEach { it.execute(pluginProject) } + + createActionSlot.captured.execute(createdBuildType) + verify { createdBuildType.initWith(appBuildType) } + // The custom debuggable build type maps to the debug engine artifacts. + verify { + pluginProject.dependencies.add( + "stagingApi", + "io.flutter:flutter_embedding_debug:$EXAMPLE_ENGINE_VERSION" ) } } @Test - fun `configurePlugins creates individual build types for library plugins`( + fun `configurePlugins skips creating build types that already exist on the plugin project`( @TempDir tempDir: Path ) { val project = mockk() val pluginProject = mockk() + val appBuildType = + mockk { + every { name } returns "staging" + every { isDebuggable } returns true + } + val existingPluginBuildType = + mockk { + every { name } returns "staging" + } + every { project.logger } returns mockk(relaxed = true) + + setupMockProjectDir(project, tempDir) + setupMockPluginProject(project, pluginProject, isAppPlugin = false) - // Setup minimal mocks - setupBasicMocks(project, pluginProject, mockk(), tempDir) - setupPluginMocks(project) + val pluginProjectBuildTypes = mockk>(relaxed = true) + val mockLibraryExtension = setUpMockLibraryAndroidExtension(pluginProject) + every { mockLibraryExtension.buildTypes } returns pluginProjectBuildTypes - // Mock isBuiltAsApp to return false (library plugin) - mockkObject(FlutterPluginUtils) - every { FlutterPluginUtils.isBuiltAsApp(pluginProject) } returns false + setUpMockAndroidExtension(project, compileSdk = 35, buildTypes = listOf(appBuildType)) + setupMockPluginLoader(project, listOf(cameraDependency)) - val mockProjectBuildTypes = mockk>() - val mockPluginProjectBuildTypes = mockk>() - val mockCreatedBuildType = mockk(relaxed = true) + every { pluginProjectBuildTypes.findByName("staging") } returns existingPluginBuildType - every { project.extensions.findByType(BaseExtension::class.java)!!.buildTypes } returns mockProjectBuildTypes - every { pluginProject.extensions.findByType(BaseExtension::class.java)!!.buildTypes } returns mockPluginProjectBuildTypes - every { mockPluginProjectBuildTypes.findByName("debug") } returns null + val pluginHandler = PluginHandler(project) + pluginHandler.configurePlugins( + engineVersionValue = EXAMPLE_ENGINE_VERSION + ) + + val capturePluginActionSlot = mutableListOf>() + verify { pluginProject.afterEvaluate(capture(capturePluginActionSlot)) } + capturePluginActionSlot.forEach { it.execute(pluginProject) } + + verify(exactly = 0) { pluginProjectBuildTypes.create(any(), any>()) } + } + + @Test + fun `configurePlugins copies app-specific properties when the plugin project is an app`( + @TempDir tempDir: Path + ) { + val project = mockk() + val pluginProject = mockk() + val appBuildType = + mockk { + every { name } returns "staging" + every { isDebuggable } returns true + } + every { project.logger } returns mockk(relaxed = true) + + setupMockProjectDir(project, tempDir) + setupMockPluginProject(project, pluginProject, isAppPlugin = true) + + val pluginProjectBuildTypes = mockk>(relaxed = true) + val mockAppExtension = setUpMockAndroidExtension(pluginProject) + every { mockAppExtension.buildTypes } returns pluginProjectBuildTypes + + setUpMockAndroidExtension(project, compileSdk = 35, buildTypes = listOf(appBuildType)) + setupMockPluginLoader(project, listOf(cameraDependency)) + + every { pluginProjectBuildTypes.findByName("staging") } returns null + val createdBuildType = mockk(relaxed = true) + val createActionSlot = slot>() every { - mockPluginProjectBuildTypes.create( - "debug", - any>() + pluginProjectBuildTypes.create("staging", capture(createActionSlot)) + } returns createdBuildType + + val pluginHandler = PluginHandler(project) + pluginHandler.configurePlugins( + engineVersionValue = EXAMPLE_ENGINE_VERSION + ) + + val capturePluginActionSlot = mutableListOf>() + verify { pluginProject.afterEvaluate(capture(capturePluginActionSlot)) } + capturePluginActionSlot.forEach { it.execute(pluginProject) } + + createActionSlot.captured.execute(createdBuildType) + verify { createdBuildType.initWith(appBuildType) } + verify { createdBuildType.isDebuggable = true } + } + + @Test + fun `configurePlugins logs warning when plugin compileSdk is higher than project compileSdk`( + @TempDir tempDir: Path + ) { + val project = mockk() + val pluginProject = mockk() + val mockBuildType = + mockk { + every { name } returns "debug" + every { isDebuggable } returns true + } + val mockLogger = mockk(relaxed = true) + every { project.logger } returns mockLogger + + setupMockProjectDir(project, tempDir) + setupMockPluginProject(project, pluginProject) + setUpMockAndroidExtension(project, compileSdk = 34, buildTypes = listOf(mockBuildType)) + setUpMockLibraryAndroidExtension(pluginProject, compileSdk = 35) + setupMockPluginLoader(project, listOf(cameraDependency)) + + val capturePluginActionSlot = mutableListOf>() + + val pluginHandler = PluginHandler(project) + pluginHandler.configurePlugins( + engineVersionValue = EXAMPLE_ENGINE_VERSION + ) + + verify { pluginProject.afterEvaluate(capture(capturePluginActionSlot)) } + capturePluginActionSlot.forEach { it.execute(pluginProject) } + + verify { + mockLogger.quiet( + match { message -> + message.contains("The plugin camera_android_camerax requires Android SDK version 35 or higher") + } ) - } returns mockCreatedBuildType - - // Mock the iterator for forEach - val testBuildType = mockk() - every { testBuildType.name } returns "debug" - every { testBuildType.isDebuggable } returns true - every { testBuildType.isMinifyEnabled } returns false - every { mockProjectBuildTypes.iterator() } returns mutableListOf(testBuildType).iterator() - - // Mock FlutterPluginUtils calls that our logic depends on - mockkObject(FlutterPluginUtils) - every { FlutterPluginUtils.getLegacyAndroidExtension(project) } returns project.extensions.findByType(BaseExtension::class.java)!! - every { FlutterPluginUtils.getLegacyAndroidExtension(pluginProject) } returns - pluginProject.extensions.findByType(BaseExtension::class.java)!! - - // For library plugins, individual build type creation should happen - // This is tested by verifying that create is called for the build type - // Verify that individual create was called (library behavior) - verify(exactly = 0) { mockPluginProjectBuildTypes.addAll(any()) } + } } - private fun setupBasicMocks( + private fun setupMockProjectDir( project: Project, - pluginProject: Project, - mockBuildType: com.android.build.gradle.internal.dsl.BuildType, - tempDir: Path + tempDir: Path, + projectName: String = "my-plugin" ) { - // Configuration for project directory - val projectDir = tempDir.resolve("my-plugin") + val projectDir = tempDir.resolve(projectName) projectDir.toFile().mkdirs() every { project.projectDir } returns projectDir.toFile() val settingsGradle = File(projectDir.parent.toFile(), "settings.gradle") settingsGradle.createNewFile() - val mockLogger = mockk() - every { project.logger } returns mockLogger + } - // Plugin project setup + private fun setupMockPluginLoader( + project: Project, + pluginList: List> + ) { + mockkObject(NativePluginLoaderReflectionBridge) + every { NativePluginLoaderReflectionBridge.getPlugins(any(), any()) } returns pluginList + every { project.extraProperties } returns mockk() + every { project.extensions.findByType(FlutterExtension::class.java) } returns FlutterExtension() + every { project.file(any()) } returns mockk() + } + + private fun setupMockPluginProject( + project: Project, + pluginProject: Project, + pluginName: String = cameraDependency["name"] as String, + isAppPlugin: Boolean = false, + dependencyProject: Project? = null + ) { every { pluginProject.hasProperty("local-engine-repo") } returns false every { pluginProject.hasProperty("android") } returns true val mockPluginContainer = mockk() every { pluginProject.plugins } returns mockPluginContainer - every { mockPluginContainer.hasPlugin("com.android.application") } returns false - every { mockBuildType.name } returns "debug" - every { mockBuildType.isDebuggable } returns true - every { project.rootProject.findProject(":${cameraDependency["name"]}") } returns pluginProject + every { mockPluginContainer.hasPlugin("com.android.application") } returns isAppPlugin + every { project.rootProject.findProject(":$pluginName") } returns pluginProject + if (dependencyProject != null) { + every { project.rootProject.findProject(":${flutterPluginAndroidLifecycleDependency["name"]}") } returns dependencyProject + } every { pluginProject.extensions.create(any(), any>()) } returns mockk() every { project.afterEvaluate(any>()) } returns Unit every { pluginProject.afterEvaluate(any>()) } returns Unit - - // Dependencies and configurations every { pluginProject.configurations.named(any()) } returns mockk() every { pluginProject.dependencies.add(any(), any()) } returns mockk() every { project.dependencies.add(any(), any()) } returns mockk() - every { project.extensions.findByType(BaseExtension::class.java)!!.compileSdkVersion } returns "android-35" - every { pluginProject.extensions.findByType(BaseExtension::class.java)!!.compileSdkVersion } returns "android-35" - } - - private fun setupPluginMocks(project: Project) { - mockkObject(NativePluginLoaderReflectionBridge) - every { NativePluginLoaderReflectionBridge.getPlugins(any(), any()) } returns listOf(cameraDependency) - every { project.extraProperties } returns mockk() - every { project.extensions.findByType(FlutterExtension::class.java) } returns FlutterExtension() - every { project.file(any()) } returns mockk() } } diff --git a/packages/flutter_tools/gradle/src/test/kotlin/tasks/CopyFlutterApksTaskTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/tasks/CopyFlutterApksTaskTest.kt new file mode 100644 index 00000000..151a1f49 --- /dev/null +++ b/packages/flutter_tools/gradle/src/test/kotlin/tasks/CopyFlutterApksTaskTest.kt @@ -0,0 +1,200 @@ +// Copyright 2014 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. + +package com.flutter.gradle.tasks + +import com.android.build.api.variant.BuiltArtifact +import com.android.build.api.variant.BuiltArtifacts +import com.android.build.api.variant.BuiltArtifactsLoader +import com.flutter.gradle.testing.mockAbiFilters +import io.mockk.every +import io.mockk.mockk +import org.gradle.api.GradleException +import org.gradle.api.Project +import org.gradle.api.file.Directory +import org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.file.Path +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse + +class CopyFlutterApksTaskTest { + @Test + fun `apkFileName matches the names the Flutter tool looks for`() { + assertEquals("app-release.apk", CopyFlutterApksTask.apkFileName(CopyFlutterApksTask.NO_ABI, null, "release")) + assertEquals("app-debug.apk", CopyFlutterApksTask.apkFileName(CopyFlutterApksTask.NO_ABI, "", "debug")) + assertEquals( + "app-arm64-v8a-release.apk", + CopyFlutterApksTask.apkFileName("arm64-v8a", null, "release") + ) + // The flavor is lower-cased, matching the Flutter tool's lowerCasedFlavor. + assertEquals( + "app-x86_64-freestaging-profile.apk", + CopyFlutterApksTask.apkFileName("x86_64", "freeStaging", "profile") + ) + } + + @Test + fun `copies split APKs under per-ABI names and declares exactly those files as outputs`( + @TempDir tempDir: Path + ) { + val project = projectIn(tempDir) + val apkDir = tempDir.resolve("apk").toFile() + val armApk = writeApk(apkDir, "app-free-armeabi-v7a-release.apk") + val arm64Apk = writeApk(apkDir, "app-free-arm64-v8a-release.apk") + val destinationDir = tempDir.resolve("flutter-apk").toFile() + + val task = + registerTask( + project, + apkDir = apkDir, + destinationDir = destinationDir, + outputAbis = listOf("armeabi-v7a", "arm64-v8a"), + flavorName = "Free", + buildMode = "release", + loader = loaderFor(builtApk(armApk, "armeabi-v7a"), builtApk(arm64Apk, "arm64-v8a")) + ) + + task.copyApks() + + val armCopy = destinationDir.resolve("app-armeabi-v7a-free-release.apk") + val arm64Copy = destinationDir.resolve("app-arm64-v8a-free-release.apk") + assertEquals(setOf(armCopy, arm64Copy), destinationDir.listFiles()!!.toSet()) + assertEquals("app-free-armeabi-v7a-release.apk", armCopy.readText()) + assertEquals("app-free-arm64-v8a-release.apk", arm64Copy.readText()) + // Reading task.outputs also checks that Gradle resolves the provider. + assertEquals(setOf(armCopy, arm64Copy), task.outputs.files.files) + } + + @Test + fun `copies the single APK of a build without ABI splits under the non-ABI name`( + @TempDir tempDir: Path + ) { + val project = projectIn(tempDir) + val apkDir = tempDir.resolve("apk").toFile() + val apk = writeApk(apkDir, "custom-output-name.apk") + val destinationDir = tempDir.resolve("flutter-apk").toFile() + + val task = + registerTask( + project, + apkDir = apkDir, + destinationDir = destinationDir, + outputAbis = listOf(CopyFlutterApksTask.NO_ABI), + flavorName = null, + buildMode = "debug", + loader = loaderFor(builtApk(apk, abi = null)) + ) + + task.copyApks() + + assertEquals(setOf(destinationDir.resolve("app-debug.apk")), destinationDir.listFiles()!!.toSet()) + assertEquals(setOf(destinationDir.resolve("app-debug.apk")), task.outputs.files.files) + } + + @Test + fun `fails instead of writing an undeclared output when AGP built an unexpected ABI`( + @TempDir tempDir: Path + ) { + val project = projectIn(tempDir) + val apkDir = tempDir.resolve("apk").toFile() + val apk = writeApk(apkDir, "app-x86_64-release.apk") + val destinationDir = tempDir.resolve("flutter-apk").toFile() + + val task = + registerTask( + project, + apkDir = apkDir, + destinationDir = destinationDir, + outputAbis = listOf("arm64-v8a"), + flavorName = null, + buildMode = "release", + loader = loaderFor(builtApk(apk, "x86_64")) + ) + + val exception = assertThrows { task.copyApks() } + assertContains(exception.message!!, "for ABI 'x86_64'") + assertContains(exception.message!!, "transforms SingleArtifact.APK") + assertFalse(exception.message!!.contains("github.com/flutter/flutter/issues")) + assertFalse(destinationDir.resolve("app-x86_64-release.apk").exists()) + } + + @Test + fun `fails with an actionable message when the APK metadata cannot be read`( + @TempDir tempDir: Path + ) { + val project = projectIn(tempDir) + val apkDir = tempDir.resolve("apk").toFile().apply { mkdirs() } + val unreadableMetadataLoader = mockk() + every { unreadableMetadataLoader.load(any()) } returns null + val task = + registerTask( + project, + apkDir = apkDir, + destinationDir = tempDir.resolve("flutter-apk").toFile(), + outputAbis = listOf(CopyFlutterApksTask.NO_ABI), + flavorName = null, + buildMode = "release", + loader = unreadableMetadataLoader + ) + + val exception = assertThrows { task.copyApks() } + assertContains(exception.message!!, "could not read the APK metadata") + assertContains(exception.message!!, "transforms SingleArtifact.APK") + assertFalse(exception.message!!.contains("github.com/flutter/flutter/issues")) + } + + private fun projectIn(tempDir: Path): Project = ProjectBuilder.builder().withProjectDir(tempDir.resolve("project").toFile()).build() + + private fun writeApk( + apkDir: File, + name: String + ): File { + apkDir.mkdirs() + // The content is the source file name, so tests can check which file landed where. + return apkDir.resolve(name).apply { writeText(name) } + } + + /** A built APK as AGP's metadata describes it: its path and its ABI filter, if any. */ + private fun builtApk( + apk: File, + abi: String? + ): BuiltArtifact { + val artifact = mockk() + every { artifact.outputFile } returns apk.absolutePath + every { artifact.filters } returns mockAbiFilters(abi) + return artifact + } + + /** A loader whose metadata lists [builtArtifacts]. */ + private fun loaderFor(vararg builtArtifacts: BuiltArtifact): BuiltArtifactsLoader { + val artifacts = mockk() + every { artifacts.elements } returns builtArtifacts.toList() + val loader = mockk() + every { loader.load(any()) } returns artifacts + return loader + } + + private fun registerTask( + project: Project, + apkDir: File, + destinationDir: File, + outputAbis: List, + flavorName: String?, + buildMode: String, + loader: BuiltArtifactsLoader + ): CopyFlutterApksTask = + project.tasks.register("copyFlutterApksTest", CopyFlutterApksTask::class.java).get().also { task -> + task.apkDirectory.set(apkDir) + task.builtArtifactsLoader.set(loader) + task.outputAbis.set(outputAbis) + task.flavorName.set(flavorName) + task.buildMode.set(buildMode) + task.destinationDir.set(destinationDir) + } +} diff --git a/packages/flutter_tools/gradle/src/test/kotlin/tasks/CopyFlutterAssetsTaskTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/tasks/CopyFlutterAssetsTaskTest.kt new file mode 100644 index 00000000..53020d7e --- /dev/null +++ b/packages/flutter_tools/gradle/src/test/kotlin/tasks/CopyFlutterAssetsTaskTest.kt @@ -0,0 +1,93 @@ +// Copyright 2014 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. + +package com.flutter.gradle.tasks + +import org.gradle.testfixtures.ProjectBuilder +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.nio.file.FileSystems +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.attribute.PosixFilePermission +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class CopyFlutterAssetsTaskTest { + @Test + fun `stages flutter_assets under the destination directory with user read and write permissions`( + @TempDir tempDir: Path + ) { + val project = ProjectBuilder.builder().withProjectDir(tempDir.resolve("project").toFile()).build() + val intermediateDir = tempDir.resolve("intermediate").toFile() + val assetFile = intermediateDir.resolve("flutter_assets/sub/asset.txt") + assetFile.parentFile.mkdirs() + assetFile.writeText("asset-bytes") + assetFile.setWritable(false) + // Files outside flutter_assets (e.g. app.so snapshots) must not be staged as assets. + val nonAssetFile = intermediateDir.resolve("arm64_v8a/app.so") + nonAssetFile.parentFile.mkdirs() + nonAssetFile.writeText("not-an-asset") + val destinationDir = tempDir.resolve("staged").toFile() + + val task = + project.tasks + .register("testCopyFlutterAssets", CopyFlutterAssetsTask::class.java) + .get() + .also { task -> + task.intermediateDir.set(intermediateDir) + task.destinationDir.set(destinationDir) + } + + task.copy() + + val stagedAsset = destinationDir.resolve("flutter_assets/sub/asset.txt") + assertTrue(stagedAsset.isFile, "expected $stagedAsset to be staged") + // The contract is that the staged copy is readable and writable even though the source + // was not writable, because a packaging step downstream has to be able to replace it. + assertTrue(stagedAsset.canRead(), "staged asset should be user-readable") + assertTrue(stagedAsset.canWrite(), "staged asset should be user-writable") + // Files.getPosixFilePermissions throws UnsupportedOperationException on file systems + // without a POSIX view, which is the usual case on Windows, so assert the owner bits only + // where they exist. + if (FileSystems.getDefault().supportedFileAttributeViews().contains("posix")) { + val perms = Files.getPosixFilePermissions(stagedAsset.toPath()) + assertTrue(perms.contains(PosixFilePermission.OWNER_READ), "staged asset should have POSIX OWNER_READ") + assertTrue(perms.contains(PosixFilePermission.OWNER_WRITE), "staged asset should have POSIX OWNER_WRITE") + } + assertFalse( + destinationDir.resolve("arm64_v8a/app.so").exists(), + "only flutter_assets content should be staged" + ) + } + + @Test + fun `removes staged assets that the flutter build does not produce`( + @TempDir tempDir: Path + ) { + val project = ProjectBuilder.builder().withProjectDir(tempDir.resolve("project").toFile()).build() + val intermediateDir = tempDir.resolve("intermediate").toFile() + val assetFile = intermediateDir.resolve("flutter_assets/asset.txt") + assetFile.parentFile.mkdirs() + assetFile.writeText("asset-bytes") + val destinationDir = tempDir.resolve("staged").toFile() + val staleFile = destinationDir.resolve("flutter_assets/stale.txt") + staleFile.parentFile.mkdirs() + staleFile.writeText("stale") + + val task = + project.tasks + .register("testCopyFlutterAssets", CopyFlutterAssetsTask::class.java) + .get() + .also { task -> + task.intermediateDir.set(intermediateDir) + task.destinationDir.set(destinationDir) + } + + task.copy() + + assertFalse(staleFile.exists(), "stale staged assets should be removed") + assertTrue(destinationDir.resolve("flutter_assets/asset.txt").isFile, "expected the asset to be staged") + } +} diff --git a/packages/flutter_tools/gradle/src/test/kotlin/tasks/GenerateEngineFlagsManifestTaskTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/tasks/GenerateEngineFlagsManifestTaskTest.kt new file mode 100644 index 00000000..e0ee8f0a --- /dev/null +++ b/packages/flutter_tools/gradle/src/test/kotlin/tasks/GenerateEngineFlagsManifestTaskTest.kt @@ -0,0 +1,182 @@ +// Copyright 2014 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. + +package com.flutter.gradle.tasks + +import org.gradle.testfixtures.ProjectBuilder +import java.io.File +import java.util.Base64 +import kotlin.test.Test +import kotlin.test.assertEquals + +class GenerateEngineFlagsManifestTaskTest { + private fun setupTask(): Pair { + val project = ProjectBuilder.builder().build() + val task = + project.tasks + .register( + "generateEngineFlagsManifestTest", + GenerateEngineFlagsManifestTask::class.java + ).get() + val outputFile = File.createTempFile("AndroidManifest", ".xml") + outputFile.deleteOnExit() + return Pair(task, outputFile) + } + + private fun encodeJsonMap(json: String): String = Base64.getEncoder().encodeToString(json.toByteArray(Charsets.UTF_8)) + + @Test + fun generateHandlesNoArgsCorrectly() { + val (task, outputFile) = setupTask() + val jsonStr = "[]" + task.engineShellArgsJson.set(encodeJsonMap(jsonStr)) + task.manifestOutputFile.set(outputFile) + + task.generateManifest() + + val expectedContent = + """ + + + + + + + """.trimIndent() + + assertEquals(expectedContent, outputFile.readText()) + } + + @Test + fun generateHandlesOneArgCorrectly() { + val (task, outputFile) = setupTask() + val jsonStr = """["--enable-impeller"]""" + task.engineShellArgsJson.set(encodeJsonMap(jsonStr)) + task.manifestOutputFile.set(outputFile) + + task.generateManifest() + + val expectedContent = + """ + + + + + + + """.trimIndent() + + assertEquals(expectedContent, outputFile.readText()) + } + + @Test + fun generateHandlesMultipleArgsCorrectly() { + val (task, outputFile) = setupTask() + val jsonStr = """["--enable-impeller=true","--trace-systrace","--old-gen-heap-size=100"]""" + task.engineShellArgsJson.set(encodeJsonMap(jsonStr)) + task.manifestOutputFile.set(outputFile) + + task.generateManifest() + + val expectedContent = + """ + + + + + + + """.trimIndent() + + assertEquals(expectedContent, outputFile.readText()) + } + + @Test + fun generateHandlesArgsWithBackSlashes() { + val (task, outputFile) = setupTask() + val jsonStr = """["--trace-to-file=\"path/to/a file\""]""" + task.engineShellArgsJson.set(encodeJsonMap(jsonStr)) + task.manifestOutputFile.set(outputFile) + + task.generateManifest() + + val expectedContent = + """ + + + + + + + """.trimIndent() + + assertEquals(expectedContent, outputFile.readText()) + } + + @Test + fun generateHandlesArgsWithAmpersandCorrectly() { + val (task, outputFile) = setupTask() + val jsonStr = """["--some-arg=a&b"]""" + task.engineShellArgsJson.set(encodeJsonMap(jsonStr)) + task.manifestOutputFile.set(outputFile) + + task.generateManifest() + + val expectedContent = + """ + + + + + + + """.trimIndent() + + assertEquals(expectedContent, outputFile.readText()) + } + + @Test + fun generateHandlesArgsWithAngleBracketsCorrectly() { + val (task, outputFile) = setupTask() + val jsonStr = """["--some-arg="]""" + task.engineShellArgsJson.set(encodeJsonMap(jsonStr)) + task.manifestOutputFile.set(outputFile) + + task.generateManifest() + + val expectedContent = + """ + + + + + + + """.trimIndent() + + assertEquals(expectedContent, outputFile.readText()) + } + + @Test + fun generateHandlesArgsWithSingleQuotesCorrectly() { + val (task, outputFile) = setupTask() + val jsonStr = """["--some-arg='a'"]""" + task.engineShellArgsJson.set(encodeJsonMap(jsonStr)) + task.manifestOutputFile.set(outputFile) + + task.generateManifest() + + val expectedContent = + """ + + + + + + + """.trimIndent() + + assertEquals(expectedContent, outputFile.readText()) + } +} diff --git a/packages/flutter_tools/gradle/src/test/kotlin/testing/AndroidExtensionMocks.kt b/packages/flutter_tools/gradle/src/test/kotlin/testing/AndroidExtensionMocks.kt new file mode 100644 index 00000000..2672a14f --- /dev/null +++ b/packages/flutter_tools/gradle/src/test/kotlin/testing/AndroidExtensionMocks.kt @@ -0,0 +1,211 @@ +// Copyright 2014 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. + +package com.flutter.gradle.testing + +import com.android.build.api.dsl.ApplicationBuildType +import com.android.build.api.dsl.ApplicationExtension +import com.android.build.api.dsl.ApplicationProductFlavor +import com.android.build.api.dsl.Cmake +import com.android.build.api.dsl.LibraryBuildType +import com.android.build.api.dsl.LibraryExtension +import com.android.build.api.dsl.NdkBuild +import io.mockk.every +import io.mockk.mockk +import org.gradle.api.Action +import org.gradle.api.NamedDomainObjectContainer +import org.gradle.api.Project +import org.gradle.api.file.Directory +import org.gradle.api.file.DirectoryProperty +import java.io.File + +/** + * Mocks the Android extension for unit tests reading `compileSdk`, `ndkVersion`, or `buildTypes` via the public DSL. + * + * Default parameter values (e.g. `compileSdk = 35`, `ndkVersion = "29.0.13846066"`) serve as representative + * test fixtures reflecting AGP 8.x and 9.x project configurations to avoid NPEs when tested logic reads project extensions. + * In actual generated Flutter projects, these values are populated from template properties. + * + * Note: This helper also stubs `project.gradle.startParameter.taskNames` (empty) and + * `project.gradle.startParameter.isOffline` (`false`) because plugin lifecycle and reflection bridge + * utilities read Gradle execution parameters during project configuration. + */ +fun setUpMockAndroidExtension( + project: Project, + compileSdk: Int? = 35, + compileSdkPreview: String? = null, + ndkVersion: String? = "29.0.13846066", + buildTypes: List = emptyList() +): ApplicationExtension { + val mockAndroidExtension = mockk() + + every { mockAndroidExtension.compileSdk } returns compileSdk + every { mockAndroidExtension.compileSdkPreview } returns compileSdkPreview + if (ndkVersion != null) { + every { mockAndroidExtension.ndkVersion } returns ndkVersion + } + + val container = mockk>() + // A fresh iterator per call: the container is iterated by multiple loops. + every { container.iterator() } answers { buildTypes.toMutableList().iterator() } + every { container.findByName(any()) } answers { + val name = firstArg() + buildTypes.firstOrNull { it.name == name } + } + every { container.create(any(), any>()) } answers { + val name = firstArg() + val action = secondArg>() + val created = + mockk(relaxed = true) { + every { this@mockk.name } returns name + } + action.execute(created) + created + } + every { mockAndroidExtension.buildTypes } returns container + + every { project.extensions.findByType(ApplicationExtension::class.java) } returns mockAndroidExtension + every { project.extensions.findByName("android") } returns mockAndroidExtension + + every { project.gradle.startParameter.taskNames } returns emptyList() + every { project.gradle.startParameter.isOffline } returns false + + return mockAndroidExtension +} + +/** + * Mocks the Library Android extension for unit tests reading library project configurations via the public DSL. + */ +fun setUpMockLibraryAndroidExtension( + project: Project, + compileSdk: Int? = 35, + compileSdkPreview: String? = null, + ndkVersion: String? = "29.0.13846066", + buildTypes: List = emptyList() +): LibraryExtension { + val mockLibraryExtension = mockk() + + every { mockLibraryExtension.compileSdk } returns compileSdk + every { mockLibraryExtension.compileSdkPreview } returns compileSdkPreview + if (ndkVersion != null) { + every { mockLibraryExtension.ndkVersion } returns ndkVersion + } + + val container = mockk>() + // A fresh iterator per call: the container is iterated by multiple loops. + every { container.iterator() } answers { buildTypes.toMutableList().iterator() } + every { container.findByName(any()) } answers { + val name = firstArg() + buildTypes.firstOrNull { it.name == name } + } + every { container.create(any(), any>()) } answers { + val name = firstArg() + val action = secondArg>() + val created = + mockk(relaxed = true) { + every { this@mockk.name } returns name + } + action.execute(created) + created + } + every { mockLibraryExtension.buildTypes } returns container + + every { project.extensions.findByType(LibraryExtension::class.java) } returns mockLibraryExtension + every { project.extensions.findByName("android") } returns mockLibraryExtension + + every { project.gradle.startParameter.taskNames } returns emptyList() + every { project.gradle.startParameter.isOffline } returns false + + return mockLibraryExtension +} + +/** + * Test fixture holding the mocked public DSL objects for external native build and NDK fallback tests. + */ +data class MockExternalNativeBuildConfig( + val androidExtension: ApplicationExtension, + val cmake: Cmake, + val ndkBuild: NdkBuild, + val cmakeArguments: MutableList, + val buildType: ApplicationBuildType +) + +/** + * Mocks `externalNativeBuild` (`cmake` and `ndkBuild`), `buildTypes`, and `layout.buildDirectory` + * on a Gradle [Project] for testing NDK download and synthetic CMake fallback configuration. + */ +fun setUpMockExternalNativeBuild( + project: Project, + ndkVersion: String? = "29.0.13846066", + cmakePath: File? = null, + ndkBuildPath: File? = null, + buildDirectory: File = File("/randomapp/build/app/") +): MockExternalNativeBuildConfig { + val mockCmake = + mockk(relaxed = true) { + every { path } returns cmakePath + } + val mockNdkBuild = + mockk(relaxed = true) { + every { path } returns ndkBuildPath + } + val cmakeArguments = mutableListOf() + val mockBuildType = + mockk(relaxed = true) { + every { name } returns "Debug" + every { externalNativeBuild.cmake.arguments } returns cmakeArguments + } + val mockAndroidExtension = + mockk(relaxed = true) { + every { externalNativeBuild.cmake } returns mockCmake + every { externalNativeBuild.ndkBuild } returns mockNdkBuild + if (ndkVersion != null) { + every { this@mockk.ndkVersion } returns ndkVersion + } + val container = + mockk>(relaxed = true) { + every { iterator() } answers { mutableListOf(mockBuildType).iterator() } + } + every { buildTypes } returns container + } + every { project.extensions.findByType(ApplicationExtension::class.java) } returns mockAndroidExtension + every { project.extensions.findByName("android") } returns mockAndroidExtension + + val mockDirectory = + mockk { + every { asFile } returns buildDirectory + } + val mockDirectoryProperty = + mockk(relaxed = true) { + every { dir(any()) } returns this + every { get() } returns mockDirectory + } + every { project.layout.buildDirectory } returns mockDirectoryProperty + + every { project.gradle.startParameter.taskNames } returns emptyList() + every { project.gradle.startParameter.isOffline } returns false + + return MockExternalNativeBuildConfig( + androidExtension = mockAndroidExtension, + cmake = mockCmake, + ndkBuild = mockNdkBuild, + cmakeArguments = cmakeArguments, + buildType = mockBuildType + ) +} + +/** A `productFlavors` container with one flavor per entry: name to versionCode (or null). */ +fun mockProductFlavors(versionCodes: Map): NamedDomainObjectContainer { + val productFlavors = + versionCodes.map { (flavorName, flavorVersionCode) -> + mockk { + every { name } returns flavorName + every { versionCode } returns flavorVersionCode + } + } + val container = mockk>() + // A fresh iterator per call, so the container can be iterated more than once. + every { container.iterator() } answers { productFlavors.toMutableList().iterator() } + return container +} diff --git a/packages/flutter_tools/gradle/src/test/kotlin/testing/FilterConfigurationMocks.kt b/packages/flutter_tools/gradle/src/test/kotlin/testing/FilterConfigurationMocks.kt new file mode 100644 index 00000000..40e69417 --- /dev/null +++ b/packages/flutter_tools/gradle/src/test/kotlin/testing/FilterConfigurationMocks.kt @@ -0,0 +1,20 @@ +// Copyright 2014 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. + +package com.flutter.gradle.testing + +import com.android.build.api.variant.FilterConfiguration +import io.mockk.every +import io.mockk.mockk + +/** One ABI filter for [abi], or no filters if [abi] is null. */ +fun mockAbiFilters(abi: String?): List { + if (abi == null) { + return emptyList() + } + val abiFilter = mockk() + every { abiFilter.filterType } returns FilterConfiguration.FilterType.ABI + every { abiFilter.identifier } returns abi + return listOf(abiFilter) +} diff --git a/packages/flutter_tools/gradle/src/test/kotlin/testing/VersionFetcherTestHelper.kt b/packages/flutter_tools/gradle/src/test/kotlin/testing/VersionFetcherTestHelper.kt deleted file mode 100644 index 60dd6ef6..00000000 --- a/packages/flutter_tools/gradle/src/test/kotlin/testing/VersionFetcherTestHelper.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.flutter.gradle.testing - -import io.mockk.every -import io.mockk.mockk -import org.gradle.api.Project -import org.jetbrains.kotlin.gradle.plugin.KotlinBaseApiPlugin - -/** - * Prevent AGP's kotlin version checker from throwing `no answer found` - * - * Intended to be called by tests that call `VersionFetcher.getKGPVersion(project)` - * and who do not care about the internal implementation of - * `com.android.build.gradle.internal.utils.getKotlinAndroidPluginVersion` - */ -internal fun setAgpKotlinVersionToNull(mockProject: Project) { - // The internals of `getKotlinAndroidPluginVersion` depend on `getKotlinPluginVersionFromPlugin` - // which relies on reflection to get the value. Instead make sure fetching the plugin has valid - // response then rely on the default behavior in `getKotlinPluginVersionFromPlugin` to - // return null. - every { mockProject.plugins.findPlugin(any>()) } returns mockk() - every { mockProject.plugins.findPlugin("kotlin-android") } returns mockk() -} diff --git a/packages/flutter_tools/gradle/src/test/kotlin/validation/BytecodeValidatorTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/validation/BytecodeValidatorTest.kt new file mode 100644 index 00000000..55c71705 --- /dev/null +++ b/packages/flutter_tools/gradle/src/test/kotlin/validation/BytecodeValidatorTest.kt @@ -0,0 +1,104 @@ +// Copyright 2014 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. + +package com.flutter.gradle.validation + +import org.junit.jupiter.api.io.TempDir +import java.io.File +import java.nio.file.Path +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Validates that no compiled main class in the Flutter Gradle Plugin references + * `com.android.build.api.dsl.CommonExtension`. + * + * `CommonExtension` is binary-incompatible between AGP 8 and AGP 9 (the interface methods and + * generic descriptors changed when `android.newDsl=true` became default in AGP 9.0). FGP routes + * all DSL access through [AgpCommonExtensionWrapper] to maintain binary compatibility across both + * AGP 8 and 9. + * + * This test and [AgpCommonExtensionWrapper] can be safely deleted when Flutter drops support for + * AGP 8.x (i.e. when the minimum supported AGP version is >= 9.0.0). + */ +class BytecodeValidatorTest { + private val needle = "com/android/build/api/dsl/CommonExtension".toByteArray(Charsets.ISO_8859_1) + + /** Returns true if [bytes] contains the forbidden binary needle pattern. */ + private fun containsForbiddenReference(bytes: ByteArray): Boolean = + (0..bytes.size - needle.size).any { start -> + needle.indices.all { i -> bytes[start + i] == needle[i] } + } + + /** Walks [dir] recursively and returns relative paths of any `.class` files containing forbidden references. */ + private fun findOffendingClasses(dir: File): List = + dir + .walkTopDown() + .filter { it.isFile && it.extension == "class" } + .filter { containsForbiddenReference(it.readBytes()) } + .map { it.relativeTo(dir).path } + .toList() + + @Test + fun `no compiled main classes reference CommonExtension`() { + val testClassesUri = + BytecodeValidatorTest::class.java + .protectionDomain + .codeSource + .location + .toURI() + val testClassesDir = File(testClassesUri) + val mainClassesDir = testClassesDir.parentFile.resolve("main") + assertTrue(mainClassesDir.exists(), "Main classes directory does not exist at: ${mainClassesDir.absolutePath}") + + val offendingClasses = findOffendingClasses(mainClassesDir) + assertTrue( + offendingClasses.isEmpty(), + "CommonExtension must not be referenced from Flutter Gradle Plugin bytecode " + + "(binary incompatible between AGP 8 and 9). Route DSL access through " + + "AgpCommonExtensionWrapper instead. Offending classes: $offendingClasses" + ) + } + + @Test + fun `detects forbidden CommonExtension byte sequence in class bytes`() { + val cleanBytes = "com/android/build/api/dsl/ApplicationExtension".toByteArray(Charsets.ISO_8859_1) + val dirtyBytes = "SomeHeader/com/android/build/api/dsl/CommonExtension/SomeFooter".toByteArray(Charsets.ISO_8859_1) + + assertFalse(containsForbiddenReference(cleanBytes)) + assertTrue(containsForbiddenReference(dirtyBytes)) + } + + @Test + fun `findOffendingClasses identifies violating class files in directory`( + @TempDir tempDir: Path + ) { + val root = tempDir.toFile() + val cleanClass = File(root, "CleanPlugin.class") + cleanClass.writeBytes("com/android/build/api/dsl/LibraryExtension".toByteArray(Charsets.ISO_8859_1)) + + val dirtyClass = File(root, "ViolatingPlugin.class") + dirtyClass.writeBytes("Lcom/android/build/api/dsl/CommonExtension;".toByteArray(Charsets.ISO_8859_1)) + + val nonClassFile = File(root, "Readme.txt") + nonClassFile.writeBytes("com/android/build/api/dsl/CommonExtension".toByteArray(Charsets.ISO_8859_1)) + + val offenders = findOffendingClasses(root) + assertEquals(listOf("ViolatingPlugin.class"), offenders) + } + + @Test + fun `findOffendingClasses returns empty list for empty directory or non-class files`( + @TempDir tempDir: Path + ) { + val root = tempDir.toFile() + assertEquals(emptyList(), findOffendingClasses(root)) + + val nonClassFile = File(root, "Notes.md") + nonClassFile.writeText("No compiled classes here") + assertEquals(emptyList(), findOffendingClasses(root)) + } +} diff --git a/packages/flutter_tools/gradle/src/test/kotlin/validation/InternalAgpApiImportTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/validation/InternalAgpApiImportTest.kt new file mode 100644 index 00000000..89fb067b --- /dev/null +++ b/packages/flutter_tools/gradle/src/test/kotlin/validation/InternalAgpApiImportTest.kt @@ -0,0 +1,53 @@ +// Copyright 2014 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. + +package com.flutter.gradle.validation + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Guards the AGP public-API migration (https://github.com/flutter/flutter/issues/180137): + * production sources must use the public AGP DSL surface rather than AGP internals + * (`com.android.build.gradle.internal.*`), ensuring the Flutter Gradle Plugin is ready + * to compile against the public `gradle-api` artifact where internal packages are absent. + * Test sources may still reference internal types until the compile-time dependency swap. + */ +class InternalAgpApiImportTest { + @Test + fun `main sources do not import AGP internals`() { + val workingDir = File(".") + val mainSources = + listOf( + File("src/main"), + File("packages/flutter_tools/gradle/src/main") + ).firstOrNull { it.isDirectory } + assertTrue( + mainSources != null, + "Expected to find src/main relative to test working directory (${workingDir.absolutePath})." + ) + val internalApiPattern = Regex("""\bcom\.android\.build\.gradle\.internal\b|\bcom\.android\.builder\.internal\b""") + val offendingLines = + mainSources + .walkTopDown() + .filter { it.isFile && it.extension in setOf("kt", "java", "groovy", "gradle") } + .flatMap { file -> + file.readLines().mapIndexedNotNull { index, line -> + if (internalApiPattern.containsMatchIn(line)) { + "${file.path}:${index + 1}: ${line.trim()}" + } else { + null + } + } + }.toList() + assertTrue( + offendingLines.isEmpty(), + "AGP internal APIs must not be used in production sources. Use the public " + + "com.android.build.api surface (see " + + "docs/platforms/android/Migrating-Flutter-Gradle-Plugin-to-AGP-public-API.md).\n" + + offendingLines.joinToString("\n") + ) + } +} diff --git a/packages/flutter_tools/gradle/src/test/kotlin/validation/README.md b/packages/flutter_tools/gradle/src/test/kotlin/validation/README.md new file mode 100644 index 00000000..15504699 --- /dev/null +++ b/packages/flutter_tools/gradle/src/test/kotlin/validation/README.md @@ -0,0 +1,3 @@ +# Flutter Gradle Plugin Validation Tests + +This directory contains architectural and hygiene tests that enforce constraints on Flutter Gradle Plugin sources and compiled bytecode. diff --git a/packages/flutter_tools/lib/executable.dart b/packages/flutter_tools/lib/executable.dart index 6ec0a983..1a37eb86 100644 --- a/packages/flutter_tools/lib/executable.dart +++ b/packages/flutter_tools/lib/executable.dart @@ -2,8 +2,15 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:args/args.dart'; +import 'package:flutter_tools_extension_linux_prototype/flutter_tools_extension_linux_prototype.dart'; +import 'package:meta/meta.dart'; + import 'runner.dart' as runner; +import 'src/android/android_workflow.dart' as android_workflow; +import 'src/android/gradle.dart'; import 'src/base/context.dart'; +import 'src/base/file_system.dart'; import 'src/base/io.dart'; import 'src/base/logger.dart'; import 'src/base/platform.dart'; @@ -44,7 +51,12 @@ import 'src/commands/test.dart'; import 'src/commands/update_packages.dart'; import 'src/commands/upgrade.dart'; import 'src/commands/widget_preview.dart'; +import 'src/context/tool_context.dart'; +import 'src/context/tool_dependencies.dart'; import 'src/devtools_launcher.dart'; +import 'src/experimental/extension_discovery.dart'; +import 'src/experimental/extension_manager.dart'; +import 'src/experimental/templates.dart'; import 'src/features.dart'; import 'src/globals.dart' as globals; // Files in `isolated` are intentionally excluded from google3 tooling. @@ -58,6 +70,7 @@ import 'src/pre_run_validator.dart'; import 'src/project_validator.dart'; import 'src/resident_runner.dart'; import 'src/runner/flutter_command.dart'; +import 'src/runner/flutter_command_runner.dart'; import 'src/web/web_runner.dart'; /// Main entry point for commands. @@ -77,18 +90,17 @@ Future main(List args) async { args[slashQuestionHelpIndex] = '-h'; } - final bool doctor = - (args.isNotEmpty && args.first == 'doctor') || - (args.length == 2 && verbose && args.last == 'doctor'); + final String? commandName = findCommandName(args); + final doctor = commandName == 'doctor'; final bool help = args.contains('-h') || args.contains('--help') || - (args.isNotEmpty && args.first == 'help') || + commandName == 'help' || (args.length == 1 && verbose); final bool muteCommandLogging = (help || doctor) && !veryVerbose; final bool verboseHelp = help && verbose; - final bool daemon = args.contains('daemon'); - final bool widgetPreviews = args.contains(WidgetPreviewCommand.kWidgetPreview); + final daemon = commandName == 'daemon'; + final widgetPreviews = commandName == WidgetPreviewCommand.kWidgetPreview; final bool runMachine = args.contains('--machine'); // Cache.flutterRoot must be set early because other features use it (e.g. @@ -102,7 +114,27 @@ Future main(List args) async { await runner.run( args, - () => generateCommands(verboseHelp: verboseHelp, verbose: verbose), + (ToolDependencies toolDependencies) { + final ToolDependencies( + :ExtensionManager? extensionManager, + :FeatureFlags featureFlags, + toolContext: ToolContext(:FileSystem fs, :Logger logger), + ) = toolDependencies; + final ExtensionManager manager = extensionManager!; + final templateManager = ExtensionTemplateManager( + extensionManager: manager, + featureFlags: featureFlags, + fileSystem: fs, + logger: logger, + ); + return generateCommands( + toolDependencies: toolDependencies, + verboseHelp: verboseHelp, + verbose: verbose, + extensionTemplateManager: templateManager, + ); + }, + extensionEntryPoints: [linuxExtensionEntryPoint], verbose: verbose, muteCommandLogging: muteCommandLogging, verboseHelp: verboseHelp, @@ -156,146 +188,211 @@ Future main(List args) async { ); } -List generateCommands({required bool verboseHelp, required bool verbose}) => - [ - AnalyzeCommand( - verboseHelp: verboseHelp, - fileSystem: globals.fs, - platform: globals.platform, - processManager: globals.processManager, - logger: globals.logger, - terminal: globals.terminal, - artifacts: globals.artifacts!, - // new ProjectValidators should be added here for the --suggestions to run - allProjectValidators: [ - GeneralInfoProjectValidator(), - VariableDumpMachineProjectValidator( - logger: globals.logger, - fileSystem: globals.fs, - platform: globals.platform, - git: globals.git, - ), - ], - suppressAnalytics: !globals.analytics.okToSend, - ), - AssembleCommand(verboseHelp: verboseHelp, buildSystem: globals.buildSystem), - AttachCommand( - verboseHelp: verboseHelp, - stdio: globals.stdio, - logger: globals.logger, - terminal: globals.terminal, - signals: globals.signals, - platform: globals.platform, - processInfo: globals.processInfo, - fileSystem: globals.fs, - ), - BuildCommand( - fileSystem: globals.fs, - buildSystem: globals.buildSystem, - osUtils: globals.os, - verboseHelp: verboseHelp, - androidSdk: globals.androidSdk, - logger: globals.logger, - config: globals.config, - platform: globals.platform, - fileSystemUtils: globals.fsUtils, - terminal: globals.terminal, - plistParser: globals.plistParser, - processUtils: globals.processUtils, - processManager: globals.processManager, - templateRenderer: globals.templateRenderer, - xcode: globals.xcode, - artifacts: globals.artifacts!, - cache: globals.cache, - flutterVersion: globals.flutterVersion, - ), - ChannelCommand(verboseHelp: verboseHelp), - CleanCommand(verbose: verbose), - ConfigCommand(verboseHelp: verboseHelp), - CustomDevicesCommand( - customDevicesConfig: globals.customDevicesConfig, - operatingSystemUtils: globals.os, - terminal: globals.terminal, - platform: globals.platform, - featureFlags: featureFlags, - processManager: globals.processManager, - fileSystem: globals.fs, - logger: globals.logger, - ), - CreateCommand(verboseHelp: verboseHelp), - DaemonCommand(hidden: !verboseHelp), - DebugAdapterCommand(verboseHelp: verboseHelp), - DevicesCommand(verboseHelp: verboseHelp), - DoctorCommand(verbose: verbose), - DowngradeCommand(verboseHelp: verboseHelp, logger: globals.logger), - DriveCommand( - verboseHelp: verboseHelp, - fileSystem: globals.fs, - logger: globals.logger, - platform: globals.platform, - terminal: globals.terminal, - outputPreferences: globals.outputPreferences, - signals: globals.signals, - ), - EmulatorsCommand(), - GenerateCommand(), - GenerateLocalizationsCommand( - fileSystem: globals.fs, - logger: globals.logger, - artifacts: globals.artifacts!, - processManager: globals.processManager, - ), - InstallCommand(verboseHelp: verboseHelp), - LogsCommand(sigint: ProcessSignal.sigint, sigterm: ProcessSignal.sigterm), - PackagesCommand(), - PrecacheCommand( - verboseHelp: verboseHelp, - cache: globals.cache, - logger: globals.logger, - platform: globals.platform, - featureFlags: featureFlags, - ), - RunCommand(verboseHelp: verboseHelp), - ScreenshotCommand(fs: globals.fs), - ShellCompletionCommand(), - TestCommand( - verboseHelp: verboseHelp, - verbose: verbose, - nativeAssetsBuilder: globals.nativeAssetsBuilder, - ), - WidgetPreviewCommand( - verboseHelp: verboseHelp, - logger: globals.logger, - fs: globals.fs, - projectFactory: globals.projectFactory, - cache: globals.cache, - platform: globals.platform, - shutdownHooks: globals.shutdownHooks, - os: globals.os, - processManager: globals.processManager, - artifacts: globals.artifacts!, - terminal: globals.terminal, +/// The name of the command in `args`, or null if there isn't one. +/// +/// Global options can come before the command, so it can't be found by +/// position. A throwaway parser walks past them instead: trailing options are +/// disabled, so parsing stops at the command and leaves it at the head of +/// [ArgResults.rest]. `help` is the exception, since the command runner +/// registers it on the parser itself and so reports it as a parsed command. +@visibleForTesting +String? findCommandName(List args, {ToolContext? toolContext}) { + final ArgResults results; + try { + results = FlutterCommandRunner(toolContext: toolContext ?? _FallbackToolContext()).argParser + .parse(args); + } on ArgParserException { + // The real parser will complain about these later. + return null; + } + return results.command?.name ?? results.rest.firstOrNull; +} + +class _FallbackToolContext implements ToolContext { + _FallbackToolContext() : _outputPreferences = null; + + final OutputPreferences? _outputPreferences; + + @override + OutputPreferences get outputPreferences { + if (_outputPreferences != null) { + return _outputPreferences; + } + try { + return globals.outputPreferences; + } on Object catch (_) { + return OutputPreferences.test(); + } + } + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +List generateCommands({ + required ToolDependencies toolDependencies, + required bool verbose, + required bool verboseHelp, + ExtensionTemplateManager? extensionTemplateManager, +}) => [ + AnalyzeCommand( + allProjectValidators: [ + GeneralInfoProjectValidator(), + VariableDumpMachineProjectValidator( + logger: toolDependencies.toolContext.logger, + fileSystem: toolDependencies.toolContext.fs, + platform: toolDependencies.toolContext.platform, + git: toolDependencies.toolContext.git, ), - UpgradeCommand(verboseHelp: verboseHelp), - SymbolizeCommand(stdio: globals.stdio, fileSystem: globals.fs), - // Development-only commands. These are always hidden, - IdeConfigCommand(), - UpdatePackagesCommand(verboseHelp: verboseHelp), - ]; + ], + suppressAnalytics: !toolDependencies.analytics.okToSend, + toolContext: toolDependencies.toolContext, + verboseHelp: verboseHelp, + ), + AssembleCommand( + buildSystem: toolDependencies.buildSystem, + featureFlags: toolDependencies.featureFlags, + toolContext: toolDependencies.toolContext, + verboseHelp: verboseHelp, + ), + AttachCommand( + buildSystem: toolDependencies.buildSystem, + buildTargets: toolDependencies.buildTargets, + toolContext: toolDependencies.toolContext, + xcode: toolDependencies.appleContext.xcode, + verboseHelp: verboseHelp, + ), + BuildCommand( + androidBuilder: AndroidGradleBuilder.fromContexts( + analytics: toolDependencies.analytics, + androidContext: toolDependencies.androidContext, + toolContext: toolDependencies.toolContext, + ), + androidContext: toolDependencies.androidContext, + appleContext: toolDependencies.appleContext, + buildSystem: toolDependencies.buildSystem, + extensionBuildManager: toolDependencies.extensionBuildManager, + featureFlags: toolDependencies.featureFlags, + templateRenderer: const MustacheTemplateRenderer(), + toolContext: toolDependencies.toolContext, + verboseHelp: verboseHelp, + ), + ChannelCommand(verboseHelp: verboseHelp, toolContext: toolDependencies.toolContext), + CleanCommand( + verbose: verbose, + toolContext: toolDependencies.toolContext, + xcode: toolDependencies.appleContext.xcode, + xcodeProjectInterpreter: toolDependencies.appleContext.xcodeProjectInterpreter, + ), + ConfigCommand( + verboseHelp: verboseHelp, + androidContext: toolDependencies.androidContext, + toolContext: toolDependencies.toolContext, + featureFlags: featureFlags, + extensionManager: toolDependencies.extensionManager, + ), + CustomDevicesCommand(featureFlags: featureFlags, toolContext: toolDependencies.toolContext), + CreateCommand( + androidContext: toolDependencies.androidContext, + appleContext: toolDependencies.appleContext, + templateRenderer: const MustacheTemplateRenderer(), + toolContext: toolDependencies.toolContext, + extensionTemplateManager: extensionTemplateManager, + verboseHelp: verboseHelp, + ), + DaemonCommand( + androidContext: toolDependencies.androidContext, + androidWorkflow: android_workflow.androidWorkflow, + buildSystem: toolDependencies.buildSystem, + buildTargets: toolDependencies.buildTargets, + deviceManager: toolDependencies.deviceManager, + hidden: !verboseHelp, + toolContext: toolDependencies.toolContext, + xcode: toolDependencies.appleContext.xcode, + ), + DebugAdapterCommand(toolContext: toolDependencies.toolContext, verboseHelp: verboseHelp), + DevicesCommand( + deviceManager: toolDependencies.deviceManager, + doctor: globals.doctor!, + toolContext: toolDependencies.toolContext, + verboseHelp: verboseHelp, + ), + DoctorCommand( + doctor: toolDependencies.doctor, + extensionManager: toolDependencies.extensionManager, + toolContext: toolDependencies.toolContext, + verbose: verbose, + ), + DowngradeCommand(toolContext: toolDependencies.toolContext, verboseHelp: verboseHelp), + DriveCommand( + buildSystem: toolDependencies.buildSystem, + buildTargets: toolDependencies.buildTargets, + toolContext: toolDependencies.toolContext, + verboseHelp: verboseHelp, + ), + EmulatorsCommand( + doctor: toolDependencies.doctor, + emulatorManager: toolDependencies.emulatorManager, + toolContext: toolDependencies.toolContext, + verboseHelp: verboseHelp, + ), + GenerateCommand(toolContext: toolDependencies.toolContext), + GenerateLocalizationsCommand(toolContext: toolDependencies.toolContext), + InstallCommand(toolContext: toolDependencies.toolContext, verboseHelp: verboseHelp), + LogsCommand(toolContext: toolDependencies.toolContext), + PackagesCommand( + buildSystem: toolDependencies.buildSystem, + toolContext: toolDependencies.toolContext, + verboseHelp: verboseHelp, + ), + PrecacheCommand( + verboseHelp: verboseHelp, + cache: toolDependencies.toolContext.cache, + logger: toolDependencies.toolContext.logger, + platform: toolDependencies.toolContext.platform, + featureFlags: featureFlags, + ), + RunCommand( + appleContext: toolDependencies.appleContext, + buildSystem: toolDependencies.buildSystem, + buildTargets: toolDependencies.buildTargets, + toolContext: toolDependencies.toolContext, + androidContext: toolDependencies.androidContext, + androidWorkflow: android_workflow.androidWorkflow, + deviceManager: toolDependencies.deviceManager, + verboseHelp: verboseHelp, + ), + ScreenshotCommand(toolContext: toolDependencies.toolContext), + ShellCompletionCommand(toolContext: toolDependencies.toolContext), + TestCommand( + toolContext: toolDependencies.toolContext, + verboseHelp: verboseHelp, + verbose: verbose, + nativeAssetsBuilder: toolDependencies.toolContext.nativeAssetsBuilder, + ), + WidgetPreviewCommand( + buildSystem: toolDependencies.buildSystem, + buildTargets: toolDependencies.buildTargets, + toolContext: toolDependencies.toolContext, + verboseHelp: verboseHelp, + ), + UpgradeCommand(toolContext: toolDependencies.toolContext, verboseHelp: verboseHelp), + SymbolizeCommand(toolContext: toolDependencies.toolContext), + // Development-only commands. These are always hidden, + IdeConfigCommand(toolContext: toolDependencies.toolContext), + UpdatePackagesCommand(toolContext: toolDependencies.toolContext, verboseHelp: verboseHelp), +]; /// An abstraction for instantiation of the correct logger type. /// /// Our logger class hierarchy and runtime requirements are overly complicated. class LoggerFactory { LoggerFactory({ - required Terminal terminal, - required Stdio stdio, - required OutputPreferences outputPreferences, - StopwatchFactory stopwatchFactory = const StopwatchFactory(), - }) : _terminal = terminal, - _stdio = stdio, - _stopwatchFactory = stopwatchFactory, - _outputPreferences = outputPreferences; + required this._terminal, + required this._stdio, + required this._outputPreferences, + this._stopwatchFactory = const StopwatchFactory(), + }); final Terminal _terminal; final Stdio _stdio; @@ -334,7 +431,12 @@ class LoggerFactory { logger = PrefixedErrorLogger(logger); } if (widgetPreviews) { - return WidgetPreviewMachineAwareLogger(logger, machine: machine, verbose: verbose); + return WidgetPreviewMachineAwareLogger( + logger, + machine: machine, + verbose: verbose, + stdio: _stdio, + ); } if (daemon) { return NotifyingLogger(verbose: verbose, parent: logger); diff --git a/packages/flutter_tools/lib/runner.dart b/packages/flutter_tools/lib/runner.dart index 460d69f6..e51b5685 100644 --- a/packages/flutter_tools/lib/runner.dart +++ b/packages/flutter_tools/lib/runner.dart @@ -10,6 +10,7 @@ import 'package:intl/intl_standalone.dart' as intl_standalone; import 'package:process/process.dart'; import 'package:unified_analytics/unified_analytics.dart'; +import 'src/android/android_workflow.dart'; import 'src/base/async_guard.dart'; import 'src/base/common.dart'; import 'src/base/context.dart'; @@ -19,25 +20,31 @@ import 'src/base/file_system.dart'; import 'src/base/io.dart'; import 'src/base/logger.dart'; import 'src/base/process.dart'; +import 'src/context/tool_dependencies.dart'; import 'src/context_runner.dart'; +import 'src/device.dart'; import 'src/doctor.dart'; +import 'src/emulator.dart'; +import 'src/experimental/extension_discovery.dart'; import 'src/features.dart'; import 'src/globals.dart' as globals; import 'src/reporting/crash_reporting.dart'; import 'src/runner/flutter_command.dart'; import 'src/runner/flutter_command_runner.dart'; +import 'src/windows/windows_workflow.dart'; /// Runs the Flutter tool with support for the specified list of [commands]. Future run( List args, - List Function() commands, { + List Function(ToolDependencies toolDependencies) commands, { + required ShutdownHooks shutdownHooks, + List extensionEntryPoints = const [], + String? flutterVersion, bool muteCommandLogging = false, + Map? overrides, + bool? reportCrashes, bool verbose = false, bool verboseHelp = false, - bool? reportCrashes, - String? flutterVersion, - Map? overrides, - required ShutdownHooks shutdownHooks, }) async { if (muteCommandLogging) { // Remove the verbose option; for help and doctor, users don't need to see @@ -55,100 +62,158 @@ Future run( globals.terminal.applyFeatureFlags(featureFlags); reportCrashes ??= !await globals.isRunningOnBot; - final runner = FlutterCommandRunner(verboseHelp: verboseHelp); - commands().forEach(runner.addCommand); - - // Initialize the system locale. - final String systemLocale = await intl_standalone.findSystemLocale(); - intl.Intl.defaultLocale = intl.Intl.verifiedLocale( - systemLocale, - intl.NumberFormat.localeExists, - onFailure: (String _) => 'en_US', + final ToolDependencies toolDeps = await ToolDependencies.bootstrap( + analytics: globals.analytics, + androidSdkBuilder: () => globals.androidSdk, + androidStudioBuilder: () => globals.androidStudio, + androidWorkflow: androidWorkflow, + artifacts: globals.artifacts, + botDetector: globals.botDetector, + buildSystem: globals.buildSystem, + buildTargets: globals.buildTargets, + cache: globals.cache, + cocoaPods: globals.cocoaPods, + cocoapodsValidator: globals.cocoapodsValidator, + config: globals.config, + crashReporter: globals.crashReporter, + customDevicesConfig: globals.customDevicesConfig, + deviceManager: globals.deviceManager, + doctor: globals.doctor, + emulatorManager: emulatorManager, + extensionEntryPoints: extensionEntryPoints, + featureFlags: featureFlags, + flutterVersion: globals.flutterVersion, + fs: globals.fs, + git: globals.git, + gradleUtilsBuilder: () => globals.gradleUtils!, + iosSimulatorUtils: globals.iosSimulatorUtils, + iosWorkflow: globals.iosWorkflow, + javaBuilder: () => globals.java, + localEngineLocator: globals.localEngineLocator, + logger: globals.logger, + nativeAssetsBuilder: globals.nativeAssetsBuilder, + outputPreferences: globals.outputPreferences, + persistentToolState: globals.persistentToolState, + platform: globals.platform, + plistParser: globals.plistParser, + preRunValidator: globals.preRunValidator, + processInfo: globals.processInfo, + processManager: globals.processManager, + projectFactory: globals.projectFactory, + shutdownHooks: shutdownHooks, + stdio: globals.stdio, + systemClock: globals.systemClock, + terminal: globals.terminal, + userMessages: globals.userMessages, + windowsWorkflow: windowsWorkflow, + xcdevice: globals.xcdevice, + xcode: globals.xcode, + xcodeProjectInterpreter: globals.xcodeProjectInterpreter, ); - - String getVersion() => - flutterVersion ?? globals.flutterVersion.getVersionString(redactUnknownBranches: true); - Object? firstError; - StackTrace? firstStackTrace; - return runZonedGuarded>( - () async { - try { - if (args.contains('--disable-analytics') && args.contains('--enable-analytics')) { - throwToolExit( - 'Both enable and disable analytics commands were detected ' - 'when only one can be supplied per invocation.', - exitCode: 1, - ); - } - - if (args.contains('--disable-analytics')) { - if (globals.analytics.telemetryEnabled) { - globals.analytics.send(Event.analyticsCollectionEnabled(status: false)); - // Before disablig analytics, we need to close the client to make - // sure the above collection event is sent. - await globals.analytics.close(); - } - await globals.analytics.setTelemetry(false); - globals.printStatus('Analytics reporting disabled.'); - } - - if (args.contains('--enable-analytics')) { - final bool alreadyEnabled = globals.analytics.telemetryEnabled; - await globals.analytics.setTelemetry(true); - if (!alreadyEnabled) { - globals.analytics.send(Event.analyticsCollectionEnabled(status: true)); - } - globals.printStatus('Analytics reporting enabled.'); - } - - await runner.run(args); - - // Triggering [runZoned]'s error callback does not necessarily mean that - // we stopped executing the body. See https://github.com/dart-lang/sdk/issues/42150. - if (firstError == null) { - return await exitWithHooks(0, shutdownHooks: shutdownHooks); - } - - // We already hit some error, so don't return success. The error path - // (which should be in progress) is responsible for calling _exit(). - return 1; - // This catches all exceptions to send to crash logging, etc. - // ignore: avoid_catches_without_on_clauses - } catch (error, stackTrace) { - firstError = error; - firstStackTrace = stackTrace; - return _handleToolError( - error, - stackTrace, - verbose, - args, - reportCrashes!, - getVersion, - shutdownHooks, - featureFlags: featureFlags, - usingLocalEngine: usingLocalEngine, - ); - } - }, - (Object error, StackTrace stackTrace) async { - // If sending a crash report throws an error into the zone, we don't want - // to re-try sending the crash report with *that* error. Rather, we want - // to send the original error that triggered the crash report. - firstError ??= error; - firstStackTrace ??= stackTrace; - await _handleToolError( - firstError!, - firstStackTrace, - verbose, - args, - reportCrashes!, - getVersion, - shutdownHooks, + return context.run( + overrides: {DeviceManager: () => toolDeps.deviceManager}, + body: () async { + final runner = FlutterCommandRunner( + analytics: toolDeps.analytics, featureFlags: featureFlags, - usingLocalEngine: usingLocalEngine, + toolContext: toolDeps.toolContext, + verboseHelp: verboseHelp, + ); + commands(toolDeps).forEach(runner.addCommand); + + // Initialize the system locale. + final String systemLocale = await intl_standalone.findSystemLocale(); + intl.Intl.defaultLocale = intl.Intl.verifiedLocale( + systemLocale, + intl.NumberFormat.localeExists, + onFailure: (String _) => 'en_US', ); + + String getVersion() => + flutterVersion ?? globals.flutterVersion.getVersionString(redactUnknownBranches: true); + Object? firstError; + StackTrace? firstStackTrace; + return runZonedGuarded>( + () async { + try { + if (args.contains('--disable-analytics') && args.contains('--enable-analytics')) { + throwToolExit( + 'Both enable and disable analytics commands were detected ' + 'when only one can be supplied per invocation.', + exitCode: 1, + ); + } + + if (args.contains('--disable-analytics')) { + if (globals.analytics.telemetryEnabled) { + globals.analytics.send(Event.analyticsCollectionEnabled(status: false)); + // Before disablig analytics, we need to close the client to make + // sure the above collection event is sent. + await globals.analytics.close(); + } + await globals.analytics.setTelemetry(false); + globals.printStatus('Analytics reporting disabled.'); + } + + if (args.contains('--enable-analytics')) { + final bool alreadyEnabled = globals.analytics.telemetryEnabled; + await globals.analytics.setTelemetry(true); + if (!alreadyEnabled) { + globals.analytics.send(Event.analyticsCollectionEnabled(status: true)); + } + globals.printStatus('Analytics reporting enabled.'); + } + + await runner.run(args); + + // Triggering [runZoned]'s error callback does not necessarily mean that + // we stopped executing the body. See https://github.com/dart-lang/sdk/issues/42150. + if (firstError == null) { + return await exitWithHooks(0, shutdownHooks: shutdownHooks); + } + + // We already hit some error, so don't return success. The error path + // (which should be in progress) is responsible for calling _exit(). + return 1; + // This catches all exceptions to send to crash logging, etc. + // ignore: avoid_catches_without_on_clauses + } catch (error, stackTrace) { + firstError = error; + firstStackTrace = stackTrace; + return _handleToolError( + error, + stackTrace, + verbose, + args, + reportCrashes!, + getVersion, + shutdownHooks, + featureFlags: featureFlags, + usingLocalEngine: usingLocalEngine, + ); + } + }, + (Object error, StackTrace stackTrace) async { + // If sending a crash report throws an error into the zone, we don't want + // to re-try sending the crash report with *that* error. Rather, we want + // to send the original error that triggered the crash report. + firstError ??= error; + firstStackTrace ??= stackTrace; + await _handleToolError( + firstError!, + firstStackTrace, + verbose, + args, + reportCrashes!, + getVersion, + shutdownHooks, + featureFlags: featureFlags, + usingLocalEngine: usingLocalEngine, + ); + }, + )!; }, - )!; + ); }, overrides: overrides); } diff --git a/packages/flutter_tools/lib/src/android/README.md b/packages/flutter_tools/lib/src/android/README.md index d2096f1a..9d3a150c 100644 --- a/packages/flutter_tools/lib/src/android/README.md +++ b/packages/flutter_tools/lib/src/android/README.md @@ -99,6 +99,10 @@ information known to the tool. be marked inline). - Update the test cases in [create_test.dart](../../../test/commands.shard/permeable/create_test.dart) that test for a warning for Java/AGP incompatibilities as needed (relevant tests should fail if you do not fix them preemptively). +- When Flutter adds support for a new AGP major version, bump +`firstUnsupportedAGPMajorVersion` in +[DependencyVersionChecker.kt](../../../gradle/src/main/kotlin/DependencyVersionChecker.kt) +so that builds using that major version are no longer blocked. For information about the latest version, check https://developer.android.com/studio/releases/gradle-plugin#updating-gradle. diff --git a/packages/flutter_tools/lib/src/android/android_console.dart b/packages/flutter_tools/lib/src/android/android_console.dart index 52975c58..13a2a417 100644 --- a/packages/flutter_tools/lib/src/android/android_console.dart +++ b/packages/flutter_tools/lib/src/android/android_console.dart @@ -2,8 +2,11 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + import 'package:async/async.dart'; +import '../base/common.dart'; import '../base/io.dart'; import '../convert.dart'; @@ -34,6 +37,12 @@ class AndroidConsole { assert(_socket != null); assert(_queue == null); + unawaited( + _socket!.done.handleError((Object _, StackTrace _) { + // Socket error, handled when reading or destroying. + }), + ); + _queue = StreamQueue(_socket!.asyncMap(ascii.decode)); // Discard any initial connection text. diff --git a/packages/flutter_tools/lib/src/android/android_device.dart b/packages/flutter_tools/lib/src/android/android_device.dart index 29cf0aef..13b430c6 100644 --- a/packages/flutter_tools/lib/src/android/android_device.dart +++ b/packages/flutter_tools/lib/src/android/android_device.dart @@ -26,6 +26,7 @@ import '../protocol_discovery.dart'; import '../vmservice.dart'; import 'android_builder.dart'; import 'android_console.dart'; +import 'android_engine_cli_flags.dart'; import 'android_sdk.dart'; import 'application_package.dart'; import 'gradle_utils.dart' as gradle_utils; @@ -64,16 +65,12 @@ class AndroidDevice extends Device { this.deviceCodeName, required super.logger, required ProcessManager processManager, - required Platform platform, - required AndroidSdk androidSdk, - required FileSystem fileSystem, - AndroidConsoleSocketFactory androidConsoleSocketFactory = kAndroidConsoleSocketFactory, + required this._platform, + required this._androidSdk, + required this._fileSystem, + this._androidConsoleSocketFactory = kAndroidConsoleSocketFactory, }) : _logger = logger, _processManager = processManager, - _androidSdk = androidSdk, - _platform = platform, - _fileSystem = fileSystem, - _androidConsoleSocketFactory = androidConsoleSocketFactory, _processUtils = ProcessUtils(logger: logger, processManager: processManager), super(category: Category.mobile, platformType: PlatformType.android, ephemeral: true); @@ -227,26 +224,10 @@ class AndroidDevice extends Device { @override Future supportsRuntimeMode(BuildMode buildMode) async { - switch (await targetPlatform) { - case TargetPlatform.android_arm: - case TargetPlatform.android_arm64: - case TargetPlatform.android_x64: - return buildMode != BuildMode.jitRelease; - case TargetPlatform.android: - case TargetPlatform.darwin: - case TargetPlatform.fuchsia_arm64: - case TargetPlatform.fuchsia_x64: - case TargetPlatform.ios: - case TargetPlatform.linux_arm64: - case TargetPlatform.linux_riscv64: - case TargetPlatform.linux_x64: - case TargetPlatform.tester: - case TargetPlatform.web_javascript: - case TargetPlatform.windows_x64: - case TargetPlatform.windows_arm64: - case TargetPlatform.unsupported: - throw UnsupportedError('Invalid target platform for Android'); + if ((await targetPlatform).os != .android) { + throw UnsupportedError('Invalid target platform for Android'); } + return buildMode != BuildMode.jitRelease; } @override @@ -566,22 +547,33 @@ class AndroidDevice extends Device { _androidSdk.licensesAvailable && _androidSdk.latestVersion == null) { _logger.printTrace('Building APK'); final FlutterProject project = FlutterProject.current(); + + final releaseManifestEngineShellArgs = [ + if (debuggingOptions.buildInfo.mode == BuildMode.release) ...[ + ...debuggingOptions.getAndroidLaunchArguments(), + if (platformArgs[AndroidEngineCliFlags.traceStartup] as bool? ?? false) + '--${AndroidEngineCliFlags.traceStartup}', + if (route != null) '--${AndroidEngineCliFlags.route}=$route', + ], + ]; + await androidBuilder!.buildApk( project: project, target: mainPath ?? 'lib/main.dart', androidBuildInfo: AndroidBuildInfo( debuggingOptions.buildInfo, targetArchs: [cpuArch], + releaseManifestEngineShellArgs: releaseManifestEngineShellArgs.isEmpty + ? null + : releaseManifestEngineShellArgs, ), ); // Package has been built, so we can get the updated application ID and // activity name from the .apk. - builtPackage = - await ApplicationPackageFactory.instance!.getPackageForPlatform( - devicePlatform, - buildInfo: debuggingOptions.buildInfo, - ) - as AndroidApk?; + builtPackage = await ApplicationPackageFactory.instance!.getPackageForPlatform( + devicePlatform, + buildInfo: debuggingOptions.buildInfo, + ) as AndroidApk?; } // There was a failure parsing the android project information. if (builtPackage == null) { @@ -595,7 +587,7 @@ class AndroidDevice extends Device { return LaunchResult.failed(); } - final bool traceStartup = platformArgs['trace-startup'] as bool? ?? false; + final bool traceStartup = platformArgs[AndroidEngineCliFlags.traceStartup] as bool? ?? false; ProtocolDiscovery? vmServiceDiscovery; if (debuggingOptions.debuggingEnabled) { @@ -603,7 +595,12 @@ class AndroidDevice extends Device { // Avoid using getLogReader, which returns a singleton instance, because the // VM Service discovery will dispose at the end. creating a new logger here allows // logs to be surfaced normally during `flutter drive`. - await AdbLogReader.createLogReader(this, _processManager, _logger), + await AdbLogReader.createLogReader( + this, + _processManager, + _logger, + adbLogFiltering: debuggingOptions.adbLogFiltering, + ), portForwarder: portForwarder, hostPort: debuggingOptions.hostVmServicePort, devicePort: debuggingOptions.deviceVmServicePort, @@ -612,94 +609,19 @@ class AndroidDevice extends Device { ); } - final String? traceAllowlist = debuggingOptions.traceAllowlist; - final String? traceSkiaAllowlist = debuggingOptions.traceSkiaAllowlist; - final String? traceToFile = debuggingOptions.traceToFile; final cmd = [ 'shell', 'am', 'start', '-a', 'android.intent.action.MAIN', '-c', 'android.intent.category.LAUNCHER', '-f', '0x20000000', // FLAG_ACTIVITY_SINGLE_TOP - if (debuggingOptions.enableDartProfiling) ...[ - '--ez', - 'enable-dart-profiling', - 'true', - ], - if (debuggingOptions.profileStartup) ...['--ez', 'profile-startup', 'true'], - if (traceStartup) ...['--ez', 'trace-startup', 'true'], - if (route != null) ...['--es', 'route', route], - if (debuggingOptions.enableSoftwareRendering) ...[ - '--ez', - 'enable-software-rendering', - 'true', - ], - if (debuggingOptions.skiaDeterministicRendering) ...[ - '--ez', - 'skia-deterministic-rendering', - 'true', - ], - if (debuggingOptions.traceSkia) ...['--ez', 'trace-skia', 'true'], - if (traceAllowlist != null) ...['--es', 'trace-allowlist', traceAllowlist], - if (traceSkiaAllowlist != null) ...[ - '--es', - 'trace-skia-allowlist', - traceSkiaAllowlist, + if (debuggingOptions.buildInfo.mode != BuildMode.release) ...[ + ...debuggingOptions.getAndroidLaunchArgumentsAsIntentExtras(), + if (traceStartup) ...['--ez', AndroidEngineCliFlags.traceStartup, 'true'], + if (route != null) ...['--es', AndroidEngineCliFlags.route, route], ], - if (debuggingOptions.traceSystrace) ...['--ez', 'trace-systrace', 'true'], - if (traceToFile != null) ...['--es', 'trace-to-file', traceToFile], - if (debuggingOptions.endlessTraceBuffer) ...['--ez', 'endless-trace-buffer', 'true'], - if (debuggingOptions.profileMicrotasks) ...['--ez', 'profile-microtasks', 'true'], - if (debuggingOptions.purgePersistentCache) ...[ - '--ez', - 'purge-persistent-cache', - 'true', - ], - if (debuggingOptions.enableImpeller == ImpellerStatus.enabled) ...[ - '--ez', - 'enable-impeller', - 'true', - ], - if (debuggingOptions.enableImpeller == ImpellerStatus.disabled) ...[ - '--ez', - 'enable-impeller', - 'false', - ], - if (debuggingOptions.enableFlutterGpu) ...['--ez', 'enable-flutter-gpu', 'true'], - if (debuggingOptions.enableVulkanValidation) ...[ - '--ez', - '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'], - ...['--ez', 'verify-entry-points', 'true'], - ], - if (debuggingOptions.startPaused) ...['--ez', 'start-paused', 'true'], - if (debuggingOptions.disableServiceAuthCodes) ...[ - '--ez', - 'disable-service-auth-codes', - 'true', - ], - if (debuggingOptions.disableServiceOriginCheck) ...[ - '--ez', - 'disable-service-origin-check', - 'true', - ], - if (debuggingOptions.dartFlags.isNotEmpty) ...[ - '--es', - 'dart-flags', - debuggingOptions.dartFlags, - ], - if (debuggingOptions.useTestFonts) ...['--ez', 'use-test-fonts', 'true'], - if (debuggingOptions.verboseSystemLogs) ...['--ez', 'verbose-logging', 'true'], - if (debuggingOptions.testFlag) ...['--ez', 'test-flag', 'true'], - if (userIdentifier != null) ...['--user', userIdentifier], + if (debuggingOptions.debuggingEnabled && userIdentifier != null) ...[ + '--user', + userIdentifier, ], builtPackage.launchActivity, ]; @@ -805,6 +727,7 @@ class AndroidDevice extends Device { FutureOr getLogReader({ ApplicationPackage? app, bool includePastLogs = false, + bool adbLogFiltering = true, }) async { // The Android log reader isn't app-specific. The `app` parameter isn't used. if (includePastLogs) { @@ -813,9 +736,15 @@ class AndroidDevice extends Device { _processManager, _logger, includePastLogs: true, + adbLogFiltering: adbLogFiltering, ); } else { - return _logReader ??= await AdbLogReader.createLogReader(this, _processManager, _logger); + return _logReader ??= await AdbLogReader.createLogReader( + this, + _processManager, + _logger, + adbLogFiltering: adbLogFiltering, + ); } } @@ -873,14 +802,7 @@ class AndroidDevice extends Device { @override Future isSupported() async { - final TargetPlatform platform = await targetPlatform; - return switch (platform) { - TargetPlatform.android || - TargetPlatform.android_arm || - TargetPlatform.android_arm64 || - TargetPlatform.android_x64 => true, - _ => false, - }; + return (await targetPlatform).os == .android; } @override @@ -1064,10 +986,17 @@ class AndroidMemoryInfo extends MemoryInfo { /// A log reader that logs from `adb logcat`. class AdbLogReader extends DeviceLogReader { - AdbLogReader._(this._adbProcess, this.name, this._logger); + AdbLogReader._(this._adbProcess, this.name, this._logger, {this.adbLogFiltering = true}); @visibleForTesting - factory AdbLogReader.test(Process adbProcess, String name, Logger logger) = AdbLogReader._; + factory AdbLogReader.test( + Process adbProcess, + String name, + Logger logger, { + bool adbLogFiltering = true, + }) { + return AdbLogReader._(adbProcess, name, logger, adbLogFiltering: adbLogFiltering); + } /// Create a new [AdbLogReader] from an [AndroidDevice] instance. static Future createLogReader( @@ -1075,6 +1004,7 @@ class AdbLogReader extends DeviceLogReader { ProcessManager processManager, Logger logger, { bool includePastLogs = false, + bool adbLogFiltering = true, }) async { // logcat -T is not supported on Android releases before Lollipop. const kLollipopVersionCode = 21; @@ -1102,7 +1032,7 @@ class AdbLogReader extends DeviceLogReader { ]); } final Process process = await processManager.start(device.adbCommandForDevice(args)); - return AdbLogReader._(process, device.displayName, logger); + return AdbLogReader._(process, device.displayName, logger, adbLogFiltering: adbLogFiltering); } int? _appPid; @@ -1111,6 +1041,8 @@ class AdbLogReader extends DeviceLogReader { final Logger _logger; + final bool adbLogFiltering; + @override final String name; @@ -1224,7 +1156,9 @@ class AdbLogReader extends DeviceLogReader { if (logMatch != null) { var acceptLine = false; - if (_fatalCrash) { + if (!adbLogFiltering) { + acceptLine = true; + } else if (_fatalCrash) { // While a fatal crash is going on, only accept lines from the crash // Otherwise the crash log in the console may get interrupted @@ -1287,11 +1221,9 @@ class AndroidDevicePortForwarder extends DevicePortForwarder { AndroidDevicePortForwarder({ required ProcessManager processManager, required Logger logger, - required String deviceId, - required String adbPath, - }) : _deviceId = deviceId, - _adbPath = adbPath, - _logger = logger, + required this._deviceId, + required this._adbPath, + }) : _logger = logger, _processUtils = ProcessUtils(logger: logger, processManager: processManager); final String _deviceId; diff --git a/packages/flutter_tools/lib/src/android/android_device_discovery.dart b/packages/flutter_tools/lib/src/android/android_device_discovery.dart index f292cdbb..825b4a97 100644 --- a/packages/flutter_tools/lib/src/android/android_device_discovery.dart +++ b/packages/flutter_tools/lib/src/android/android_device_discovery.dart @@ -26,26 +26,28 @@ import 'android_workflow.dart'; /// * [AndroidDevice], the type of discovered device. class AndroidDevices extends PollingDeviceDiscovery { AndroidDevices({ - required AndroidWorkflow androidWorkflow, + required this._androidWorkflow, required ProcessManager processManager, required Logger logger, + required this._fileSystem, + required this._platform, + required this._userMessages, AndroidSdk? androidSdk, - required FileSystem fileSystem, - required Platform platform, - required UserMessages userMessages, - }) : _androidWorkflow = androidWorkflow, - _androidSdk = androidSdk, + AndroidSdk? Function()? androidSdkBuilder, + }) : assert( + androidSdk == null || androidSdkBuilder == null, + 'Cannot provide both androidSdk and androidSdkBuilder to AndroidDevices.', + ), + _androidSdkBuilder = androidSdkBuilder ?? (() => androidSdk), _processUtils = ProcessUtils(logger: logger, processManager: processManager), _processManager = processManager, _logger = logger, - _fileSystem = fileSystem, - _platform = platform, - _userMessages = userMessages, super('Android devices'); final AndroidWorkflow _androidWorkflow; final ProcessUtils _processUtils; - final AndroidSdk? _androidSdk; + final AndroidSdk? Function() _androidSdkBuilder; + late final AndroidSdk? _androidSdk = _androidSdkBuilder(); final ProcessManager _processManager; final Logger _logger; final FileSystem _fileSystem; diff --git a/packages/flutter_tools/lib/src/android/android_emulator.dart b/packages/flutter_tools/lib/src/android/android_emulator.dart index b8938b5e..761384fd 100644 --- a/packages/flutter_tools/lib/src/android/android_emulator.dart +++ b/packages/flutter_tools/lib/src/android/android_emulator.dart @@ -20,20 +20,18 @@ import 'android_workflow.dart'; class AndroidEmulators extends EmulatorDiscovery { AndroidEmulators({ - AndroidSdk? androidSdk, - required AndroidWorkflow androidWorkflow, - required FileSystem fileSystem, + required this._androidWorkflow, + required this._fileSystem, required Logger logger, required ProcessManager processManager, - }) : _androidSdk = androidSdk, - _androidWorkflow = androidWorkflow, - _fileSystem = fileSystem, - _logger = logger, + this._androidSdkBuilder, + }) : _logger = logger, _processManager = processManager, _processUtils = ProcessUtils(logger: logger, processManager: processManager); final AndroidWorkflow _androidWorkflow; - final AndroidSdk? _androidSdk; + final AndroidSdk? Function()? _androidSdkBuilder; + late final AndroidSdk? _androidSdk = _androidSdkBuilder?.call(); final FileSystem _fileSystem; final Logger _logger; final ProcessManager _processManager; @@ -59,10 +57,9 @@ class AndroidEmulators extends EmulatorDiscovery { return []; } - final String listAvdsOutput = (await _processUtils.run([ - emulatorPath, - '-list-avds', - ])).stdout.trim(); + final String listAvdsOutput = (await _processUtils.run([emulatorPath, '-list-avds'])) + .stdout + .trim(); final emulators = []; _extractEmulatorAvdInfo(listAvdsOutput, emulators); @@ -140,12 +137,11 @@ class AndroidEmulator extends Emulator { String id, { Map? properties, required Logger logger, - AndroidSdk? androidSdk, + this._androidSdk, required ProcessManager processManager, this.avdDirectory, }) : _properties = properties, _logger = logger, - _androidSdk = androidSdk, _processUtils = ProcessUtils(logger: logger, processManager: processManager), super(id, properties != null && properties.isNotEmpty); diff --git a/packages/flutter_tools/lib/src/android/android_engine_cli_flags.dart b/packages/flutter_tools/lib/src/android/android_engine_cli_flags.dart new file mode 100644 index 00000000..56686122 --- /dev/null +++ b/packages/flutter_tools/lib/src/android/android_engine_cli_flags.dart @@ -0,0 +1,69 @@ +// Copyright 2014 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. + +// TODO(camsim99): Migrate to a shared file with Dart/Kotlin/Java once constants are +// defined cross-language; see https://github.com/flutter/flutter/pull/190236. +/// The command line flags that are passed directly to the Android engine. +abstract final class AndroidEngineCliFlags { + // Flags that represent both the Flutter CLI option key and the Android engine shell argument. + static const String traceStartup = 'trace-startup'; + static const String profileStartup = 'profile-startup'; + static const String route = 'route'; + static const String traceSkia = 'trace-skia'; + static const String traceAllowlist = 'trace-allowlist'; + static const String traceSkiaAllowlist = 'trace-skia-allowlist'; + static const String traceSystrace = 'trace-systrace'; + static const String traceToFile = 'trace-to-file'; + static const String enableDartProfiling = 'enable-dart-profiling'; + static const String enableSoftwareRendering = 'enable-software-rendering'; + static const String skiaDeterministicRendering = 'skia-deterministic-rendering'; + static const String endlessTraceBuffer = 'endless-trace-buffer'; + static const String profileMicrotasks = 'profile-microtasks'; + static const String purgePersistentCache = 'purge-persistent-cache'; + static const String enableImpeller = 'enable-impeller'; + static const String enableVulkanValidation = 'enable-vulkan-validation'; + static const String enableFlutterGpu = 'enable-flutter-gpu'; + static const String testFlag = 'test-flag'; + static const String startPaused = 'start-paused'; + static const String disableServiceAuthCodes = 'disable-service-auth-codes'; + static const String disableServiceOriginCheck = 'disable-service-origin-check'; + static const String dartFlags = 'dart-flags'; + static const String useTestFonts = 'use-test-fonts'; + + // Flags that represent only the Flutter CLI option key. These are mapped to a different Android engine shell argument. + static const String enableHcpp = 'enable-hcpp'; + static const String verboseSystemLogs = 'verbose-system-logs'; + + // Flags that represent only the Android engine shell argument. These are mapped from a different Flutter CLI option key. + static const String enableHcppAndSurfaceControl = 'enable-hcpp-and-surface-control'; + static const String verboseLogging = 'verbose-logging'; + + static const List allFlags = [ + route, + traceStartup, + profileStartup, + traceSkia, + traceAllowlist, + traceSkiaAllowlist, + traceSystrace, + traceToFile, + enableDartProfiling, + enableSoftwareRendering, + skiaDeterministicRendering, + endlessTraceBuffer, + profileMicrotasks, + purgePersistentCache, + enableImpeller, + enableVulkanValidation, + enableFlutterGpu, + enableHcpp, + testFlag, + startPaused, + disableServiceAuthCodes, + disableServiceOriginCheck, + dartFlags, + useTestFonts, + verboseSystemLogs, + ]; +} diff --git a/packages/flutter_tools/lib/src/android/android_sdk.dart b/packages/flutter_tools/lib/src/android/android_sdk.dart index 225fecd0..fda0bec7 100644 --- a/packages/flutter_tools/lib/src/android/android_sdk.dart +++ b/packages/flutter_tools/lib/src/android/android_sdk.dart @@ -5,14 +5,18 @@ /// @docImport 'application_package.dart'; library; +import 'package:process/process.dart'; + import '../base/common.dart'; import '../base/config.dart'; import '../base/file_system.dart'; +import '../base/logger.dart'; +import '../base/os.dart'; import '../base/platform.dart'; import '../base/process.dart'; import '../base/version.dart'; +import '../context/tool_context.dart'; import '../convert.dart'; -import '../globals.dart' as globals; import 'java.dart'; // ANDROID_SDK_ROOT is deprecated. @@ -44,17 +48,27 @@ final _sdkVersionRe = RegExp(r'^ro.build.version.sdk=([0-9]+)$'); // $ANDROID_HOME/platforms/android-23/android.jar // $ANDROID_HOME/platforms/android-N/android.jar class AndroidSdk { - AndroidSdk(this.directory, {Java? java, FileSystem? fileSystem}) : _java = java { - reinitialize(fileSystem: fileSystem); - } + AndroidSdk(this.directory, {required this._toolContext, this._java}); /// The Android SDK root directory. final Directory directory; final Java? _java; + final ToolContext _toolContext; + + FileSystem get _fileSystem => directory.fileSystem; var _sdkVersions = []; AndroidSdkVersion? _latestVersion; + bool _reinitialized = false; + + void _ensureInitialized() { + if (_reinitialized) { + return; + } + _reinitialized = true; + reinitialize(); + } /// Whether the `cmdline-tools` directory exists in the Android SDK. /// @@ -80,32 +94,36 @@ class AndroidSdk { /// the SDK on demand. bool get licensesAvailable => directory.childDirectory('licenses').existsSync(); - static AndroidSdk? locateAndroidSdk() { + static AndroidSdk? locateAndroidSdk({required ToolContext toolContext}) { + final ToolContext( + :Config config, + :FileSystem fs, + :FileSystemUtils fileSystemUtils, + :Logger logger, + :OperatingSystemUtils os, + :Platform platform, + ) = toolContext; + String? findAndroidHomeDir() { String? androidHomeDir; - if (globals.config.containsKey('android-sdk')) { - androidHomeDir = globals.config.getValue('android-sdk') as String?; - } else if (globals.platform.environment.containsKey(kAndroidHome)) { - androidHomeDir = globals.platform.environment[kAndroidHome]; - } else if (globals.platform.environment.containsKey(kAndroidSdkRoot)) { - androidHomeDir = globals.platform.environment[kAndroidSdkRoot]; - } else if (globals.platform.isLinux) { - if (globals.fsUtils.homeDirPath != null) { - androidHomeDir = globals.fs.path.join(globals.fsUtils.homeDirPath!, 'Android', 'Sdk'); + if (config.containsKey('android-sdk')) { + androidHomeDir = config.getValue('android-sdk') as String?; + } else if (platform.environment.containsKey(kAndroidHome)) { + androidHomeDir = platform.environment[kAndroidHome]; + } else if (platform.environment.containsKey(kAndroidSdkRoot)) { + androidHomeDir = platform.environment[kAndroidSdkRoot]; + } else if (platform.isLinux) { + if (fileSystemUtils.homeDirPath != null) { + androidHomeDir = fs.path.join(fileSystemUtils.homeDirPath!, 'Android', 'Sdk'); } - } else if (globals.platform.isMacOS) { - if (globals.fsUtils.homeDirPath != null) { - androidHomeDir = globals.fs.path.join( - globals.fsUtils.homeDirPath!, - 'Library', - 'Android', - 'sdk', - ); + } else if (platform.isMacOS) { + if (fileSystemUtils.homeDirPath != null) { + androidHomeDir = fs.path.join(fileSystemUtils.homeDirPath!, 'Library', 'Android', 'sdk'); } - } else if (globals.platform.isWindows) { - if (globals.fsUtils.homeDirPath != null) { - androidHomeDir = globals.fs.path.join( - globals.fsUtils.homeDirPath!, + } else if (platform.isWindows) { + if (fileSystemUtils.homeDirPath != null) { + androidHomeDir = fs.path.join( + fileSystemUtils.homeDirPath!, 'AppData', 'Local', 'Android', @@ -115,32 +133,37 @@ class AndroidSdk { } if (androidHomeDir != null) { - if (validSdkDirectory(androidHomeDir)) { + if (validSdkDirectory(androidHomeDir, fileSystem: fs)) { return androidHomeDir; } - if (validSdkDirectory(globals.fs.path.join(androidHomeDir, 'sdk'))) { - return globals.fs.path.join(androidHomeDir, 'sdk'); + final String subSdkDir = fs.path.join(androidHomeDir, 'sdk'); + if (validSdkDirectory(subSdkDir, fileSystem: fs)) { + return subSdkDir; } } // in build-tools/$version/aapt - final List aaptBins = globals.os.whichAll('aapt'); - for (var aaptBin in aaptBins) { + for (File aaptBin in os.whichAll('aapt')) { + if (!aaptBin.existsSync()) { + continue; + } // Make sure we're using the aapt from the SDK. - aaptBin = globals.fs.file(aaptBin.resolveSymbolicLinksSync()); + aaptBin = fs.file(aaptBin.resolveSymbolicLinksSync()); final String dir = aaptBin.parent.parent.parent.path; - if (validSdkDirectory(dir)) { + if (validSdkDirectory(dir, fileSystem: fs)) { return dir; } } // in platform-tools/adb - final List adbBins = globals.os.whichAll('adb'); - for (var adbBin in adbBins) { + for (File adbBin in os.whichAll('adb')) { + if (!adbBin.existsSync()) { + continue; + } // Make sure we're using the adb from the SDK. - adbBin = globals.fs.file(adbBin.resolveSymbolicLinksSync()); + adbBin = fs.file(adbBin.resolveSymbolicLinksSync()); final String dir = adbBin.parent.parent.path; - if (validSdkDirectory(dir)) { + if (validSdkDirectory(dir, fileSystem: fs)) { return dir; } } @@ -151,30 +174,39 @@ class AndroidSdk { final String? androidHomeDir = findAndroidHomeDir(); if (androidHomeDir == null) { // No dice. - globals.printTrace('Unable to locate an Android SDK.'); + logger.printTrace('Unable to locate an Android SDK.'); return null; } - return AndroidSdk(globals.fs.directory(androidHomeDir)); + return AndroidSdk(fs.directory(androidHomeDir), toolContext: toolContext); } - static bool validSdkDirectory(String dir) { - return sdkDirectoryHasLicenses(dir) || sdkDirectoryHasPlatformTools(dir); + static bool validSdkDirectory(String dir, {required FileSystem fileSystem}) { + return sdkDirectoryHasLicenses(dir, fileSystem: fileSystem) || + sdkDirectoryHasPlatformTools(dir, fileSystem: fileSystem); } - static bool sdkDirectoryHasPlatformTools(String dir) { - return globals.fs.isDirectorySync(globals.fs.path.join(dir, 'platform-tools')); + static bool sdkDirectoryHasPlatformTools(String dir, {required FileSystem fileSystem}) { + return fileSystem.isDirectorySync(fileSystem.path.join(dir, 'platform-tools')); } - static bool sdkDirectoryHasLicenses(String dir) { - return globals.fs.isDirectorySync(globals.fs.path.join(dir, 'licenses')); + static bool sdkDirectoryHasLicenses(String dir, {required FileSystem fileSystem}) { + return fileSystem.isDirectorySync(fileSystem.path.join(dir, 'licenses')); } - List get sdkVersions => _sdkVersions; + List get sdkVersions { + _ensureInitialized(); + return _sdkVersions; + } - AndroidSdkVersion? get latestVersion => _latestVersion; + AndroidSdkVersion? get latestVersion { + _ensureInitialized(); + return _latestVersion; + } - late final String? adbPath = getPlatformToolsPath(globals.platform.isWindows ? 'adb.exe' : 'adb'); + late final String? adbPath = getPlatformToolsPath( + _toolContext.platform.isWindows ? 'adb.exe' : 'adb', + ); String? get emulatorPath => getEmulatorPath(); @@ -182,27 +214,28 @@ class AndroidSdk { /// Locate the path for storing AVD emulator images. Returns null if none found. String? getAvdPath() { - final String? avdHome = globals.platform.environment['ANDROID_AVD_HOME']; - final String? home = globals.platform.environment['HOME']; + final Platform platform = _toolContext.platform; + final String? avdHome = platform.environment['ANDROID_AVD_HOME']; + final String? home = platform.environment['HOME']; final searchPaths = [ ?avdHome, - if (home != null) globals.fs.path.join(home, '.android', 'avd'), + if (home != null) _fileSystem.path.join(home, '.android', 'avd'), ]; - if (globals.platform.isWindows) { - final String? homeDrive = globals.platform.environment['HOMEDRIVE']; - final String? homePath = globals.platform.environment['HOMEPATH']; + if (platform.isWindows) { + final String? homeDrive = platform.environment['HOMEDRIVE']; + final String? homePath = platform.environment['HOMEPATH']; if (homeDrive != null && homePath != null) { // Can't use path.join for HOMEDRIVE/HOMEPATH // https://github.com/dart-lang/path/issues/37 final String home = homeDrive + homePath; - searchPaths.add(globals.fs.path.join(home, '.android', 'avd')); + searchPaths.add(_fileSystem.path.join(home, '.android', 'avd')); } } for (final searchPath in searchPaths) { - if (globals.fs.directory(searchPath).existsSync()) { + if (_fileSystem.directory(searchPath).existsSync()) { return searchPath; } } @@ -222,7 +255,8 @@ class AndroidSdk { /// Validate the Android SDK. This returns an empty list if there are no /// issues; otherwise, it returns a list of issues found. List validateSdkWellFormed() { - if (adbPath == null || !globals.processManager.canRun(adbPath)) { + final ProcessManager processManager = _toolContext.processManager; + if (adbPath == null || !processManager.canRun(adbPath)) { return ['Android SDK file not found: ${adbPath ?? 'adb'}.']; } @@ -264,7 +298,8 @@ class AndroidSdk { } String? getEmulatorPath() { - final binaryName = globals.platform.isWindows ? 'emulator.exe' : 'emulator'; + final Platform platform = _toolContext.platform; + final binaryName = platform.isWindows ? 'emulator.exe' : 'emulator'; // Emulator now lives inside "emulator" but used to live inside "tools" so // try both. final searchFolders = ['emulator', 'tools']; @@ -291,19 +326,15 @@ class AndroidSdk { // Next look for the highest version of the command-line tools final Directory cmdlineToolsDir = directory.childDirectory('cmdline-tools'); if (cmdlineToolsDir.existsSync()) { - final List cmdlineTools = cmdlineToolsDir - .listSync() - .whereType() - .map((Directory subDirectory) { - try { - return Version.parse(subDirectory.basename); - } on Exception { - return null; - } - }) - .whereType() - .toList(); - cmdlineTools.sort(); + final cmdlineTools = [ + ...cmdlineToolsDir.listSync().whereType().map((Directory subDirectory) { + try { + return Version.parse(subDirectory.basename); + } on Exception { + return null; + } + }).whereType(), + ]..sort(); for (final Version cmdlineToolsVersion in cmdlineTools.reversed) { final File cmdlineToolsBinary = directory @@ -332,8 +363,10 @@ class AndroidSdk { return null; } - String? getAvdManagerPath() => - getCmdlineToolsPath(globals.platform.isWindows ? 'avdmanager.bat' : 'avdmanager'); + String? getAvdManagerPath() { + final Platform platform = _toolContext.platform; + return getCmdlineToolsPath(platform.isWindows ? 'avdmanager.bat' : 'avdmanager'); + } /// From https://developer.android.com/ndk/guides/other_build_systems. static const _llvmHostDirectoryName = { @@ -346,17 +379,15 @@ class AndroidSdk { /// /// The order of resolution is as follows: /// - /// 1. If [globals.config] defines an `'android-ndk'` use that. + /// 1. If [Config] defines an `'android-ndk'` use that. /// 2. If the environment variable `ANDROID_NDK_HOME` is defined, use that. /// 3. If the environment variable `ANDROID_NDK_PATH` is defined, use that. /// 4. If the environment variable `ANDROID_NDK_ROOT` is defined, use that. /// 5. Look for the default install location inside the Android SDK: /// [directory]/ndk/\/. If multiple versions exist, use the /// newest. - Iterable getNdkDirectoriesInResolutionOrder({Platform? platform, Config? config}) { - platform ??= globals.platform; - config ??= globals.config; - + Iterable getNdkDirectoriesInResolutionOrder() { + final ToolContext(:Config config, :Platform platform) = _toolContext; final ndkDirectories = []; String? androidNdkHomeDir; if (config.containsKey('android-ndk')) { @@ -378,33 +409,25 @@ class AndroidSdk { if (!ndk.existsSync()) { return ndkDirectories; } - final List ndkVersions = - ndk - .listSync() - .map((FileSystemEntity entity) { - try { - return Version.parse(entity.basename); - } on Exception { - return null; - } - }) - .whereType() - .toList() - // Use latest NDK first. - ..sort((Version a, Version b) => -a.compareTo(b)); + final ndkVersions = [ + ...ndk.listSync().map((FileSystemEntity entity) { + try { + return Version.parse(entity.basename); + } on Exception { + return null; + } + }).whereType(), + // Use latest NDK first. + ]..sort((Version a, Version b) => -a.compareTo(b)); for (final ndkVersion in ndkVersions) { ndkDirectories.add(ndk.childDirectory(ndkVersion.toString())); } return ndkDirectories; } - String? getNdkBinaryPath(String binaryName, {Platform? platform, Config? config}) { - platform ??= globals.platform; - config ??= globals.config; - for (final Directory androidNdkHomeDir in getNdkDirectoriesInResolutionOrder( - platform: platform, - config: config, - )) { + String? getNdkBinaryPath(String binaryName) { + final Platform platform = _toolContext.platform; + for (final Directory androidNdkHomeDir in getNdkDirectoriesInResolutionOrder()) { final File executable = androidNdkHomeDir .childDirectory('toolchains') .childDirectory('llvm') @@ -420,38 +443,28 @@ class AndroidSdk { return null; } - String? getNdkClangPath({Platform? platform, Config? config}) { - platform ??= globals.platform; - return getNdkBinaryPath( - platform.isWindows ? 'clang.exe' : 'clang', - platform: platform, - config: config, - ); + String? getNdkClangPath() { + final Platform platform = _toolContext.platform; + return getNdkBinaryPath(platform.isWindows ? 'clang.exe' : 'clang'); } - String? getNdkArPath({Platform? platform, Config? config}) { - platform ??= globals.platform; - return getNdkBinaryPath( - platform.isWindows ? 'llvm-ar.exe' : 'llvm-ar', - platform: platform, - config: config, - ); + String? getNdkArPath() { + final Platform platform = _toolContext.platform; + return getNdkBinaryPath(platform.isWindows ? 'llvm-ar.exe' : 'llvm-ar'); } - String? getNdkLdPath({Platform? platform, Config? config}) { - platform ??= globals.platform; - return getNdkBinaryPath( - platform.isWindows ? 'ld.lld.exe' : 'ld.lld', - platform: platform, - config: config, - ); + String? getNdkLdPath() { + final Platform platform = _toolContext.platform; + return getNdkBinaryPath(platform.isWindows ? 'ld.lld.exe' : 'ld.lld'); } /// Sets up various paths used internally. /// /// This method should be called in a case where the tooling may have updated /// SDK artifacts, such as after running a gradle build. - void reinitialize({FileSystem? fileSystem}) { + void reinitialize() { + final ProcessManager processManager = _toolContext.processManager; + _reinitialized = true; var buildTools = []; // 19.1.0, 22.0.1, ... final Directory buildToolsDir = directory.childDirectory('build-tools'); @@ -514,10 +527,11 @@ class AndroidSdk { return AndroidSdkVersion._( this, - sdkLevel: platformVersion, - platformName: platformName, buildToolsVersion: buildToolsVersion, - fileSystem: fileSystem ?? globals.fs, + fileSystem: directory.fileSystem, + platformName: platformName, + processManager: processManager, + sdkLevel: platformVersion, ); }) .whereType() @@ -530,24 +544,27 @@ class AndroidSdk { /// Returns the filesystem path of the Android SDK manager tool. String? get sdkManagerPath { - final executable = globals.platform.isWindows ? 'sdkmanager.bat' : 'sdkmanager'; + final Platform platform = _toolContext.platform; + final executable = platform.isWindows ? 'sdkmanager.bat' : 'sdkmanager'; return getCmdlineToolsPath(executable, skipOldTools: true); } /// Returns the version of the Android SDK manager tool or null if not found. String? get sdkManagerVersion { - if (sdkManagerPath == null || !globals.processManager.canRun(sdkManagerPath)) { + final ToolContext(:Logger logger, :ProcessManager processManager, :ProcessUtils processUtils) = + _toolContext; + if (sdkManagerPath == null || !processManager.canRun(sdkManagerPath)) { throwToolExit( 'Android sdkmanager not found. Update to the latest Android SDK and ensure that ' 'the cmdline-tools are installed to resolve this.', ); } - final RunResult result = globals.processUtils.runSync([ + final RunResult result = processUtils.runSync([ sdkManagerPath!, '--version', ], environment: _java?.environment); if (result.exitCode != 0) { - globals.printTrace( + logger.printTrace( 'sdkmanager --version failed: exitCode: ${result.exitCode} stdout: ${result.stdout} stderr: ${result.stderr}', ); return null; @@ -559,49 +576,15 @@ class AndroidSdk { String toString() => 'AndroidSdk: $directory'; } -extension AndroidSdkNdkHelpers on AndroidSdk { - /// Returns whether the Android SDK already contains the requested NDK version. - bool hasNdkVersion(String version) { - final Directory ndkDirectory = directory.childDirectory('ndk').childDirectory(version); - return ndkDirectory.childFile('source.properties').existsSync(); - } - - /// Installs a specific Android SDK component with sdkmanager. - Future installSdkComponent( - String component, { - Java? java, - ProcessUtils? processUtils, - }) async { - processUtils ??= globals.processUtils; - final String? executable = sdkManagerPath; - if (executable == null || !globals.processManager.canRun(executable)) { - throwToolExit( - 'Android sdkmanager not found. Update to the latest Android SDK and ensure that ' - 'the cmdline-tools are installed to resolve this.', - ); - } - return processUtils.run([ - executable, - '--sdk_root=${directory.path}', - '--install', - component, - ], environment: java?.environment); - } - - /// Installs the requested NDK version via sdkmanager. - Future installNdkVersion(String version, {Java? java, ProcessUtils? processUtils}) { - return installSdkComponent('ndk;$version', java: java, processUtils: processUtils); - } -} - class AndroidSdkVersion implements Comparable { AndroidSdkVersion._( this.sdk, { - required this.sdkLevel, - required this.platformName, required this.buildToolsVersion, - required FileSystem fileSystem, - }) : _fileSystem = fileSystem; + required this._fileSystem, + required this.platformName, + required this._processManager, + required this.sdkLevel, + }); final AndroidSdk sdk; final int sdkLevel; @@ -609,6 +592,7 @@ class AndroidSdkVersion implements Comparable { final Version buildToolsVersion; final FileSystem _fileSystem; + final ProcessManager _processManager; String get buildToolsVersionName => buildToolsVersion.toString(); @@ -667,7 +651,7 @@ class AndroidSdkVersion implements Comparable { } String? _canRun(String path) { - if (!globals.processManager.canRun(path)) { + if (!_processManager.canRun(path)) { return 'Android SDK file not found: $path.'; } return null; diff --git a/packages/flutter_tools/lib/src/android/android_studio.dart b/packages/flutter_tools/lib/src/android/android_studio.dart index 03b611d8..e5d107c4 100644 --- a/packages/flutter_tools/lib/src/android/android_studio.dart +++ b/packages/flutter_tools/lib/src/android/android_studio.dart @@ -6,15 +6,19 @@ library; import 'package:meta/meta.dart'; +import 'package:process/process.dart'; import '../base/common.dart'; +import '../base/config.dart'; import '../base/file_system.dart'; import '../base/io.dart'; +import '../base/logger.dart'; +import '../base/platform.dart'; import '../base/process.dart'; import '../base/utils.dart'; import '../base/version.dart'; +import '../context/tool_context.dart'; import '../convert.dart'; -import '../globals.dart' as globals; import '../ios/plist_parser.dart'; @visibleForTesting @@ -51,18 +55,24 @@ class AndroidStudio { /// A [version] value of null represents an unknown version. AndroidStudio( this.directory, { - this.version, + required this._toolContext, this.configuredPath, - this.studioAppName = 'AndroidStudio', this.presetPluginsPath, + this.studioAppName = _androidStudioId, + this.version, + }); + + static AndroidStudio? fromMacOSBundle( + String bundlePath, { + required PlistParser plistParser, + required ToolContext toolContext, + String? configuredPath, }) { - _initAndValidate(); - } + final ToolContext(:FileSystem fs, :FileSystemUtils fileSystemUtils) = toolContext; - static AndroidStudio? fromMacOSBundle(String bundlePath, {String? configuredPath}) { - final String studioPath = globals.fs.path.join(bundlePath, 'Contents'); - final String plistFile = globals.fs.path.join(studioPath, 'Info.plist'); - final Map plistValues = globals.plistParser.parseFile(plistFile); + final String studioPath = fs.path.join(bundlePath, 'Contents'); + final String plistFile = fs.path.join(studioPath, 'Info.plist'); + final Map plistValues = plistParser.parseFile(plistFile); // If we've found a JetBrainsToolbox wrapper, ignore it. if (plistValues.containsKey('JetBrainsToolboxApp')) { return null; @@ -76,21 +86,19 @@ class AndroidStudio { } String? pathsSelectorValue; - final Map? jvmOptions = castStringKeyedMap(plistValues['JVMOptions']); - if (jvmOptions != null) { - final Map? jvmProperties = castStringKeyedMap(jvmOptions['Properties']); - if (jvmProperties != null) { - pathsSelectorValue = jvmProperties['idea.paths.selector'] as String; + if (castStringKeyedMap(plistValues['JVMOptions']) case final jvmOptions?) { + if (castStringKeyedMap(jvmOptions['Properties']) case final jvmProperties?) { + pathsSelectorValue = jvmProperties['idea.paths.selector'] as String?; } } final int? major = version?.major; final int? minor = version?.minor; String? presetPluginsPath; - final String? homeDirPath = globals.fsUtils.homeDirPath; + final String? homeDirPath = fileSystemUtils.homeDirPath; if (homeDirPath != null && pathsSelectorValue != null) { if (major != null && major >= 4 && minor != null && minor >= 1) { - presetPluginsPath = globals.fs.path.join( + presetPluginsPath = fs.path.join( homeDirPath, 'Library', 'Application Support', @@ -98,7 +106,7 @@ class AndroidStudio { pathsSelectorValue, ); } else { - presetPluginsPath = globals.fs.path.join( + presetPluginsPath = fs.path.join( homeDirPath, 'Library', 'Application Support', @@ -108,13 +116,15 @@ class AndroidStudio { } return AndroidStudio( studioPath, - version: version, - presetPluginsPath: presetPluginsPath, configuredPath: configuredPath, + presetPluginsPath: presetPluginsPath, + toolContext: toolContext, + version: version, ); } - static AndroidStudio? fromHomeDot(Directory homeDotDir) { + static AndroidStudio? fromHomeDot(Directory homeDotDir, {required ToolContext toolContext}) { + final FileSystem fs = toolContext.fs; final Match? versionMatch = _dotHomeStudioVersionMatcher.firstMatch(homeDotDir.basename); if (versionMatch?.groupCount != 2) { return null; @@ -134,21 +144,26 @@ class AndroidStudio { String dotHomeFilePath; if (major >= 4 && minor >= 1) { - dotHomeFilePath = globals.fs.path.join(homeDotDir.path, '.home'); + dotHomeFilePath = fs.path.join(homeDotDir.path, '.home'); } else { - dotHomeFilePath = globals.fs.path.join(homeDotDir.path, 'system', '.home'); + dotHomeFilePath = fs.path.join(homeDotDir.path, 'system', '.home'); } String? installPath; try { - installPath = globals.fs.file(dotHomeFilePath).readAsStringSync(); + installPath = fs.file(dotHomeFilePath).readAsStringSync(); } on Exception { // ignored, installPath will be null, which is handled below } - if (installPath != null && globals.fs.isDirectorySync(installPath)) { - return AndroidStudio(installPath, version: version, studioAppName: studioAppName); + if (installPath != null && fs.isDirectorySync(installPath)) { + return AndroidStudio( + installPath, + studioAppName: studioAppName, + toolContext: toolContext, + version: version, + ); } return null; } @@ -164,9 +179,20 @@ class AndroidStudio { final String? configuredPath; final String? presetPluginsPath; + final ToolContext _toolContext; + String? _javaPath; - var _isValid = false; + bool _isValid = false; final _validationMessages = []; + bool _validated = false; + + void _ensureValidated() { + if (_validated) { + return; + } + _validated = true; + _initAndValidate(); + } /// The path of the JDK bundled with Android Studio. /// @@ -174,19 +200,28 @@ class AndroidStudio { /// /// If you looking to invoke the java binary or add it to the system /// environment variables, consider using the [Java] class instead. - String? get javaPath => _javaPath; + String? get javaPath { + _ensureValidated(); + return _javaPath; + } - bool get isValid => _isValid; + bool get isValid { + _ensureValidated(); + return _isValid; + } String? get pluginsPath { if (presetPluginsPath != null) { return presetPluginsPath!; } + final ToolContext(:FileSystem fs, :FileSystemUtils fileSystemUtils, :Platform platform) = + _toolContext; + // JetBrains Toolbox writes plugins to a sibling directory with a ".plugins" suffix. - if (!globals.platform.isMacOS) { + if (!platform.isMacOS) { final toolboxPluginsPath = '$directory.plugins'; - if (globals.fs.directory(toolboxPluginsPath).existsSync()) { + if (fs.directory(toolboxPluginsPath).existsSync()) { return toolboxPluginsPath; } } @@ -197,14 +232,14 @@ class AndroidStudio { final int major = version!.major; final int minor = version!.minor; - final String? homeDirPath = globals.fsUtils.homeDirPath; + final String? homeDirPath = fileSystemUtils.homeDirPath; if (homeDirPath == null) { return null; } - if (globals.platform.isMacOS) { + if (platform.isMacOS) { /// plugin path of Android Studio has been changed after version 4.1. if (major >= 4 && minor >= 1) { - return globals.fs.path.join( + return fs.path.join( homeDirPath, 'Library', 'Application Support', @@ -212,7 +247,7 @@ class AndroidStudio { 'AndroidStudio$major.$minor', ); } else { - return globals.fs.path.join( + return fs.path.join( homeDirPath, 'Library', 'Application Support', @@ -220,8 +255,8 @@ class AndroidStudio { ); } } else { - if (major >= 4 && minor >= 1 && globals.platform.isLinux) { - return globals.fs.path.join( + if (major >= 4 && minor >= 1 && platform.isLinux) { + return fs.path.join( homeDirPath, '.local', 'share', @@ -230,22 +265,29 @@ class AndroidStudio { ); } - return globals.fs.path.join(homeDirPath, '.$studioAppName$major.$minor', 'config', 'plugins'); + return fs.path.join(homeDirPath, '.$studioAppName$major.$minor', 'config', 'plugins'); } } - List get validationMessages => _validationMessages; + List get validationMessages { + _ensureValidated(); + return _validationMessages; + } /// Locates the newest, valid version of Android Studio. /// /// In the case that `--android-studio-dir` is configured, the version of /// Android Studio found at that location is always returned, even if it is /// invalid. - static AndroidStudio? latestValid() { - final Directory? configuredStudioDir = _configuredDir(); + static AndroidStudio? latestValid({required ToolContext toolContext, PlistParser? plistParser}) { + final ToolContext(:Config config, :FileSystem fs) = toolContext; + final Directory? configuredStudioDir = _configuredDir(config: config, fileSystem: fs); // Find all available Studio installations. - final List studios = allInstalled(); + final List studios = allInstalled( + plistParser: plistParser, + toolContext: toolContext, + ); if (studios.isEmpty) { return null; } @@ -255,7 +297,7 @@ class AndroidStudio { (AndroidStudio studio) => studio.configuredPath != null && configuredStudioDir != null && - _pathsAreEqual(studio.configuredPath!, configuredStudioDir.path), + _pathsAreEqual(studio.configuredPath!, configuredStudioDir.path, fileSystem: fs), ) .firstOrNull; @@ -263,42 +305,63 @@ class AndroidStudio { return manuallyConfigured; } - AndroidStudio? newest; - for (final AndroidStudio studio in studios.where((AndroidStudio s) => s.isValid)) { - if (newest == null) { - newest = studio; - continue; - } + final sortedStudios = List.of(studios)..sort(_compareCandidates); - // We prefer installs with known versions. - if (studio.version != null && newest.version == null) { - newest = studio; - } else if (studio.version != null && - newest.version != null && - studio.version! > newest.version!) { - newest = studio; - } else if (studio.version == null && - newest.version == null && - studio.directory.compareTo(newest.directory) > 0) { - newest = studio; + for (final studio in sortedStudios) { + if (studio.isValid) { + return studio; } } - return newest; + return null; + } + + static int _compareCandidates(AndroidStudio a, AndroidStudio b) { + if (a.version != null && b.version == null) { + return -1; + } + if (a.version == null && b.version != null) { + return 1; + } + if (a.version != null && b.version != null) { + final int versionComparison = b.version!.compareTo(a.version!); + if (versionComparison != 0) { + return versionComparison; + } + } + return b.directory.compareTo(a.directory); } - static List allInstalled() => - globals.platform.isMacOS ? _allMacOS() : _allLinuxOrWindows(); + static List allInstalled({ + required ToolContext toolContext, + PlistParser? plistParser, + }) { + return toolContext.platform.isMacOS + ? _allMacOS(plistParser: plistParser, toolContext: toolContext) + : _allLinuxOrWindows(toolContext: toolContext); + } - static List _allMacOS() { + static List _allMacOS({ + required ToolContext toolContext, + PlistParser? plistParser, + }) { + final ToolContext( + :Config config, + :FileSystem fs, + :FileSystemUtils fileSystemUtils, + :Logger logger, + :ProcessManager processManager, + ) = toolContext; + final PlistParser resolvedPlistParser = + plistParser ?? PlistParser(fileSystem: fs, logger: logger, processManager: processManager); final candidatePaths = []; void checkForStudio(String path) { - if (!globals.fs.isDirectorySync(path)) { + if (!fs.isDirectorySync(path)) { return; } try { - final Iterable directories = globals.fs + final Iterable directories = fs .directory(path) .listSync(followLinks: false) .whereType(); @@ -312,23 +375,23 @@ class AndroidStudio { } } } on Exception catch (e) { - globals.printTrace('Exception while looking for Android Studio: $e'); + logger.printTrace('Exception while looking for Android Studio: $e'); } } checkForStudio('/Applications'); - final String? homeDirPath = globals.fsUtils.homeDirPath; + final String? homeDirPath = fileSystemUtils.homeDirPath; if (homeDirPath != null) { - checkForStudio(globals.fs.path.join(homeDirPath, 'Applications')); + checkForStudio(fs.path.join(homeDirPath, 'Applications')); } - Directory? configuredStudioDir = _configuredDir(); + Directory? configuredStudioDir = _configuredDir(config: config, fileSystem: fs); if (configuredStudioDir != null) { if (configuredStudioDir.basename == 'Contents') { configuredStudioDir = configuredStudioDir.parent; } if (!candidatePaths.any( - (FileSystemEntity e) => _pathsAreEqual(e.path, configuredStudioDir!.path), + (FileSystemEntity e) => _pathsAreEqual(e.path, configuredStudioDir!.path, fileSystem: fs), )) { candidatePaths.add(configuredStudioDir); } @@ -340,14 +403,14 @@ class AndroidStudio { // and flutter daemon from hanging indefinitely (https://github.com/flutter/flutter/issues/189177). var spotlightQueryResult = ''; try { - final ProcessResult spotlightResult = globals.processManager.runSync([ + final ProcessResult spotlightResult = processManager.runSync([ 'sh', '-c', // com.google.android.studio, com.google.android.studio-EAP kSpotlightMdfindCommand, ]); if (spotlightResult.exitCode != 0) { - globals.printTrace( + logger.printTrace( 'Spotlight mdfind query failed or timed out with exit code ${spotlightResult.exitCode}', ); } @@ -356,7 +419,7 @@ class AndroidStudio { // The Spotlight query is a nice-to-have, continue checking known installation locations. } for (final String studioPath in LineSplitter.split(spotlightQueryResult)) { - final Directory appBundle = globals.fs.directory(studioPath); + final Directory appBundle = fs.directory(studioPath); if (!candidatePaths.any((FileSystemEntity e) => e.path == studioPath)) { candidatePaths.add(appBundle); } @@ -365,14 +428,20 @@ class AndroidStudio { return candidatePaths .map((FileSystemEntity e) { if (configuredStudioDir == null) { - return AndroidStudio.fromMacOSBundle(e.path); + return AndroidStudio.fromMacOSBundle( + e.path, + plistParser: resolvedPlistParser, + toolContext: toolContext, + ); } return AndroidStudio.fromMacOSBundle( e.path, - configuredPath: _pathsAreEqual(configuredStudioDir.path, e.path) + configuredPath: _pathsAreEqual(configuredStudioDir.path, e.path, fileSystem: fs) ? configuredStudioDir.path : null, + plistParser: resolvedPlistParser, + toolContext: toolContext, ); }) .whereType() @@ -384,7 +453,13 @@ class AndroidStudio { _androidStudioPreviewId: _androidStudioPreviewTitle, }; - static List _allLinuxOrWindows() { + static List _allLinuxOrWindows({required ToolContext toolContext}) { + final ToolContext( + :Config config, + :FileSystem fs, + :FileSystemUtils fileSystemUtils, + :Platform platform, + ) = toolContext; final studios = []; bool alreadyFoundStudioAt(String path, {Version? newerThan}) { @@ -406,14 +481,14 @@ class AndroidStudio { // or $HOME/.cache/Google/AndroidStudio*/.home files. // There may be several pointing to the same installation, // so we grab only the latest one. - final String? homeDirPath = globals.fsUtils.homeDirPath; + final String? homeDirPath = fileSystemUtils.homeDirPath; - if (homeDirPath != null && globals.fs.directory(homeDirPath).existsSync()) { + if (homeDirPath != null && fs.directory(homeDirPath).existsSync()) { // >=4.1 has new install location at $HOME/.cache/Google - final String cacheDirPath = globals.fs.path.join(homeDirPath, '.cache', 'Google'); + final String cacheDirPath = fs.path.join(homeDirPath, '.cache', 'Google'); final directoriesToSearch = [ - globals.fs.directory(homeDirPath), - if (globals.fs.isDirectorySync(cacheDirPath)) globals.fs.directory(cacheDirPath), + fs.directory(homeDirPath), + if (fs.isDirectorySync(cacheDirPath)) fs.directory(cacheDirPath), ]; final entities = []; @@ -430,7 +505,7 @@ class AndroidStudio { } for (final entity in entities) { - final AndroidStudio? studio = AndroidStudio.fromHomeDot(entity); + final AndroidStudio? studio = AndroidStudio.fromHomeDot(entity, toolContext: toolContext); if (studio != null && !alreadyFoundStudioAt(studio.directory, newerThan: studio.version)) { studios.removeWhere((AndroidStudio other) => other.directory == studio.directory); studios.add(studio); @@ -439,36 +514,36 @@ class AndroidStudio { } // Discover Android Studio > 4.1 - if (globals.platform.isWindows && globals.platform.environment.containsKey('LOCALAPPDATA')) { - final Directory cacheDir = globals.fs.directory( - globals.fs.path.join(globals.platform.environment['LOCALAPPDATA']!, 'Google'), + if (platform.isWindows && platform.environment.containsKey('LOCALAPPDATA')) { + final Directory cacheDir = fs.directory( + fs.path.join(platform.environment['LOCALAPPDATA']!, 'Google'), ); if (!cacheDir.existsSync()) { return studios; } for (final Directory dir in cacheDir.listSync().whereType()) { - final String name = globals.fs.path.basename(dir.path); + final String name = fs.path.basename(dir.path); _idToTitle.forEach((String id, String title) { if (name.startsWith(id)) { final String version = name.substring(id.length); String? installPath; try { - installPath = globals.fs - .file(globals.fs.path.join(dir.path, '.home')) - .readAsStringSync(); + installPath = fs.file(fs.path.join(dir.path, '.home')).readAsStringSync(); } on FileSystemException { // ignored } - if (installPath != null && globals.fs.isDirectorySync(installPath)) { + if (installPath != null && fs.isDirectorySync(installPath)) { final studio = AndroidStudio( installPath, - version: Version.parse(version), studioAppName: title, + toolContext: toolContext, + version: Version.parse(version), ); if (!alreadyFoundStudioAt(studio.directory, newerThan: studio.version)) { studios.removeWhere( - (AndroidStudio other) => _pathsAreEqual(other.directory, studio.directory), + (AndroidStudio other) => + _pathsAreEqual(other.directory, studio.directory, fileSystem: fs), ); studios.add(studio); } @@ -478,10 +553,13 @@ class AndroidStudio { } } - final configuredStudioDir = globals.config.getValue('android-studio-dir') as String?; + final configuredStudioDir = config.getValue('android-studio-dir') as String?; if (configuredStudioDir != null) { final AndroidStudio? matchingAlreadyFoundInstall = studios - .where((AndroidStudio other) => _pathsAreEqual(configuredStudioDir, other.directory)) + .where( + (AndroidStudio other) => + _pathsAreEqual(configuredStudioDir, other.directory, fileSystem: fs), + ) .firstOrNull; if (matchingAlreadyFoundInstall != null) { studios.remove(matchingAlreadyFoundInstall); @@ -489,24 +567,31 @@ class AndroidStudio { AndroidStudio( configuredStudioDir, configuredPath: configuredStudioDir, + toolContext: toolContext, version: matchingAlreadyFoundInstall.version, ), ); } else { - studios.add(AndroidStudio(configuredStudioDir, configuredPath: configuredStudioDir)); + studios.add( + AndroidStudio( + configuredStudioDir, + configuredPath: configuredStudioDir, + toolContext: toolContext, + ), + ); } } - if (globals.platform.isLinux) { + if (platform.isLinux) { void checkWellKnownPath(String path) { - if (globals.fs.isDirectorySync(path) && !alreadyFoundStudioAt(path)) { - studios.add(AndroidStudio(path)); + if (fs.isDirectorySync(path) && !alreadyFoundStudioAt(path)) { + studios.add(AndroidStudio(path, toolContext: toolContext)); } } // Add /opt/android-studio and $HOME/android-studio, if they exist. checkWellKnownPath('/opt/android-studio'); - checkWellKnownPath('${globals.fsUtils.homeDirPath}/android-studio'); + checkWellKnownPath('${fileSystemUtils.homeDirPath}/android-studio'); } return studios; } @@ -515,12 +600,12 @@ class AndroidStudio { /// /// The returned [Directory], if not null, is guaranteed to have existed during /// this function's execution. - static Directory? _configuredDir() { - final configuredPath = globals.config.getValue('android-studio-dir') as String?; + static Directory? _configuredDir({required Config config, required FileSystem fileSystem}) { + final configuredPath = config.getValue('android-studio-dir') as String?; if (configuredPath == null) { return null; } - final Directory result = globals.fs.directory(configuredPath); + final Directory result = fileSystem.directory(configuredPath); bool? configuredStudioPathExists; String? exceptionMessage; @@ -548,6 +633,12 @@ the configured path by running this command: flutter config --android-studio-dir } void _initAndValidate() { + final ToolContext( + :FileSystem fs, + :Platform platform, + :ProcessManager processManager, + :ProcessUtils processUtils, + ) = _toolContext; _isValid = false; _validationMessages.clear(); @@ -555,35 +646,35 @@ the configured path by running this command: flutter config --android-studio-dir _validationMessages.add('android-studio-dir = $configuredPath'); } - if (!globals.fs.isDirectorySync(directory)) { + if (!fs.isDirectorySync(directory)) { _validationMessages.add('Android Studio not found at $directory'); return; } final String javaPath; - if (globals.platform.isMacOS) { + if (platform.isMacOS) { if (version != null && version!.major < 2020) { - javaPath = globals.fs.path.join(directory, 'jre', 'jdk', 'Contents', 'Home'); + javaPath = fs.path.join(directory, 'jre', 'jdk', 'Contents', 'Home'); } else if (version != null && version!.major < 2022) { - javaPath = globals.fs.path.join(directory, 'jre', 'Contents', 'Home'); + javaPath = fs.path.join(directory, 'jre', 'Contents', 'Home'); // See https://github.com/flutter/flutter/issues/125246 for more context. } else { - javaPath = globals.fs.path.join(directory, 'jbr', 'Contents', 'Home'); + javaPath = fs.path.join(directory, 'jbr', 'Contents', 'Home'); } } else { if (version != null && version!.major < 2022) { - javaPath = globals.fs.path.join(directory, 'jre'); + javaPath = fs.path.join(directory, 'jre'); } else { - javaPath = globals.fs.path.join(directory, 'jbr'); + javaPath = fs.path.join(directory, 'jbr'); } } - final String javaExecutable = globals.fs.path.join(javaPath, 'bin', 'java'); - if (!globals.processManager.canRun(javaExecutable)) { + final String javaExecutable = fs.path.join(javaPath, 'bin', 'java'); + if (!processManager.canRun(javaExecutable)) { _validationMessages.add('Unable to find bundled Java version.'); } else { RunResult? result; try { - result = globals.processUtils.runSync([javaExecutable, '-version']); + result = processUtils.runSync([javaExecutable, '-version']); } on ProcessException catch (e) { _validationMessages.add('Failed to run Java: $e'); } @@ -632,6 +723,6 @@ the configured path by running this command: flutter config --android-studio-dir String toString() => 'Android Studio ($version)'; } -bool _pathsAreEqual(String path, String other) { - return globals.fs.path.canonicalize(path) == globals.fs.path.canonicalize(other); +bool _pathsAreEqual(String path, String other, {required FileSystem fileSystem}) { + return fileSystem.path.canonicalize(path) == fileSystem.path.canonicalize(other); } diff --git a/packages/flutter_tools/lib/src/android/android_workflow.dart b/packages/flutter_tools/lib/src/android/android_workflow.dart index 15ff20f5..ce256dde 100644 --- a/packages/flutter_tools/lib/src/android/android_workflow.dart +++ b/packages/flutter_tools/lib/src/android/android_workflow.dart @@ -4,6 +4,7 @@ import 'dart:async'; +import 'package:flutter_tools_core/flutter_tools_core.dart'; import 'package:process/process.dart'; import '../base/common.dart'; @@ -32,13 +33,16 @@ enum LicensesAccepted { none, some, all, unknown } final licenseCounts = RegExp(r'(\d+) of (\d+) SDK package licenses? not accepted.'); final licenseNotAccepted = RegExp(r'licenses? not accepted', caseSensitive: false); final licenseAccepted = RegExp(r'All SDK package licenses accepted.'); +final licensesNoLongerNeeded = RegExp( + r'--licenses option is no longer needed', + caseSensitive: false, +); class AndroidWorkflow implements Workflow { - AndroidWorkflow({required AndroidSdk? androidSdk, required FeatureFlags featureFlags}) - : _androidSdk = androidSdk, - _featureFlags = featureFlags; + AndroidWorkflow({required this._androidSdkBuilder, required this._featureFlags}); - final AndroidSdk? _androidSdk; + final AndroidSdk? Function() _androidSdkBuilder; + late final AndroidSdk? _androidSdk = _androidSdkBuilder(); final FeatureFlags _featureFlags; @override @@ -107,21 +111,14 @@ Future getEmulatorVersion(AndroidSdk androidSdk, ProcessManager process /// Android Studio. class AndroidValidator extends DoctorValidator { AndroidValidator({ - required Java? java, - required AndroidSdk? androidSdk, - required Logger logger, - required Platform platform, - required UserMessages userMessages, - required ProcessManager processManager, - required OperatingSystemUtils osUtils, - }) : _java = java, - _androidSdk = androidSdk, - _logger = logger, - _platform = platform, - _userMessages = userMessages, - _processManager = processManager, - _osUtils = osUtils, - super('Android toolchain - develop for Android devices'); + required this._java, + required this._androidSdk, + required this._logger, + required this._platform, + required this._userMessages, + required this._processManager, + required this._osUtils, + }) : super('Android toolchain - develop for Android devices'); final Java? _java; final AndroidSdk? _androidSdk; @@ -347,21 +344,14 @@ class AndroidValidator extends DoctorValidator { /// SDK have been accepted. class AndroidLicenseValidator extends DoctorValidator { AndroidLicenseValidator({ - required Java? java, - required AndroidSdk? androidSdk, - required Platform platform, - required ProcessManager processManager, - required Logger logger, - required Stdio stdio, - required UserMessages userMessages, - }) : _java = java, - _androidSdk = androidSdk, - _platform = platform, - _processManager = processManager, - _logger = logger, - _stdio = stdio, - _userMessages = userMessages, - super('Android license subvalidator'); + required this._java, + required this._androidSdk, + required this._platform, + required this._processManager, + required this._logger, + required this._stdio, + required this._userMessages, + }) : super('Android license subvalidator'); final Java? _java; final AndroidSdk? _androidSdk; @@ -445,6 +435,7 @@ class AndroidLicenseValidator extends DoctorValidator { Future get licensesAccepted async { LicensesAccepted? status; + var sawNewCliLicensesMessage = false; void handleLine(String line) { if (licenseCounts.hasMatch(line)) { @@ -461,6 +452,8 @@ class AndroidLicenseValidator extends DoctorValidator { status = LicensesAccepted.none; } else if (licenseAccepted.hasMatch(line)) { status ??= LicensesAccepted.all; + } else if (licensesNoLongerNeeded.hasMatch(line)) { + sawNewCliLicensesMessage = true; } } @@ -487,13 +480,37 @@ class AndroidLicenseValidator extends DoctorValidator { .listen(handleLine) .asFuture(); await Future.wait(>[output, errors]); - return status ?? LicensesAccepted.unknown; + if (status != null) { + return status!; + } + if (sawNewCliLicensesMessage) { + return _licensesAcceptedFromDisk(); + } + return LicensesAccepted.unknown; } on IOException catch (e) { _logger.printTrace('Failed to run Android sdk manager: $e'); return LicensesAccepted.unknown; } } + /// Fallback license check for Android cmdline-tools versions where + /// `sdkmanager --licenses` no longer prints a parseable status + /// (see https://github.com/flutter/flutter/issues/191487). + LicensesAccepted _licensesAcceptedFromDisk() { + if (_androidSdk == null || !_androidSdk.licensesAvailable) { + return LicensesAccepted.none; + } + final Directory licensesDir = _androidSdk.directory.childDirectory('licenses'); + try { + final bool hasAcceptedLicense = licensesDir.listSync().whereType().any( + (File file) => !file.basename.startsWith('.') && file.lengthSync() > 0, + ); + return hasAcceptedLicense ? LicensesAccepted.all : LicensesAccepted.none; + } on FileSystemException { + return LicensesAccepted.unknown; + } + } + /// Run the Android SDK manager tool in order to accept SDK licenses. Future runLicenseManager() async { if (_androidSdk == null) { diff --git a/packages/flutter_tools/lib/src/android/application_package.dart b/packages/flutter_tools/lib/src/android/application_package.dart index d94a2fd3..83a8b7c5 100644 --- a/packages/flutter_tools/lib/src/android/application_package.dart +++ b/packages/flutter_tools/lib/src/android/application_package.dart @@ -202,12 +202,16 @@ class AndroidApk extends ApplicationPackage implements PrebuiltApplicationPackag final String? packageId = manifests.first.getAttribute('package') ?? androidProject.namespace; String? launchActivity; - for (final XmlElement activity in document.findAllElements('activity')) { + final Iterable activities = document + .findAllElements('activity') + .followedBy(document.findAllElements('activity-alias')); + for (final activity in activities) { final String? enabled = activity.getAttribute('android:enabled'); if (enabled != null && enabled == 'false') { continue; } + var foundLauncher = false; for (final XmlElement element in activity.findElements('intent-filter')) { String? actionName = ''; String? categoryName = ''; @@ -227,10 +231,16 @@ class AndroidApk extends ApplicationPackage implements PrebuiltApplicationPackag actionName.isNotEmpty && categoryName.isNotEmpty) { final String? activityName = activity.getAttribute('android:name'); - launchActivity = '$packageId/$activityName'; - break; + if (packageId != null && activityName != null && activityName.isNotEmpty) { + launchActivity = '$packageId/$activityName'; + foundLauncher = true; + break; + } } } + if (foundLauncher) { + break; + } } if (packageId == null || launchActivity == null) { diff --git a/packages/flutter_tools/lib/src/android/build_validation.dart b/packages/flutter_tools/lib/src/android/build_validation.dart index 81f47269..31c5000d 100644 --- a/packages/flutter_tools/lib/src/android/build_validation.dart +++ b/packages/flutter_tools/lib/src/android/build_validation.dart @@ -6,7 +6,6 @@ import '../base/common.dart'; import '../build_info.dart'; const kGooglePlayVersioning = 'https://developer.android.com/studio/publish/versioning.html'; -const kSupportedAbis = 'https://flutter.dev/to/android-supported-architectures'; /// Validates that the build mode and build number are valid for a given build. void validateBuild(AndroidBuildInfo androidBuildInfo) { diff --git a/packages/flutter_tools/lib/src/android/deferred_components_gen_snapshot_validator.dart b/packages/flutter_tools/lib/src/android/deferred_components_gen_snapshot_validator.dart index c200d2fd..b15fb535 100644 --- a/packages/flutter_tools/lib/src/android/deferred_components_gen_snapshot_validator.dart +++ b/packages/flutter_tools/lib/src/android/deferred_components_gen_snapshot_validator.dart @@ -25,8 +25,12 @@ class DeferredComponentsGenSnapshotValidator extends DeferredComponentsValidator /// When [exitOnFail] is set to true, the [handleResults] and [attemptToolExit] /// methods will exit the tool when this validator detects a recommended /// change. This defaults to true. - DeferredComponentsGenSnapshotValidator(this.env, {super.exitOnFail = true, super.title}) - : super(env.projectDir, env.logger, env.platform); + DeferredComponentsGenSnapshotValidator( + this.env, { + super.exitOnFail = true, + super.title, + super.outputDir, + }) : super(env.projectDir, env.logger, env.platform); /// The build environment that should be used to find the input files to run /// checks against. diff --git a/packages/flutter_tools/lib/src/android/deferred_components_prebuild_validator.dart b/packages/flutter_tools/lib/src/android/deferred_components_prebuild_validator.dart index 9956bdfd..b37b287d 100644 --- a/packages/flutter_tools/lib/src/android/deferred_components_prebuild_validator.dart +++ b/packages/flutter_tools/lib/src/android/deferred_components_prebuild_validator.dart @@ -8,7 +8,8 @@ import '../base/deferred_component.dart'; import '../base/error_handling_io.dart'; import '../base/file_system.dart'; import '../base/logger.dart'; -import '../globals.dart' as globals; +import '../base/template.dart'; +import '../context/tool_context.dart'; import '../project.dart'; import '../template.dart'; import 'deferred_components_validator.dart'; @@ -22,22 +23,25 @@ import 'deferred_components_validator.dart'; class DeferredComponentsPrebuildValidator extends DeferredComponentsValidator { /// Constructs a validator instance. /// - /// The [templatesDir] parameter is optional. If null, the tool's default + /// The [_templatesDir] parameter is optional. If null, the tool's default /// templates directory will be used. /// /// When [exitOnFail] is set to true, the [handleResults] and [attemptToolExit] /// methods will exit the tool when this validator detects a recommended /// change. This defaults to true. DeferredComponentsPrebuildValidator( - super.projectDir, - super.logger, - super.platform, { + Directory projectDir, { + required this._templateRenderer, + required this._toolContext, super.exitOnFail, + super.outputDir, + this._templatesDir, super.title, - Directory? templatesDir, - }) : _templatesDir = templatesDir; + }) : super(projectDir, _toolContext.logger, _toolContext.platform); final Directory? _templatesDir; + final TemplateRenderer _templateRenderer; + final ToolContext _toolContext; /// Checks if an android dynamic feature module exists for each deferred /// component. @@ -62,8 +66,9 @@ class DeferredComponentsPrebuildValidator extends DeferredComponentsValidator { final androidFiles = _DeferredComponentAndroidFiles( name: component.name, projectDir: projectDir, - logger: logger, + templateRenderer: _templateRenderer, templatesDir: _templatesDir, + toolContext: _toolContext, ); if (!androidFiles.verifyFilesExist()) { // generate into temp directory @@ -201,10 +206,7 @@ class DeferredComponentsPrebuildValidator extends DeferredComponentsValidator { /// Deletes all files inside of the validator's output directory. void clearOutputDir() { - final Directory dir = projectDir - .childDirectory('build') - .childDirectory(DeferredComponentsValidator.kDeferredComponentsTempDirectory); - ErrorHandlingFileSystem.deleteIfExists(dir, recursive: true); + ErrorHandlingFileSystem.deleteIfExists(outputDir, recursive: true); } } @@ -214,15 +216,17 @@ class _DeferredComponentAndroidFiles { _DeferredComponentAndroidFiles({ required this.name, required this.projectDir, - required this.logger, - Directory? templatesDir, - }) : _templatesDir = templatesDir; + required this.templateRenderer, + required this.toolContext, + this._templatesDir, + }); // The name of the deferred component. final String name; final Directory projectDir; - final Logger logger; + final TemplateRenderer templateRenderer; final Directory? _templatesDir; + final ToolContext toolContext; Directory get androidDir => projectDir.childDirectory('android'); Directory get componentDir => androidDir.childDirectory(name); @@ -261,32 +265,34 @@ class _DeferredComponentAndroidFiles { // generates default build.gradle and AndroidManifest.xml for the deferred component. Future> _setupComponentFiles(Directory outputDir) async { + final ToolContext(:FileSystem fs, :Logger logger, :FlutterProjectFactory projectFactory) = + toolContext; Template template; - final Directory? templatesDir = _templatesDir; - if (templatesDir != null) { - final Directory templateComponentDir = templatesDir.childDirectory( - 'module${globals.fs.path.separator}android${globals.fs.path.separator}deferred_component', + if (_templatesDir != null) { + final Directory templateComponentDir = _templatesDir.childDirectory( + 'module${fs.path.separator}android${fs.path.separator}deferred_component', ); template = Template( templateComponentDir, templateComponentDir, - fileSystem: globals.fs, + fileSystem: fs, logger: logger, - templateRenderer: globals.templateRenderer, + templateRenderer: templateRenderer, ); } else { template = await Template.fromName( - 'module${globals.fs.path.separator}android${globals.fs.path.separator}deferred_component', - fileSystem: globals.fs, + 'module${fs.path.separator}android${fs.path.separator}deferred_component', + fileSystem: fs, templateManifest: null, logger: logger, - templateRenderer: globals.templateRenderer, + templateRenderer: templateRenderer, ); } + final FlutterProject flutterProject = projectFactory.fromDirectory(projectDir); final context = { 'androidIdentifier': - FlutterProject.current().manifest.androidPackage ?? - 'com.example.${FlutterProject.current().manifest.appName}', + flutterProject.manifest.androidPackage ?? + 'com.example.${flutterProject.manifest.appName}', 'componentName': name, }; diff --git a/packages/flutter_tools/lib/src/android/deferred_components_validator.dart b/packages/flutter_tools/lib/src/android/deferred_components_validator.dart index 636ab670..c28166ee 100644 --- a/packages/flutter_tools/lib/src/android/deferred_components_validator.dart +++ b/packages/flutter_tools/lib/src/android/deferred_components_validator.dart @@ -25,9 +25,10 @@ abstract class DeferredComponentsValidator { this.platform, { this.exitOnFail = true, String? title, - }) : outputDir = projectDir - .childDirectory('build') - .childDirectory(kDeferredComponentsTempDirectory), + Directory? outputDir, + }) : outputDir = + outputDir ?? + projectDir.childDirectory('build').childDirectory(kDeferredComponentsTempDirectory), inputs = [], outputs = [], title = title ?? 'Deferred components setup verification', diff --git a/packages/flutter_tools/lib/src/android/gradle.dart b/packages/flutter_tools/lib/src/android/gradle.dart index be7e0313..a8e9dbe5 100644 --- a/packages/flutter_tools/lib/src/android/gradle.dart +++ b/packages/flutter_tools/lib/src/android/gradle.dart @@ -28,6 +28,8 @@ import '../base/utils.dart'; import '../base/version.dart'; import '../build_info.dart'; import '../cache.dart'; +import '../context/android_context.dart'; +import '../context/tool_context.dart'; import '../convert.dart'; import '../flutter_manifest.dart'; import '../globals.dart' as globals; @@ -36,8 +38,8 @@ import 'android_builder.dart'; import 'android_sdk.dart'; import 'android_studio.dart'; import 'gradle_errors.dart'; -import 'gradle_utils.dart'; import 'gradle_utils.dart' as gradle; +import 'gradle_utils.dart'; import 'java.dart'; import 'migrations/android_studio_java_gradle_conflict_migration.dart'; import 'migrations/cmake_android_16k_pages_migration.dart'; @@ -163,37 +165,51 @@ const kMaxRetryTime = Duration(seconds: 10); /// An implementation of the [AndroidBuilder] that delegates to gradle. class AndroidGradleBuilder implements AndroidBuilder { AndroidGradleBuilder({ - required Java? java, - required Logger logger, - required ProcessManager processManager, + required this._analytics, + required AndroidStudio? androidStudio, + required this._artifacts, required FileSystem fileSystem, - required Artifacts artifacts, - required Analytics analytics, required GradleUtils gradleUtils, + required Java? java, + required Logger logger, required Platform platform, - required AndroidStudio? androidStudio, + required ProcessManager processManager, + required this._toolContext, AndroidSdk? androidSdk, - }) : _java = java, - _logger = logger, + }) : _androidContext = AndroidContext( + androidSdkBuilder: () => androidSdk, + androidStudioBuilder: () => androidStudio, + gradleUtilsBuilder: () => gradleUtils, + javaBuilder: () => java, + ), _fileSystem = fileSystem, - _artifacts = artifacts, - _analytics = analytics, - _gradleUtils = gradleUtils, - _androidStudio = androidStudio, - _androidSdk = androidSdk, + _logger = logger, _fileSystemUtils = FileSystemUtils(fileSystem: fileSystem, platform: platform), _processUtils = ProcessUtils(logger: logger, processManager: processManager); - final Java? _java; + AndroidGradleBuilder.fromContexts({ + required this._analytics, + required this._androidContext, + required ToolContext toolContext, + }) : _artifacts = toolContext.artifacts, + _fileSystem = toolContext.fs, + _logger = toolContext.logger, + _fileSystemUtils = toolContext.fileSystemUtils, + _processUtils = toolContext.processUtils, + _toolContext = toolContext; + + final Analytics _analytics; + final AndroidContext _androidContext; + late final Java? _java = _androidContext.java; final Logger _logger; - final ProcessUtils _processUtils; final FileSystem _fileSystem; final Artifacts _artifacts; - final Analytics _analytics; - final GradleUtils _gradleUtils; + late final GradleUtils _gradleUtils = _androidContext.gradleUtils; + late final AndroidStudio? _androidStudio = _androidContext.androidStudio; + late final AndroidSdk? _androidSdk = _androidContext.androidSdk; final FileSystemUtils _fileSystemUtils; - final AndroidStudio? _androidStudio; - final AndroidSdk? _androidSdk; + final ProcessUtils _processUtils; + final ToolContext _toolContext; /// Builds the AAR and POM files for the current Flutter module or plugin. @override @@ -353,7 +369,7 @@ class AndroidGradleBuilder implements AndroidBuilder { command, workingDirectory: project.android.hostAppGradleRoot.path, allowReentrantFlutter: true, - environment: _java?.environment, + environment: _java?.gradleEnvironment, mapFunction: consumeLog, ); } on ProcessException catch (exception) { @@ -375,8 +391,11 @@ class AndroidGradleBuilder implements AndroidBuilder { return exitCode; } final GradleBuildStatus status = await detectedGradleError!.handler( + gradleUtils: _gradleUtils, + java: _java, line: detectedGradleErrorLine!, project: project, + toolContext: _toolContext, usesAndroidX: usesAndroidX, ); @@ -443,8 +462,11 @@ class AndroidGradleBuilder implements AndroidBuilder { // succeed despite an unsupported Java/Gradle version pair. // 3. This also helps address https://github.com/flutter/flutter/issues/167931 // by providing actionable version recommendations directly in the error. - Future _checkJavaAndGradleCompatibility(FlutterProject project, BuildInfo buildInfo) async { - if (!buildInfo.androidSkipBuildDependencyValidation) { + Future _checkJavaAndGradleCompatibility( + FlutterProject project, + AndroidBuildInfo androidBuildInfo, + ) async { + if (!androidBuildInfo.buildInfo.androidGradleConfig.skipBuildDependencyValidation) { final Version? javaVersionObj = _java?.version; final String? javaVersion = javaVersionObj != null ? '${javaVersionObj.major}.${javaVersionObj.minor}.${javaVersionObj.patch}' @@ -526,7 +548,14 @@ To fix this, you can either: // The default Gradle script reads the version name and number // from the local.properties file. - updateLocalProperties(project: project, buildInfo: androidBuildInfo.buildInfo); + updateLocalProperties( + project: project, + analytics: _analytics, + androidSdk: _androidSdk, + buildInfo: androidBuildInfo.buildInfo, + fileSystemUtils: _fileSystemUtils, + logger: _logger, + ); final options = []; @@ -550,10 +579,10 @@ To fix this, you can either: } else { options.add('-q'); } - if (!buildInfo.androidGradleDaemon) { + if (!buildInfo.androidGradleConfig.gradleDaemon) { options.add('--no-daemon'); } - if (buildInfo.androidSkipBuildDependencyValidation) { + if (buildInfo.androidGradleConfig.skipBuildDependencyValidation) { options.add('-PskipDependencyChecks=true'); } final LocalEngineInfo? localEngineInfo = _artifacts.localEngineInfo; @@ -580,6 +609,14 @@ To fix this, you can either: .join(','); options.add('-Ptarget-platform=$targetPlatforms'); } + if (androidBuildInfo.releaseManifestEngineShellArgs != null) { + // Base64-encode the JSON string to prevent shell or Gradle argument parser + // from splitting or stripping double quotes and spaces across operating systems. + final String base64JsonArgs = base64Encode( + utf8.encode(jsonEncode(androidBuildInfo.releaseManifestEngineShellArgs)), + ); + options.add('-Pflutter.engineShellArgs=$base64JsonArgs'); + } options.add('-Ptarget=$target'); // If using v1 embedding, we want to use FlutterApplication as the base app. final baseApplicationName = project.android.getEmbeddingVersion() == AndroidEmbeddingVersion.v2 @@ -613,16 +650,13 @@ To fix this, you can either: options.add('-Pshrink=false'); } } - options.addAll(androidBuildInfo.buildInfo.toGradleConfig()); + options.addAll(androidBuildInfo.toGradleConfig()); if (buildInfo.fileSystemRoots.isNotEmpty) { options.add('-Pfilesystem-roots=${buildInfo.fileSystemRoots.join('|')}'); } if (buildInfo.fileSystemScheme != null) { options.add('-Pfilesystem-scheme=${buildInfo.fileSystemScheme}'); } - if (androidBuildInfo.splitPerAbi) { - options.add('-Psplit-per-abi=true'); - } options.addAll(_getAndroidNdkProvisioningProperties()); late Stopwatch sw; @@ -649,7 +683,7 @@ To fix this, you can either: ); if (exitCode != 0) { - await _checkJavaAndGradleCompatibility(project, androidBuildInfo.buildInfo); + await _checkJavaAndGradleCompatibility(project, androidBuildInfo); throwToolExit( 'Gradle task $assembleTask failed with exit code $exitCode', exitCode: exitCode, @@ -737,13 +771,14 @@ To fix this, you can either: ); return false; } - if (!_androidSdk.cmdlineToolsAvailable) { + final AndroidSdk androidSdk = _androidSdk; + if (!androidSdk.cmdlineToolsAvailable) { _logger.printTrace( 'Failed to find cmdline-tools when checking final appbundle for debug symbols.', ); return false; } - final String? apkAnalyzerPath = _androidSdk.getCmdlineToolsPath(apkAnalyzerBinaryName); + final String? apkAnalyzerPath = androidSdk.getCmdlineToolsPath(apkAnalyzerBinaryName); if (apkAnalyzerPath == null) { _logger.printTrace( 'Failed to find apkanalyzer when checking final appbundle for debug symbols.', @@ -871,14 +906,14 @@ To fix this, you can either: } else { command.add('-q'); } - if (!buildInfo.androidGradleDaemon) { + if (!buildInfo.androidGradleConfig.gradleDaemon) { command.add('--no-daemon'); } if (target.isNotEmpty) { command.add('-Ptarget=$target'); } - command.addAll(androidBuildInfo.buildInfo.toGradleConfig()); + command.addAll(androidBuildInfo.toGradleConfig()); command.addAll(_getAndroidNdkProvisioningProperties()); if (buildInfo.dartObfuscation && buildInfo.mode != BuildMode.release) { _logger.printStatus( @@ -931,7 +966,7 @@ To fix this, you can either: command, workingDirectory: project.android.hostAppGradleRoot.path, allowReentrantFlutter: true, - environment: _java?.environment, + environment: _java?.gradleEnvironment, ); } finally { status.stop(); @@ -948,7 +983,7 @@ To fix this, you can either: if (result.exitCode != 0) { _logger.printStatus(result.stdout, wrap: false); _logger.printError(result.stderr, wrap: false); - await _checkJavaAndGradleCompatibility(project, androidBuildInfo.buildInfo); + await _checkJavaAndGradleCompatibility(project, androidBuildInfo); throwToolExit( 'Gradle task $aarTask failed with exit code ${result.exitCode}.', exitCode: result.exitCode, diff --git a/packages/flutter_tools/lib/src/android/gradle_errors.dart b/packages/flutter_tools/lib/src/android/gradle_errors.dart index f58462d5..7771ba0f 100644 --- a/packages/flutter_tools/lib/src/android/gradle_errors.dart +++ b/packages/flutter_tools/lib/src/android/gradle_errors.dart @@ -4,16 +4,29 @@ import 'package:meta/meta.dart'; +import '../base/bot_detector.dart'; import '../base/error_handling_io.dart'; import '../base/file_system.dart'; +import '../base/logger.dart'; +import '../base/platform.dart'; import '../base/process.dart'; import '../base/terminal.dart'; -import '../globals.dart' as globals; +import '../context/tool_context.dart'; import '../project.dart'; import 'gradle_utils.dart' as utils; +import 'java.dart'; typedef GradleErrorTest = bool Function(String); +typedef GradleErrorHandler = Future Function({ + required utils.GradleUtils gradleUtils, + required String line, + required FlutterProject project, + required ToolContext toolContext, + required bool usesAndroidX, + Java? java, +}); + /// A Gradle error handled by the tool. class GradleHandledError { const GradleHandledError({required this.test, required this.handler, this.eventLabel}); @@ -24,12 +37,7 @@ class GradleHandledError { final GradleErrorTest test; /// The handler function. - final Future Function({ - required String line, - required FlutterProject project, - required bool usesAndroidX, - }) - handler; + final GradleErrorHandler handler; /// The build event label is named gradle-[eventLabel]. /// If not empty, the build event is logged along with @@ -96,9 +104,17 @@ const _boxTitle = 'Flutter Fix'; final permissionDeniedErrorHandler = GradleHandledError( test: _lineMatcher(const ['Permission denied']), handler: - ({required String line, required FlutterProject project, required bool usesAndroidX}) async { - globals.printBox( - '${globals.logger.terminal.warningMark} Gradle does not have execution permission.\n' + ({ + required utils.GradleUtils gradleUtils, + required String line, + required FlutterProject project, + required ToolContext toolContext, + required bool usesAndroidX, + Java? java, + }) async { + final Logger logger = toolContext.logger; + logger.printBox( + '${logger.terminal.warningMark} Gradle does not have execution permission.\n' 'You should change the ownership of the project directory to your user, ' 'or move the project to a directory with execute permissions.', title: _boxTitle, @@ -125,9 +141,17 @@ final networkErrorHandler = GradleHandledError( 'javax.net.ssl.SSLHandshakeException: Remote host closed connection during handshake', ]), handler: - ({required String line, required FlutterProject project, required bool usesAndroidX}) async { - globals.printError( - '${globals.logger.terminal.warningMark} ' + ({ + required utils.GradleUtils gradleUtils, + required String line, + required FlutterProject project, + required ToolContext toolContext, + required bool usesAndroidX, + Java? java, + }) async { + final Logger logger = toolContext.logger; + logger.printError( + '${logger.terminal.warningMark} ' 'Gradle threw an error while downloading artifacts from the network.', ); return GradleBuildStatus.retry; @@ -150,41 +174,52 @@ final networkErrorHandler = GradleHandledError( final zipExceptionHandler = GradleHandledError( test: _lineMatcher(const ['java.util.zip.ZipException: error in opening zip file']), handler: - ({required String line, required FlutterProject project, required bool usesAndroidX}) async { - globals.printError( - '${globals.logger.terminal.warningMark} ' + ({ + required utils.GradleUtils gradleUtils, + required String line, + required FlutterProject project, + required ToolContext toolContext, + required bool usesAndroidX, + Java? java, + }) async { + final ToolContext( + :BotDetector botDetector, + :FileSystem fs, + :Logger logger, + :Platform platform, + ) = toolContext; + logger.printError( + '${logger.terminal.warningMark} ' 'Your .gradle directory under the home directory might be corrupted.', ); - bool shouldDeleteUserGradle = await globals.botDetector.isRunningOnBot; - if (!shouldDeleteUserGradle && globals.terminal.stdinHasTerminal) { + bool shouldDeleteUserGradle = await botDetector.isRunningOnBot; + if (!shouldDeleteUserGradle && logger.terminal.stdinHasTerminal) { try { - final String selection = await globals.terminal.promptForCharInput( + final String selection = await logger.terminal.promptForCharInput( ['y', 'n'], - logger: globals.logger, + logger: logger, prompt: 'Do you want to delete the .gradle directory under the home directory?', defaultChoiceIndex: 0, ); shouldDeleteUserGradle = selection == 'y'; } on StateError catch (e) { - globals.printError(e.message, indent: 0); + logger.printError(e.message, indent: 0); } } if (shouldDeleteUserGradle) { - final String? homeDir = globals.platform.environment['HOME']; + final String? homeDir = platform.environment['HOME']; if (homeDir == null) { - globals.logger.printStatus( + logger.printStatus( "Could not delete .gradle directory because there isn't a HOME env variable", ); return GradleBuildStatus.retry; } - final Directory userGradle = globals.fs.directory( - globals.fs.path.join(homeDir, '.gradle'), - ); - globals.logger.printStatus('Deleting ${userGradle.path}'); + final Directory userGradle = fs.directory(fs.path.join(homeDir, '.gradle')); + logger.printStatus('Deleting ${userGradle.path}'); try { ErrorHandlingFileSystem.deleteIfExists(userGradle, recursive: true); } on FileSystemException catch (err) { - globals.printTrace('Failed to delete Gradle cache: $err'); + logger.printTrace('Failed to delete Gradle cache: $err'); } } return GradleBuildStatus.retry; @@ -200,22 +235,31 @@ final licenseNotAcceptedHandler = GradleHandledError( test: _lineMatcher(const [ 'You have not accepted the license agreements of the following SDK components', ]), - handler: ({required String line, required FlutterProject project, required bool usesAndroidX}) async { - const licenseNotAcceptedMatcher = - r'You have not accepted the license agreements of the following SDK components:\s*\[(.+)\]'; - - final licenseFailure = RegExp(licenseNotAcceptedMatcher, multiLine: true); - final Match? licenseMatch = licenseFailure.firstMatch(line); - globals.printBox( - '${globals.logger.terminal.warningMark} Unable to download needed Android SDK components, as the ' - 'following licenses have not been accepted: ' - '${licenseMatch?.group(1)}\n\n' - 'To resolve this, please run the following command in a Terminal:\n' - 'flutter doctor --android-licenses', - title: _boxTitle, - ); - return GradleBuildStatus.exit; - }, + handler: + ({ + required utils.GradleUtils gradleUtils, + required String line, + required FlutterProject project, + required ToolContext toolContext, + required bool usesAndroidX, + Java? java, + }) async { + final Logger logger = toolContext.logger; + const licenseNotAcceptedMatcher = + r'You have not accepted the license agreements of the following SDK components:\s*\[(.+)\]'; + + final licenseFailure = RegExp(licenseNotAcceptedMatcher, multiLine: true); + final Match? licenseMatch = licenseFailure.firstMatch(line); + logger.printBox( + '${logger.terminal.warningMark} Unable to download needed Android SDK components, as the ' + 'following licenses have not been accepted: ' + '${licenseMatch?.group(1)}\n\n' + 'To resolve this, please run the following command in a Terminal:\n' + 'flutter doctor --android-licenses', + title: _boxTitle, + ); + return GradleBuildStatus.exit; + }, eventLabel: 'license-not-accepted', ); @@ -229,57 +273,66 @@ final flavorUndefinedHandler = GradleHandledError( test: (String line) { return _undefinedTaskPattern.hasMatch(line); }, - handler: ({required String line, required FlutterProject project, required bool usesAndroidX}) async { - final RunResult tasksRunResult = await globals.processUtils.run( - [globals.gradleUtils!.getExecutable(project), 'app:tasks', '--all', '--console=auto'], - throwOnError: true, - workingDirectory: project.android.hostAppGradleRoot.path, - environment: globals.java?.environment, - ); - // Extract build types and product flavors. - final variants = {}; - for (final String task in tasksRunResult.stdout.split('\n')) { - final Match? match = _assembleTaskPattern.matchAsPrefix(task); - if (match != null) { - final String variant = match.group(1)!.toLowerCase(); - if (!variant.endsWith('test')) { - variants.add(variant); + handler: + ({ + required utils.GradleUtils gradleUtils, + required String line, + required FlutterProject project, + required ToolContext toolContext, + required bool usesAndroidX, + Java? java, + }) async { + final ToolContext(:Logger logger, :ProcessUtils processUtils) = toolContext; + final productFlavors = {}; + final RunResult tasksRunResult = await processUtils.run( + [gradleUtils.getExecutable(project), 'app:tasks', '--all', '--console=auto'], + throwOnError: true, + workingDirectory: project.android.hostAppGradleRoot.path, + environment: java?.gradleEnvironment, + ); + // Extract build types and product flavors. + final variants = {}; + for (final String task in tasksRunResult.stdout.split('\n')) { + final Match? match = _assembleTaskPattern.matchAsPrefix(task); + if (match != null) { + final String variant = match.group(1)!.toLowerCase(); + if (!variant.endsWith('test')) { + variants.add(variant); + } + } } - } - } - final productFlavors = {}; - for (final variant1 in variants) { - for (final variant2 in variants) { - if (variant2.startsWith(variant1) && variant2 != variant1) { - final String buildType = variant2.substring(variant1.length); - if (variants.contains(buildType)) { - productFlavors.add(variant1); + for (final variant1 in variants) { + for (final variant2 in variants) { + if (variant2.startsWith(variant1) && variant2 != variant1) { + final String buildType = variant2.substring(variant1.length); + if (variants.contains(buildType)) { + productFlavors.add(variant1); + } + } } } - } - } - final errorMessage = - '${globals.logger.terminal.warningMark} Gradle project does not define a task suitable for the requested build.'; - final File buildGradle = project.android.appGradleFile; - if (productFlavors.isEmpty) { - globals.printBox( - '$errorMessage\n\n' - 'The ${buildGradle.absolute.path} file does not define ' - 'any custom product flavors. ' - 'You cannot use the --flavor option.', - title: _boxTitle, - ); - } else { - globals.printBox( - '$errorMessage\n\n' - 'The ${buildGradle.absolute.path} file defines product ' - 'flavors: ${productFlavors.join(', ')}. ' - 'You must specify a --flavor option to select one of them.', - title: _boxTitle, - ); - } - return GradleBuildStatus.exit; - }, + final errorMessage = + '${logger.terminal.warningMark} Gradle project does not define a task suitable for the requested build.'; + final File buildGradle = project.android.appGradleFile; + if (productFlavors.isEmpty) { + logger.printBox( + '$errorMessage\n\n' + 'The ${buildGradle.absolute.path} file does not define ' + 'any custom product flavors. ' + 'You cannot use the --flavor option.', + title: _boxTitle, + ); + } else { + logger.printBox( + '$errorMessage\n\n' + 'The ${buildGradle.absolute.path} file defines product ' + 'flavors: ${productFlavors.join(', ')}. ' + 'You must specify a --flavor option to select one of them.', + title: _boxTitle, + ); + } + return GradleBuildStatus.exit; + }, eventLabel: 'flavor-undefined', ); @@ -293,29 +346,38 @@ final minSdkVersionHandler = GradleHandledError( test: (String line) { return _minSdkVersionPattern.hasMatch(line); }, - handler: ({required String line, required FlutterProject project, required bool usesAndroidX}) async { - final File gradleFile = project.android.appGradleFile; - final Match? minSdkVersionMatch = _minSdkVersionPattern.firstMatch(line); - assert(minSdkVersionMatch?.groupCount == 3); - - final String textInBold = globals.logger.terminal.bolden( - 'Fix this issue by adding the following to the file ${gradleFile.path}:\n' - 'android {\n' - ' defaultConfig {\n' - ' minSdkVersion ${minSdkVersionMatch?.group(2)}\n' - ' }\n' - '}\n', - ); - globals.printBox( - 'The plugin ${minSdkVersionMatch?.group(3)} requires a higher Android SDK version.\n' - '$textInBold\n' - 'Following this change, your app will not be available to users running Android SDKs below ${minSdkVersionMatch?.group(2)}.\n' - 'Consider searching for a version of this plugin that supports these lower versions of the Android SDK instead.\n' - 'For more information, see: https://flutter.dev/to/review-gradle-config', - title: _boxTitle, - ); - return GradleBuildStatus.exit; - }, + handler: + ({ + required utils.GradleUtils gradleUtils, + required String line, + required FlutterProject project, + required ToolContext toolContext, + required bool usesAndroidX, + Java? java, + }) async { + final Logger logger = toolContext.logger; + final File gradleFile = project.android.appGradleFile; + final Match? minSdkVersionMatch = _minSdkVersionPattern.firstMatch(line); + assert(minSdkVersionMatch?.groupCount == 3); + + final String textInBold = logger.terminal.bolden( + 'Fix this issue by adding the following to the file ${gradleFile.path}:\n' + 'android {\n' + ' defaultConfig {\n' + ' minSdkVersion ${minSdkVersionMatch?.group(2)}\n' + ' }\n' + '}\n', + ); + logger.printBox( + 'The plugin ${minSdkVersionMatch?.group(3)} requires a higher Android SDK version.\n' + '$textInBold\n' + 'Following this change, your app will not be available to users running Android SDKs below ${minSdkVersionMatch?.group(2)}.\n' + 'Consider searching for a version of this plugin that supports these lower versions of the Android SDK instead.\n' + 'For more information, see: https://flutter.dev/to/review-gradle-config', + title: _boxTitle, + ); + return GradleBuildStatus.exit; + }, eventLabel: 'plugin-min-sdk', ); @@ -327,9 +389,17 @@ final transformInputIssueHandler = GradleHandledError( return line.contains('https://issuetracker.google.com/issues/158753935'); }, handler: - ({required String line, required FlutterProject project, required bool usesAndroidX}) async { + ({ + required utils.GradleUtils gradleUtils, + required String line, + required FlutterProject project, + required ToolContext toolContext, + required bool usesAndroidX, + Java? java, + }) async { + final Logger logger = toolContext.logger; final File gradleFile = project.android.appGradleFile; - final String textInBold = globals.logger.terminal.bolden( + final String textInBold = logger.terminal.bolden( 'Fix this issue by adding the following to the file ${gradleFile.path}:\n' 'android {\n' ' lintOptions {\n' @@ -337,7 +407,7 @@ final transformInputIssueHandler = GradleHandledError( ' }\n' '}', ); - globals.printBox( + logger.printBox( 'This issue appears to be https://github.com/flutter/flutter/issues/58247.\n' '$textInBold', title: _boxTitle, @@ -352,13 +422,21 @@ final transformInputIssueHandler = GradleHandledError( final javaHeapSpaceHandler = GradleHandledError( test: _lineMatcher(const ['Java heap space']), handler: - ({required String line, required FlutterProject project, required bool usesAndroidX}) async { - final String textInBold = globals.logger.terminal.bolden( + ({ + required utils.GradleUtils gradleUtils, + required String line, + required FlutterProject project, + required ToolContext toolContext, + required bool usesAndroidX, + Java? java, + }) async { + final Logger logger = toolContext.logger; + final String textInBold = logger.terminal.bolden( 'Adjust the maximum Java heap allocation according to the documentation:\n' 'https://docs.gradle.org/current/userguide/config_gradle.html#sec:configuring_jvm_memory', ); - globals.printBox( - '${globals.logger.terminal.warningMark} The Gradle build ran out of Java heap space.\n' + logger.printBox( + '${logger.terminal.warningMark} The Gradle build ran out of Java heap space.\n' '$textInBold', title: _boxTitle, ); @@ -373,20 +451,29 @@ final lockFileDepMissingHandler = GradleHandledError( test: (String line) { return line.contains('which is not part of the dependency lock state'); }, - handler: ({required String line, required FlutterProject project, required bool usesAndroidX}) async { - final File gradleFile = project.android.hostAppGradleFile; - final generatedGradleCommand = globals.platform.isWindows ? r'.\gradlew.bat' : './gradlew'; - final String textInBold = globals.logger.terminal.bolden( - 'To regenerate the lockfiles run: `$generatedGradleCommand :generateLockfiles` in ${gradleFile.path}\n' - 'To remove dependency locking, remove the `dependencyLocking` from ${gradleFile.path}', - ); - globals.printBox( - 'You need to update the lockfile, or disable Gradle dependency locking.\n' - '$textInBold', - title: _boxTitle, - ); - return GradleBuildStatus.exit; - }, + handler: + ({ + required utils.GradleUtils gradleUtils, + required String line, + required FlutterProject project, + required ToolContext toolContext, + required bool usesAndroidX, + Java? java, + }) async { + final ToolContext(:Logger logger, :Platform platform) = toolContext; + final File gradleFile = project.android.hostAppGradleFile; + final generatedGradleCommand = platform.isWindows ? r'.\gradlew.bat' : './gradlew'; + final String textInBold = logger.terminal.bolden( + 'To regenerate the lockfiles run: `$generatedGradleCommand :generateLockfiles` in ${gradleFile.path}\n' + 'To remove dependency locking, remove the `dependencyLocking` from ${gradleFile.path}', + ); + logger.printBox( + 'You need to update the lockfile, or disable Gradle dependency locking.\n' + '$textInBold', + title: _boxTitle, + ); + return GradleBuildStatus.exit; + }, eventLabel: 'lock-dep-issue', ); @@ -394,23 +481,32 @@ final lockFileDepMissingHandler = GradleHandledError( // to be the lowest priority error. final incompatibleKotlinVersionHandler = GradleHandledError( test: _lineMatcher(const ['was compiled with an incompatible version of Kotlin']), - handler: ({required String line, required FlutterProject project, required bool usesAndroidX}) async { - final File gradleFile = project.android.hostAppGradleFile; - final File settingsFile = project.directory - .childDirectory('android') - .childFile('settings.gradle'); - globals.printBox( - '${globals.logger.terminal.warningMark} Your project requires a newer version of the Kotlin Gradle plugin.\n' - 'Find the latest version on https://kotlinlang.org/docs/releases.html#release-details, then update the \n' - 'version number of the plugin with id "org.jetbrains.kotlin.android" in the plugins block of \n' - '${settingsFile.path}.\n\n' - 'Alternatively (if your project was created before Flutter 3.19), update \n' - '${gradleFile.path}\n' - "ext.kotlin_version = ''", - title: _boxTitle, - ); - return GradleBuildStatus.exit; - }, + handler: + ({ + required utils.GradleUtils gradleUtils, + required String line, + required FlutterProject project, + required ToolContext toolContext, + required bool usesAndroidX, + Java? java, + }) async { + final Logger logger = toolContext.logger; + final File gradleFile = project.android.hostAppGradleFile; + final File settingsFile = project.directory + .childDirectory('android') + .childFile('settings.gradle'); + logger.printBox( + '${logger.terminal.warningMark} Your project requires a newer version of the Kotlin Gradle plugin.\n' + 'Find the latest version on https://kotlinlang.org/docs/releases.html#release-details, then update the \n' + 'version number of the plugin with id "org.jetbrains.kotlin.android" in the plugins block of \n' + '${settingsFile.path}.\n\n' + 'Alternatively (if your project was created before Flutter 3.19), update \n' + '${gradleFile.path}\n' + "ext.kotlin_version = ''", + title: _boxTitle, + ); + return GradleBuildStatus.exit; + }, eventLabel: 'incompatible-kotlin-version', ); @@ -421,22 +517,31 @@ final _outdatedGradlePattern = RegExp( @visibleForTesting final outdatedGradleHandler = GradleHandledError( test: _outdatedGradlePattern.hasMatch, - handler: ({required String line, required FlutterProject project, required bool usesAndroidX}) async { - final File gradleFile = project.android.hostAppGradleFile; - final File gradlePropertiesFile = project.android.gradleWrapperPropertiesFile; - globals.printBox( - '${globals.logger.terminal.warningMark} Your project needs to upgrade Gradle and the Android Gradle plugin.\n\n' - 'To fix this issue, replace the following content:\n' - '${gradleFile.path}:\n' - ' ${globals.terminal.color("- classpath 'com.android.tools.build:gradle:'", TerminalColor.red)}\n' - ' ${globals.terminal.color("+ classpath 'com.android.tools.build:gradle:${utils.templateAndroidGradlePluginVersion}'", TerminalColor.green)}\n' - '${gradlePropertiesFile.path}:\n' - ' ${globals.terminal.color('- https://services.gradle.org/distributions/gradle--all.zip', TerminalColor.red)}\n' - ' ${globals.terminal.color('+ https://services.gradle.org/distributions/gradle-${utils.templateDefaultGradleVersion}-all.zip', TerminalColor.green)}', - title: _boxTitle, - ); - return GradleBuildStatus.exit; - }, + handler: + ({ + required utils.GradleUtils gradleUtils, + required String line, + required FlutterProject project, + required ToolContext toolContext, + required bool usesAndroidX, + Java? java, + }) async { + final Logger logger = toolContext.logger; + final File gradleFile = project.android.hostAppGradleFile; + final File gradlePropertiesFile = project.android.gradleWrapperPropertiesFile; + logger.printBox( + '${logger.terminal.warningMark} Your project needs to upgrade Gradle and the Android Gradle plugin.\n\n' + 'To fix this issue, replace the following content:\n' + '${gradleFile.path}:\n' + ' ${logger.terminal.color("- classpath 'com.android.tools.build:gradle:'", TerminalColor.red)}\n' + ' ${logger.terminal.color("+ classpath 'com.android.tools.build:gradle:${utils.templateAndroidGradlePluginVersion}'", TerminalColor.green)}\n' + '${gradlePropertiesFile.path}:\n' + ' ${logger.terminal.color('- https://services.gradle.org/distributions/gradle--all.zip', TerminalColor.red)}\n' + ' ${logger.terminal.color('+ https://services.gradle.org/distributions/gradle-${utils.templateDefaultGradleVersion}-all.zip', TerminalColor.green)}', + title: _boxTitle, + ); + return GradleBuildStatus.exit; + }, eventLabel: 'outdated-gradle-version', ); @@ -446,13 +551,21 @@ final _minCompileSdkVersionPattern = RegExp(r'The minCompileSdk \(([0-9]+)\) spe final minCompileSdkVersionHandler = GradleHandledError( test: _minCompileSdkVersionPattern.hasMatch, handler: - ({required String line, required FlutterProject project, required bool usesAndroidX}) async { + ({ + required utils.GradleUtils gradleUtils, + required String line, + required FlutterProject project, + required ToolContext toolContext, + required bool usesAndroidX, + Java? java, + }) async { + final Logger logger = toolContext.logger; final Match? minCompileSdkVersionMatch = _minCompileSdkVersionPattern.firstMatch(line); assert(minCompileSdkVersionMatch?.groupCount == 1); final File gradleFile = project.android.appGradleFile; - globals.printBox( - '${globals.logger.terminal.warningMark} Your project requires a higher compileSdk version.\n' + logger.printBox( + '${logger.terminal.warningMark} Your project requires a higher compileSdk version.\n' 'Fix this issue by bumping the compileSdk version in ${gradleFile.path}:\n' 'android {\n' ' compileSdk ${minCompileSdkVersionMatch?.group(1)}\n' @@ -478,19 +591,28 @@ final incompatibleJavaAndAgpVersionsHandler = GradleHandledError( test: (String line) { return _agpJavaError.hasMatch(line); }, - handler: ({required String line, required FlutterProject project, required bool usesAndroidX}) async { - final String helpfulGradleError = line.trim().substring(2); - - globals.printBox( - '${globals.logger.terminal.warningMark} $helpfulGradleError\n\n' - 'To fix this issue, try updating to the latest Android SDK and Android Studio on: ${AndroidProject.installAndroidStudioUrl}\n' - 'If that does not work, you can set the Java version used by Flutter by \n' - 'running `flutter config --jdk-dir=““`\n\n' - 'To check the Java version used by Flutter, run `flutter doctor --verbose`', - title: _boxTitle, - ); - return GradleBuildStatus.exit; - }, + handler: + ({ + required utils.GradleUtils gradleUtils, + required String line, + required FlutterProject project, + required ToolContext toolContext, + required bool usesAndroidX, + Java? java, + }) async { + final Logger logger = toolContext.logger; + final String helpfulGradleError = line.trim().substring(2); + + logger.printBox( + '${logger.terminal.warningMark} $helpfulGradleError\n\n' + 'To fix this issue, try updating to the latest Android SDK and Android Studio on: ${AndroidProject.installAndroidStudioUrl}\n' + 'If that does not work, you can set the Java version used by Flutter by \n' + 'running `flutter config --jdk-dir=““`\n\n' + 'To check the Java version used by Flutter, run `flutter doctor --verbose`', + title: _boxTitle, + ); + return GradleBuildStatus.exit; + }, eventLabel: 'incompatible-java-agp-version', ); @@ -502,9 +624,17 @@ final sslExceptionHandler = GradleHandledError( 'javax.crypto.AEADBadTagException: Tag mismatch!', ]), handler: - ({required String line, required FlutterProject project, required bool usesAndroidX}) async { - globals.printError( - '${globals.logger.terminal.warningMark} ' + ({ + required utils.GradleUtils gradleUtils, + required String line, + required FlutterProject project, + required ToolContext toolContext, + required bool usesAndroidX, + Java? java, + }) async { + final Logger logger = toolContext.logger; + logger.printError( + '${logger.terminal.warningMark} ' 'Gradle threw an error while downloading artifacts from the network.', ); return GradleBuildStatus.retry; @@ -525,10 +655,18 @@ final incompatibleJavaAndGradleVersionsHandler = GradleHandledError( return _unsupportedClassFileMajorVersionPattern.hasMatch(line); }, handler: - ({required String line, required FlutterProject project, required bool usesAndroidX}) async { + ({ + required utils.GradleUtils gradleUtils, + required String line, + required FlutterProject project, + required ToolContext toolContext, + required bool usesAndroidX, + Java? java, + }) async { + final Logger logger = toolContext.logger; final File gradlePropertiesFile = project.android.gradleWrapperPropertiesFile; - globals.printBox( - "${globals.logger.terminal.warningMark} Your project's Gradle version " + logger.printBox( + "${logger.terminal.warningMark} Your project's Gradle version " 'is incompatible with the Java version that Flutter is using for Gradle.\n\n' 'To fix this issue, first, check the Java version used by Flutter by ' 'running `flutter doctor --verbose`.\n\n' @@ -547,9 +685,17 @@ final incompatibleJavaAndGradleVersionsHandler = GradleHandledError( final remoteTerminatedHandshakeHandler = GradleHandledError( test: (String line) => line.contains('Remote host terminated the handshake'), handler: - ({required String line, required FlutterProject project, required bool usesAndroidX}) async { - globals.printError( - '${globals.logger.terminal.warningMark} ' + ({ + required utils.GradleUtils gradleUtils, + required String line, + required FlutterProject project, + required ToolContext toolContext, + required bool usesAndroidX, + Java? java, + }) async { + final Logger logger = toolContext.logger; + logger.printError( + '${logger.terminal.warningMark} ' 'Gradle threw an error while downloading artifacts from the network.', ); @@ -562,9 +708,17 @@ final remoteTerminatedHandshakeHandler = GradleHandledError( final couldNotOpenCacheDirectoryHandler = GradleHandledError( test: (String line) => line.contains('> Could not open cache directory '), handler: - ({required String line, required FlutterProject project, required bool usesAndroidX}) async { - globals.printError( - '${globals.logger.terminal.warningMark} ' + ({ + required utils.GradleUtils gradleUtils, + required String line, + required FlutterProject project, + required ToolContext toolContext, + required bool usesAndroidX, + Java? java, + }) async { + final Logger logger = toolContext.logger; + logger.printError( + '${logger.terminal.warningMark} ' 'Gradle threw an error while resolving dependencies.', ); @@ -588,16 +742,25 @@ as the number following "com.android.tools.build:gradle:".'''; final incompatibleCompileSdk35AndAgpVersionHandler = GradleHandledError( test: (String line) => line.contains('RES_TABLE_TYPE_TYPE entry offsets overlap actual entry data'), - handler: ({required String line, required FlutterProject project, required bool usesAndroidX}) async { - globals.printBox( - '${globals.logger.terminal.warningMark} Using compileSdk 35 requires Android Gradle Plugin (AGP) 8.1.0 or higher.' - ' \n Please upgrade to a newer AGP version.${_getAgpLocation(project)}\n\n Finally, if you have a' - ' strong reason to avoid upgrading AGP, you can temporarily lower the compileSdk version in the following file:\n${project.android.appGradleFile.path}', - title: _boxTitle, - ); + handler: + ({ + required utils.GradleUtils gradleUtils, + required String line, + required FlutterProject project, + required ToolContext toolContext, + required bool usesAndroidX, + Java? java, + }) async { + final Logger logger = toolContext.logger; + logger.printBox( + '${logger.terminal.warningMark} Using compileSdk 35 requires Android Gradle Plugin (AGP) 8.1.0 or higher.' + ' \n Please upgrade to a newer AGP version.${_getAgpLocation(project)}\n\n Finally, if you have a' + ' strong reason to avoid upgrading AGP, you can temporarily lower the compileSdk version in the following file:\n${project.android.appGradleFile.path}', + title: _boxTitle, + ); - return GradleBuildStatus.exit; - }, + return GradleBuildStatus.exit; + }, eventLabel: 'incompatible-compile-sdk-and-agp', ); @@ -607,9 +770,17 @@ final r8DexingBugInAgp73Handler = GradleHandledError( line.contains('com.android.tools.r8.internal') && line.contains(': Unused argument with users'), handler: - ({required String line, required FlutterProject project, required bool usesAndroidX}) async { - globals.printBox(''' -${globals.logger.terminal.warningMark} Version 7.3 of the Android Gradle Plugin (AGP) uses a version of R8 that contains a bug which causes this error (see more info at https://issuetracker.google.com/issues/242308990). + ({ + required utils.GradleUtils gradleUtils, + required String line, + required FlutterProject project, + required ToolContext toolContext, + required bool usesAndroidX, + Java? java, + }) async { + final Logger logger = toolContext.logger; + logger.printBox(''' +${logger.terminal.warningMark} Version 7.3 of the Android Gradle Plugin (AGP) uses a version of R8 that contains a bug which causes this error (see more info at https://issuetracker.google.com/issues/242308990). To fix this error, update to a newer version of AGP (at least 7.4.0). ${_getAgpLocation(project)}''', title: _boxTitle); @@ -626,10 +797,18 @@ final usageOfV1EmbeddingReferencesHandler = GradleHandledError( test: (String line) => line.contains('io.flutter.plugin.common.PluginRegistry.Registrar registrar'), handler: - ({required String line, required FlutterProject project, required bool usesAndroidX}) async { - globals.printBox( + ({ + required utils.GradleUtils gradleUtils, + required String line, + required FlutterProject project, + required ToolContext toolContext, + required bool usesAndroidX, + Java? java, + }) async { + final Logger logger = toolContext.logger; + logger.printBox( ''' -${globals.logger.terminal.warningMark} Consult the error logs above to identify any broken plugins, specifically those containing "error: cannot find symbol..." +${logger.terminal.warningMark} Consult the error logs above to identify any broken plugins, specifically those containing "error: cannot find symbol..." This issue is likely caused by v1 embedding removal and the plugin's continued usage of removed references to the v1 embedding. To fix this error, please upgrade your current package's dependencies to latest versions by running `flutter pub upgrade`. If that does not work, please file an issue for the problematic plugin(s) here: https://github.com/flutter/flutter/issues''', @@ -645,9 +824,17 @@ If that does not work, please file an issue for the problematic plugin(s) here: final jlinkErrorWithJava21AndSourceCompatibility = GradleHandledError( test: (String line) => line.contains('> Error while executing process') && line.contains('jlink'), handler: - ({required String line, required FlutterProject project, required bool usesAndroidX}) async { - globals.printBox(''' -${globals.logger.terminal.warningMark} This is likely due to a known bug in Android Gradle Plugin (AGP) versions less than 8.2.1, when + ({ + required utils.GradleUtils gradleUtils, + required String line, + required FlutterProject project, + required ToolContext toolContext, + required bool usesAndroidX, + Java? java, + }) async { + final Logger logger = toolContext.logger; + logger.printBox(''' +${logger.terminal.warningMark} This is likely due to a known bug in Android Gradle Plugin (AGP) versions less than 8.2.1, when 1. setting a value for SourceCompatibility and 2. using Java 21 or above. To fix this error, please upgrade your AGP version to at least 8.2.1.${_getAgpLocation(project)} @@ -670,10 +857,18 @@ final _missingNdkSourcePropertiesRegexp = RegExp( final missingNdkSourcePropertiesFile = GradleHandledError( test: (String line) => _missingNdkSourcePropertiesRegexp.hasMatch(line), handler: - ({required String line, required FlutterProject project, required bool usesAndroidX}) async { + ({ + required utils.GradleUtils gradleUtils, + required String line, + required FlutterProject project, + required ToolContext toolContext, + required bool usesAndroidX, + Java? java, + }) async { + final Logger logger = toolContext.logger; final String path = _missingNdkSourcePropertiesRegexp.firstMatch(line)!.group(1)!; - globals.printBox(''' - ${globals.logger.terminal.warningMark} This is likely due to a malformed download of the NDK. + logger.printBox(''' + ${logger.terminal.warningMark} This is likely due to a malformed download of the NDK. This can be fixed by deleting the local NDK copy at: $path and allowing the Android Gradle Plugin to automatically re-download it. @@ -703,9 +898,17 @@ final applyingKotlinAndroidPluginErrorHandler = GradleHandledError( ); }, handler: - ({required String line, required FlutterProject project, required bool usesAndroidX}) async { - globals.printBox(''' -${globals.logger.terminal.warningMark} Starting AGP 9+, the default has become built-in Kotlin. + ({ + required utils.GradleUtils gradleUtils, + required String line, + required FlutterProject project, + required ToolContext toolContext, + required bool usesAndroidX, + Java? java, + }) async { + final Logger logger = toolContext.logger; + logger.printBox(''' +${logger.terminal.warningMark} Starting AGP 9+, the default has become built-in Kotlin. This results in a build failure when applying the kotlin-android plugin. To resolve this, migrate to built-in Kotlin. \nFor instructions on how to migrate, see: $kMigrateToBuiltInKotlinDocsUrl''', title: _boxTitle); @@ -718,18 +921,29 @@ To resolve this, migrate to built-in Kotlin. /// Handler when using the new AGP DSL interfaces. Starting AGP 9+, only the new /// DSL interfaces are used. This results in a failure because we still depend /// on old DSL types. +/// +/// This only matches the `NullPointerException` line. Gradle prints +/// `> Failed to apply plugin 'dev.flutter.flutter-gradle-plugin'` for every +/// exception thrown while applying the Flutter Gradle plugin (for example, +/// unsupported dependency version errors), so matching that line would show +/// this box for unrelated failures. @visibleForTesting final useNewAgpDslErrorHandler = GradleHandledError( - test: _lineMatcher(const [ - "> Failed to apply plugin 'dev.flutter.flutter-gradle-plugin'", - '> java.lang.NullPointerException (no error message)', - ]), + test: _lineMatcher(const ['> java.lang.NullPointerException (no error message)']), handler: - ({required String line, required FlutterProject project, required bool usesAndroidX}) async { + ({ + required utils.GradleUtils gradleUtils, + required String line, + required FlutterProject project, + required ToolContext toolContext, + required bool usesAndroidX, + Java? java, + }) async { + final Logger logger = toolContext.logger; final File appGradleFile = project.android.appGradleFile; - globals.printBox( + logger.printBox( ''' -${globals.logger.terminal.warningMark} Starting AGP 9+, only the new DSL interface will be read. +${logger.terminal.warningMark} Starting AGP 9+, only the new DSL interface will be read. This results in a build failure when applying the Flutter Gradle plugin at ${appGradleFile.path}. \nTo resolve this update flutter or opt out of `android.newDsl`. For instructions on how to opt out, see: $kOptOutOfNewDslDocsUrl diff --git a/packages/flutter_tools/lib/src/android/gradle_utils.dart b/packages/flutter_tools/lib/src/android/gradle_utils.dart index d0c2af17..b00ce8b1 100644 --- a/packages/flutter_tools/lib/src/android/gradle_utils.dart +++ b/packages/flutter_tools/lib/src/android/gradle_utils.dart @@ -17,7 +17,6 @@ import '../base/version.dart'; import '../base/version_range.dart'; import '../build_info.dart'; import '../cache.dart'; -import '../globals.dart' as globals; import '../project.dart'; import 'android_sdk.dart'; @@ -31,25 +30,21 @@ import 'android_sdk.dart'; // When bumping, also update: // * Gradle warn version in packages/flutter_tools/gradle/src/main/kotlin/DependencyVersionChecker.kt -// * Gradle test constants in packages/flutter_tools/gradle/src/test/kotlin/DependencyVersionCheckerTest.kt // See https://gradle.org/releases -const templateDefaultGradleVersion = '9.3.1'; +const templateDefaultGradleVersion = '9.5.0'; // When bumping, also update: -// * AGP version constants in packages/flutter_tools/gradle/build.gradle.kts // * AGP warn version in packages/flutter_tools/gradle/src/main/kotlin/DependencyVersionChecker.kt -// * AGP test constants in packages/flutter_tools/gradle/src/test/kotlin/DependencyVersionCheckerTest.kt // * AGP test constants in packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt // See https://mvnrepository.com/artifact/com.android.tools.build/gradle -const templateAndroidGradlePluginVersion = '9.1.0'; -const templateAndroidGradlePluginVersionForModule = '9.1.0'; +const templateAndroidGradlePluginVersion = '9.3.1'; +const templateAndroidGradlePluginVersionForModule = '9.3.1'; // When bumping, also update: // * KGP version constants in packages/flutter_tools/gradle/build.gradle.kts // * KGP warn version in packages/flutter_tools/gradle/src/main/kotlin/DependencyVersionChecker.kt -// * KGP jvm constant in packages/flutter_tools/gradle/src/test/kotlin/DependencyVersionCheckerTest.kt // See https://kotlinlang.org/docs/releases.html#release-details -const templateKotlinGradlePluginVersion = '2.4.0'; +const templateKotlinGradlePluginVersion = '2.4.20'; // The Flutter Gradle Plugin is only applied to app projects, and modules that // are built from source using (`include_flutter.groovy`). The remaining @@ -82,29 +77,29 @@ const oneMajorVersionHigherJavaVersion = '26'; // // Supported here means supported by the tooling for // flutter analyze --suggestions and does not imply broader flutter support. -const maxKnownAndSupportedGradleVersion = '9.3.1'; +const maxKnownAndSupportedGradleVersion = '9.5.0'; // Update this with new KGP versions come out including minor versions. // // Supported here means supported by the tooling for // flutter analyze --suggestions and does not imply broader flutter support. -const maxKnownAndSupportedKgpVersion = '2.4.0'; +const maxKnownAndSupportedKgpVersion = '2.4.20'; // Update this when new versions of AGP come out. // // Supported here means tooling is aware of this version's Java <-> AGP // compatibility. @visibleForTesting -const maxKnownAndSupportedAgpVersion = '9.2'; +const maxKnownAndSupportedAgpVersion = '9.4'; // Update this when new versions of AGP with Kotlin support come out. // // Supported here means supported by the tooling for // flutter analyze --suggestions and does not imply broader flutter support. -const maxKnownAgpVersionWithFullKotlinSupport = '9.1.0'; +const maxKnownAgpVersionWithFullKotlinSupport = '9.3.1'; // Update this when new versions of AGP come out. -const maxKnownAgpVersion = '9.2'; +const maxKnownAgpVersion = '9.4'; // Supported here means tooling is aware of this versions // Java <-> AGP compatibility and does not imply broader flutter support. @@ -218,14 +213,11 @@ const gradleWrapperPropertiesFilename = 'gradle-wrapper.properties'; /// or constructing a Gradle project. class GradleUtils { GradleUtils({ - required Platform platform, - required Logger logger, - required Cache cache, - required OperatingSystemUtils operatingSystemUtils, - }) : _platform = platform, - _logger = logger, - _cache = cache, - _operatingSystemUtils = operatingSystemUtils; + required this._platform, + required this._logger, + required this._cache, + required this._operatingSystemUtils, + }); final Cache _cache; final Platform _platform; @@ -421,8 +413,9 @@ OS: Mac OS X 13.2.1 aarch64 Future getKgpVersion( Directory androidDirectory, Logger logger, - ProcessManager processManager, -) async { + ProcessManager processManager, { + Platform platform = const LocalPlatform(), +}) async { // Maintainers of the kotlin dsl and the kotlin gradle plugin are different. // // Android Docs refer to the kotlin gradle plugin with either the full name or KGP. @@ -436,9 +429,10 @@ Future getKgpVersion( // Instead the kgpVersion task is a custom flutter task dynamically added that can // print the kgp version if gradle can run successfully. - if (processManager.canRun('./gradlew', workingDirectory: androidDirectory.path)) { + final String gradlew = androidDirectory.childFile(getGradlewFileName(platform)).path; + if (processManager.canRun(gradlew, workingDirectory: androidDirectory.path)) { final ProcessResult command = await processManager.run([ - './gradlew', + gradlew, 'kgpVersion', '-q', ], workingDirectory: androidDirectory.path); @@ -594,7 +588,7 @@ bool validateGradleAndKGP(Logger logger, {required String? kgpV, required String // Continuous KGP version handling is preferred in case an emergency patch to a // past release is shipped this code will assume the version range that is closest. - // Documented max is 2.4.0, using 2.4.29 covers patch versions. + // Documented max is 2.4.20, using 2.4.29 covers patch versions. if (isWithinVersionRange(kgpV, min: '2.4.0', max: '2.4.29')) { return isWithinVersionRange(gradleV, min: '8.5', max: '9.5.99', inclusiveMax: false); } @@ -712,8 +706,14 @@ bool validateAgpAndKgp(Logger logger, {required String? kgpV, required String? a // Continuous KGP version handling is preferred in case an emergency patch to a // past release is shipped this code will assume the version range that is closest. - // Documented max is 2.4.0, using 2.4.29 covers patch versions. - if (isWithinVersionRange(kgpV, min: '2.4.0', max: '2.4.29')) { + // Documented max is 2.4.20. + if (isWithinVersionRange(kgpV, min: '2.4.20', max: '2.4.29')) { + // Documented max is 9.3.1 + return isWithinVersionRange(agpV, min: '8.2.2', max: '9.3.99', inclusiveMax: false); + } + // Documented max is 2.4.10, using 2.4.19 covers patch versions. + if (isWithinVersionRange(kgpV, min: '2.4.0', max: '2.4.19')) { + // Documented max is 9.2.0 return isWithinVersionRange(agpV, min: '8.2.2', max: '9.2.99', inclusiveMax: false); } // Documented max is 2.3.10 @@ -825,10 +825,16 @@ bool validateGradleAndAgp(Logger logger, {required String? gradleV, required Str if (isWithinVersionRange(agpV, min: '9.1.0', max: '9.1.99')) { return isWithinVersionRange(gradleV, min: '9.3.1', max: maxKnownAndSupportedGradleVersion); } + if (isWithinVersionRange(agpV, min: '9.2.0', max: '9.2.99')) { + return isWithinVersionRange(gradleV, min: '9.4.1', max: maxKnownAndSupportedGradleVersion); + } + if (isWithinVersionRange(agpV, min: '9.3.0', max: '9.3.99')) { + return isWithinVersionRange(gradleV, min: '9.5.0', max: maxKnownAndSupportedGradleVersion); + } // Check if versions are newer than the max known versions. if (isWithinVersionRange(agpV, min: maxKnownAndSupportedAgpVersion, max: '100.100')) { // Assume versions we do not know about are valid but log. - final bool validGradle = isWithinVersionRange(gradleV, min: '9.3.1', max: '100.00'); + final bool validGradle = isWithinVersionRange(gradleV, min: '9.5.0', max: '100.00'); logger.printTrace( 'Newer than known AGP version ($agpV), gradle ($gradleV).' '\n Treating as valid configuration.', @@ -1138,6 +1144,8 @@ String getGradleVersionFor(String agpV) { GradleForAgp(agpMin: '8.13.0', agpMax: '8.13.99', minRequiredGradle: '8.14'), GradleForAgp(agpMin: '9.0', agpMax: '9.0.99', minRequiredGradle: '9.1.0'), GradleForAgp(agpMin: '9.1.0', agpMax: '9.1.99', minRequiredGradle: '9.3.1'), + GradleForAgp(agpMin: '9.2.0', agpMax: '9.2.99', minRequiredGradle: '9.4.1'), + GradleForAgp(agpMin: '9.3.0', agpMax: '9.3.99', minRequiredGradle: '9.5.0'), // Assume if AGP is newer than this code knows about return the highest gradle // version we know about. GradleForAgp( @@ -1164,11 +1172,18 @@ String getGradleVersionFor(String agpV) { /// this will fail with a [ToolExit]. void updateLocalProperties({ required FlutterProject project, + Analytics? analytics, + AndroidSdk? androidSdk, BuildInfo? buildInfo, + FileSystemUtils? fileSystemUtils, + Logger? logger, bool requireAndroidSdk = true, }) { - if (requireAndroidSdk && globals.androidSdk == null) { - exitWithNoSdkMessage(); + if (requireAndroidSdk && androidSdk == null) { + exitWithNoSdkMessage( + analytics: analytics ?? const NoOpAnalytics(), + logger: logger ?? BufferLogger.test(), + ); } final File localProperties = project.android.localPropertiesFile; var changed = false; @@ -1193,24 +1208,27 @@ void updateLocalProperties({ changed = true; } - final AndroidSdk? androidSdk = globals.androidSdk; + final FileSystemUtils fsUtils = + fileSystemUtils ?? + FileSystemUtils(fileSystem: project.directory.fileSystem, platform: const LocalPlatform()); + if (androidSdk != null) { - changeIfNecessary('sdk.dir', globals.fsUtils.escapePath(androidSdk.directory.path)); + changeIfNecessary('sdk.dir', fsUtils.escapePath(androidSdk.directory.path)); } - changeIfNecessary('flutter.sdk', globals.fsUtils.escapePath(Cache.flutterRoot!)); + changeIfNecessary('flutter.sdk', fsUtils.escapePath(Cache.flutterRoot!)); if (buildInfo != null) { changeIfNecessary('flutter.buildMode', buildInfo.modeName); final String? buildName = validatedBuildNameForPlatform( TargetPlatform.android_arm, buildInfo.buildName ?? project.manifest.buildName, - globals.logger, + logger ?? BufferLogger.test(), ); changeIfNecessary('flutter.versionName', buildName); final String? buildNumber = validatedBuildNumberForPlatform( TargetPlatform.android_arm, buildInfo.buildNumber ?? project.manifest.buildNumber, - globals.logger, + logger ?? BufferLogger.test(), ); changeIfNecessary('flutter.versionCode', buildNumber); } @@ -1220,20 +1238,9 @@ void updateLocalProperties({ } } -/// Writes standard Android local properties to the specified [properties] file. -/// -/// Writes the path to the Android SDK, if known. -void writeLocalProperties(File properties) { - final settings = SettingsFile(); - final AndroidSdk? androidSdk = globals.androidSdk; - if (androidSdk != null) { - settings.values['sdk.dir'] = globals.fsUtils.escapePath(androidSdk.directory.path); - } - settings.writeContents(properties); -} - -void exitWithNoSdkMessage() { - globals.analytics.send( +/// Logs an analytics event indicating the Android SDK is missing and throws a [ToolExit]. +void exitWithNoSdkMessage({required Analytics analytics, required Logger logger}) { + analytics.send( Event.flutterBuildInfo( label: 'unsupported-project', buildType: 'gradle', @@ -1241,7 +1248,7 @@ void exitWithNoSdkMessage() { ), ); throwToolExit( - '${globals.logger.terminal.warningMark} No Android SDK found. ' + '${logger.terminal.warningMark} No Android SDK found. ' 'Try setting the ANDROID_HOME environment variable.', ); } diff --git a/packages/flutter_tools/lib/src/android/java.dart b/packages/flutter_tools/lib/src/android/java.dart index b9429ddc..d73e9c4b 100644 --- a/packages/flutter_tools/lib/src/android/java.dart +++ b/packages/flutter_tools/lib/src/android/java.dart @@ -38,14 +38,11 @@ class Java { required this.binaryPath, required this.javaSource, required Logger logger, - required FileSystem fileSystem, - required OperatingSystemUtils os, - required Platform platform, + required this._fileSystem, + required this._os, + required this._platform, required ProcessManager processManager, }) : _logger = logger, - _fileSystem = fileSystem, - _os = os, - _platform = platform, _processManager = processManager, _processUtils = ProcessUtils(processManager: processManager, logger: logger); @@ -69,10 +66,10 @@ class Java { /// /// Returns null if no java binary could be found. static Java? find({ + required AndroidStudio? Function() androidStudioBuilder, required Config config, - required AndroidStudio? androidStudio, - required Logger logger, required FileSystem fileSystem, + required Logger logger, required Platform platform, required ProcessManager processManager, }) { @@ -83,9 +80,9 @@ class Java { processManager: processManager, ); final _JavaHomePathWithSource? home = _findJavaHome( + androidStudioBuilder: androidStudioBuilder, config: config, logger: logger, - androidStudio: androidStudio, platform: platform, ); final String? binary = _findJavaBinary( @@ -159,6 +156,29 @@ class Java { _platform.environment['PATH']!, }; + /// JVM flag that grants native access to code on the class path. + static const enableNativeAccessFlag = '--enable-native-access=ALL-UNNAMED'; + + /// Returns an environment variable map for invoking Gradle. + /// + /// This is [environment], plus [enableNativeAccessFlag] appended to + /// `GRADLE_OPTS`. The Gradle client loads a native library via + /// `System::load`, which on JDK 24+ prints a warning to stderr unless native + /// access is enabled. On earlier JDKs, the flag has no effect. + /// + /// See: https://openjdk.org/jeps/472. + Map get gradleEnvironment { + final Map env = environment; + final String existingOpts = _platform.environment['GRADLE_OPTS']?.trim() ?? ''; + if (existingOpts.contains('--enable-native-access')) { + return env; + } + env['GRADLE_OPTS'] = existingOpts.isEmpty + ? enableNativeAccessFlag + : '$existingOpts $enableNativeAccessFlag'; + return env; + } + /// Returns the version of java in the format \d(.\d)+(.\d)+ /// Returns null if version could not be determined. late final Version? version = (() { @@ -224,9 +244,9 @@ class Java { } _JavaHomePathWithSource? _findJavaHome({ + required AndroidStudio? Function() androidStudioBuilder, required Config config, required Logger logger, - required AndroidStudio? androidStudio, required Platform platform, }) { final Object? configured = config.getValue('jdk-dir'); @@ -234,7 +254,7 @@ _JavaHomePathWithSource? _findJavaHome({ return (path: configured as String, source: JavaSource.flutterConfig); } - final String? androidStudioJavaPath = androidStudio?.javaPath; + final String? androidStudioJavaPath = androidStudioBuilder()?.javaPath; if (androidStudioJavaPath != null) { return (path: androidStudioJavaPath, source: JavaSource.androidStudio); } diff --git a/packages/flutter_tools/lib/src/android/migrations/android_studio_java_gradle_conflict_migration.dart b/packages/flutter_tools/lib/src/android/migrations/android_studio_java_gradle_conflict_migration.dart index e9a570e9..e67f5638 100644 --- a/packages/flutter_tools/lib/src/android/migrations/android_studio_java_gradle_conflict_migration.dart +++ b/packages/flutter_tools/lib/src/android/migrations/android_studio_java_gradle_conflict_migration.dart @@ -49,10 +49,6 @@ const javaVersionNot17 = @visibleForTesting const javaVersionNotFound = 'Version of Java not found, no migration attempted.'; @visibleForTesting -const conflictDetected = - 'Conflict detected between versions of Android Studio ' - 'and Gradle, upgrading Gradle version from current to 7.4'; -@visibleForTesting const gradleVersionNotFound = 'Failed to parse Gradle version from distribution url, ' 'skipping Gradle-Java version compatibility check.'; @@ -74,11 +70,9 @@ class AndroidStudioJavaGradleConflictMigration extends ProjectMigrator { AndroidStudioJavaGradleConflictMigration( super.logger, { required AndroidProject project, - AndroidStudio? androidStudio, - required Java? java, - }) : _gradleWrapperPropertiesFile = getGradleWrapperFile(project.hostAppGradleRoot), - _androidStudio = androidStudio, - _java = java; + this._androidStudio, + required this._java, + }) : _gradleWrapperPropertiesFile = getGradleWrapperFile(project.hostAppGradleRoot); final File _gradleWrapperPropertiesFile; final AndroidStudio? _androidStudio; diff --git a/packages/flutter_tools/lib/src/artifacts.dart b/packages/flutter_tools/lib/src/artifacts.dart index 41ca8f2d..6a09182a 100644 --- a/packages/flutter_tools/lib/src/artifacts.dart +++ b/packages/flutter_tools/lib/src/artifacts.dart @@ -83,9 +83,6 @@ enum Artifact { /// Tools related to subsetting or icon font files. fontSubset('font-subset', isExecutable: true), - /// Still used in g3 so cannot be deleted yet. - constFinder('const_finder.dart.snapshot'), - /// The location of file generators. flutterToolsFileGenerators.directory(); @@ -150,16 +147,13 @@ enum HostArtifact { /// The summary dill for the dart2js target. webPlatformDart2JSKernelDill('dart2js_platform.dill'), - /// The precompiled SDKs and sourcemaps for web debug builds with the AMD module system. - // TODO(markzipan): delete these when DDC's AMD module system is deprecated, https://github.com/flutter/flutter/issues/142060. - webPrecompiledAmdCanvaskitSdk('dart_sdk.js'), - webPrecompiledAmdCanvaskitSdkSourcemaps('dart_sdk.js.map'), + /// The precompiled SDKs and sourcemaps for web debug builds with the stable DDC. + webPrecompiledDDCStableSdk('dart_sdk.js'), + webPrecompiledDDCStableSdkSourcemaps('dart_sdk.js.map'), - /// The precompiled SDKs and sourcemaps for web debug builds with the DDC - /// library bundle module system. Only SDKs built with sound null-safety are - /// provided here. - webPrecompiledDdcLibraryBundleCanvaskitSdk('dart_sdk.js'), - webPrecompiledDdcLibraryBundleCanvaskitSdkSourcemaps('dart_sdk.js.map'), + /// The precompiled SDKs and sourcemaps for web debug builds with the canary DDC. + webPrecompiledDDCCanarySdk('dart_sdk.js'), + webPrecompiledDDCCanarySdkSourcemaps('dart_sdk.js.map'), iosDeploy('ios-deploy'), idevicesyslog('idevicesyslog'), @@ -300,22 +294,29 @@ abstract class Artifacts { return _TestLocalEngine(localEngine, localEngineHost, fileSystem ?? MemoryFileSystem.test()); } - static Artifacts getLocalEngine(EngineBuildPaths engineBuildPaths) { + static Artifacts getLocalEngine( + EngineBuildPaths engineBuildPaths, { + required Cache cache, + required FileSystem fileSystem, + required OperatingSystemUtils operatingSystemUtils, + required Platform platform, + required ProcessManager processManager, + }) { Artifacts artifacts = CachedArtifacts( - fileSystem: globals.fs, - platform: globals.platform, - cache: globals.cache, - operatingSystemUtils: globals.os, + fileSystem: fileSystem, + platform: platform, + cache: cache, + operatingSystemUtils: operatingSystemUtils, ); if (engineBuildPaths.hostEngine != null && engineBuildPaths.targetEngine != null) { artifacts = CachedLocalEngineArtifacts( engineBuildPaths.hostEngine!, engineOutPath: engineBuildPaths.targetEngine!, - cache: globals.cache, - fileSystem: globals.fs, - processManager: globals.processManager, - platform: globals.platform, - operatingSystemUtils: globals.os, + cache: cache, + fileSystem: fileSystem, + processManager: processManager, + platform: platform, + operatingSystemUtils: operatingSystemUtils, parent: artifacts, ); } @@ -323,9 +324,9 @@ abstract class Artifacts { artifacts = CachedLocalWebSdkArtifacts( parent: artifacts, webSdkPath: engineBuildPaths.webSdk!, - fileSystem: globals.fs, - platform: globals.platform, - operatingSystemUtils: globals.os, + fileSystem: fileSystem, + platform: platform, + operatingSystemUtils: operatingSystemUtils, ); } return artifacts; @@ -356,17 +357,67 @@ abstract class Artifacts { LocalEngineInfo? get localEngineInfo; } +/// Artifacts that can be changed after they are created. +/// +/// Commands are created when the tool starts up, before the command line has +/// been parsed, so the artifacts they need to build against are not yet known. +/// A [DeferredArtifacts] can be used in their place, then [resolve]d once the +/// required artifacts are known. +/// +/// Paths read before [resolve] is called are out of date, so must not be +/// cached. +class DeferredArtifacts implements Artifacts { + DeferredArtifacts(this._artifacts); + + Artifacts _artifacts; + var _isResolved = false; + + /// Uses [artifacts] from here on. + /// + /// Called before any command runs, and only once. + void resolve(Artifacts artifacts) { + assert(!_isResolved, 'The artifacts to build against have already been resolved.'); + _isResolved = true; + _artifacts = artifacts; + } + + @override + String getArtifactPath( + Artifact artifact, { + TargetPlatform? platform, + BuildMode? mode, + EnvironmentType? environmentType, + }) { + return _artifacts.getArtifactPath( + artifact, + platform: platform, + mode: mode, + environmentType: environmentType, + ); + } + + @override + FileSystemEntity getHostArtifact(HostArtifact artifact) => _artifacts.getHostArtifact(artifact); + + @override + String getEngineType(TargetPlatform platform, [BuildMode? mode]) => + _artifacts.getEngineType(platform, mode); + + @override + bool get usesLocalArtifacts => _artifacts.usesLocalArtifacts; + + @override + LocalEngineInfo? get localEngineInfo => _artifacts.localEngineInfo; +} + /// Manages the engine artifacts downloaded to the local cache. class CachedArtifacts implements Artifacts { CachedArtifacts({ - required FileSystem fileSystem, - required Platform platform, - required Cache cache, - required OperatingSystemUtils operatingSystemUtils, - }) : _fileSystem = fileSystem, - _platform = platform, - _cache = cache, - _operatingSystemUtils = operatingSystemUtils; + required this._fileSystem, + required this._platform, + required this._cache, + required this._operatingSystemUtils, + }); final FileSystem _fileSystem; final Platform _platform; @@ -385,10 +436,10 @@ class CachedArtifacts implements Artifacts { case HostArtifact.webPlatformKernelFolder: case HostArtifact.webPlatformDDCKernelDill: case HostArtifact.webPlatformDart2JSKernelDill: - case HostArtifact.webPrecompiledAmdCanvaskitSdk: - case HostArtifact.webPrecompiledAmdCanvaskitSdkSourcemaps: - case HostArtifact.webPrecompiledDdcLibraryBundleCanvaskitSdk: - case HostArtifact.webPrecompiledDdcLibraryBundleCanvaskitSdkSourcemaps: + case HostArtifact.webPrecompiledDDCStableSdk: + case HostArtifact.webPrecompiledDDCStableSdkSourcemaps: + case HostArtifact.webPrecompiledDDCCanarySdk: + case HostArtifact.webPrecompiledDDCCanarySdkSourcemaps: return _resolveWebArtifact(artifact, _getFlutterWebSdkPath(), _fileSystem, _platform); case HostArtifact.idevicesyslog: case HostArtifact.idevicescreenshot: @@ -425,34 +476,23 @@ class CachedArtifacts implements Artifacts { EnvironmentType? environmentType, }) { platform = _mapTargetPlatform(platform); - switch (platform) { - case TargetPlatform.android: - case TargetPlatform.android_arm: - case TargetPlatform.android_arm64: - case TargetPlatform.android_x64: + switch (platform?.os) { + case .android: assert(platform != TargetPlatform.android); return _getAndroidArtifactPath(artifact, platform!, mode!); - case TargetPlatform.ios: + case .ios: return _getIosArtifactPath(artifact, platform!, mode, environmentType); - case TargetPlatform.darwin: - case TargetPlatform.linux_x64: - case TargetPlatform.linux_arm64: - case TargetPlatform.linux_riscv64: - case TargetPlatform.windows_x64: - case TargetPlatform.windows_arm64: + case .macos || .linux || .windows: return _getDesktopArtifactPath(artifact, platform!, mode); - case TargetPlatform.fuchsia_arm64: - case TargetPlatform.fuchsia_x64: + case .fuchsia: return _getFuchsiaArtifactPath(artifact, platform!, mode!); - case TargetPlatform.tester: - case TargetPlatform.web_javascript: - case null: + case .tester || .web || null: return _getHostArtifactPath( artifact, platform ?? _currentHostPlatform(_platform, _operatingSystemUtils), mode, ); - case TargetPlatform.unsupported: + case .unsupported: TargetPlatform.throwUnsupportedTarget(); } } @@ -476,7 +516,6 @@ class CachedArtifacts implements Artifacts { case Artifact.engineDartBinary: case Artifact.engineDartAotRuntime: case Artifact.frontendServerSnapshotForEngineDartSdk: - case Artifact.constFinder: case Artifact.flutterFramework: case Artifact.flutterFrameworkDsym: case Artifact.flutterMacOSFramework: @@ -528,7 +567,6 @@ class CachedArtifacts implements Artifacts { case Artifact.engineDartBinary: case Artifact.engineDartAotRuntime: case Artifact.frontendServerSnapshotForEngineDartSdk: - case Artifact.constFinder: case Artifact.flutterFramework: case Artifact.flutterFrameworkDsym: case Artifact.flutterMacOSFramework: @@ -580,7 +618,6 @@ class CachedArtifacts implements Artifacts { case Artifact.engineDartBinary: case Artifact.engineDartAotRuntime: case Artifact.frontendServerSnapshotForEngineDartSdk: - case Artifact.constFinder: case Artifact.flutterMacOSFramework: case Artifact.flutterMacOSFrameworkDsym: case Artifact.flutterMacOSXcframework: @@ -632,7 +669,6 @@ class CachedArtifacts implements Artifacts { case Artifact.fuchsiaFlutterRunner: final String artifactFileName = artifact.getFileName(_platform, mode); return _fileSystem.path.join(root, runtime, artifactFileName); - case Artifact.constFinder: case Artifact.flutterFramework: case Artifact.flutterFrameworkDsym: case Artifact.flutterMacOSFramework: @@ -768,7 +804,6 @@ class CachedArtifacts implements Artifacts { final Directory dartPackageDirectory = _cache.getCacheDir('pkg'); return _fileSystem.path.join(dartPackageDirectory.path, artifact.getFileName(_platform)); case Artifact.fontSubset: - case Artifact.constFinder: return _cache .getArtifactDirectory('engine') .childDirectory(_enginePlatformDirectoryName(platform)) @@ -788,13 +823,12 @@ class CachedArtifacts implements Artifacts { String? _getEngineArtifactsPath(TargetPlatform platform, [BuildMode? mode]) { final String engineDir = _cache.getArtifactDirectory('engine').path; final String platformName = _enginePlatformDirectoryName(platform); - switch (platform) { - case TargetPlatform.linux_x64: - case TargetPlatform.linux_arm64: - case TargetPlatform.linux_riscv64: - case TargetPlatform.darwin: - case TargetPlatform.windows_x64: - case TargetPlatform.windows_arm64: + if (platform == TargetPlatform.android) { + assert(false, 'cannot use TargetPlatform.android to look up artifacts'); + return null; + } + switch (platform.os) { + case .linux || .macos || .windows: // TODO(zanderso): remove once debug desktop artifacts are uploaded // under a separate directory from the host artifacts. // https://github.com/flutter/flutter/issues/38935 @@ -803,23 +837,14 @@ class CachedArtifacts implements Artifacts { } final suffix = mode != BuildMode.debug ? '-${kebabCase(mode.cliName)}' : ''; return _fileSystem.path.join(engineDir, platformName + suffix); - case TargetPlatform.fuchsia_arm64: - case TargetPlatform.fuchsia_x64: - case TargetPlatform.tester: - case TargetPlatform.web_javascript: + case .fuchsia || .tester || .web: assert(mode == null, 'Platform $platform does not support different build modes.'); return _fileSystem.path.join(engineDir, platformName); - case TargetPlatform.ios: - case TargetPlatform.android_arm: - case TargetPlatform.android_arm64: - case TargetPlatform.android_x64: + case .ios || .android: assert(mode != null, 'Need to specify a build mode for platform $platform.'); final suffix = mode != BuildMode.debug ? '-${kebabCase(mode!.cliName)}' : ''; return _fileSystem.path.join(engineDir, platformName + suffix); - case TargetPlatform.android: - assert(false, 'cannot use TargetPlatform.android to look up artifacts'); - return null; - case TargetPlatform.unsupported: + case .unsupported: TargetPlatform.throwUnsupportedTarget(); } } @@ -993,7 +1018,7 @@ class CachedLocalEngineArtifacts implements Artifacts { required String engineOutPath, required FileSystem fileSystem, required Cache cache, - required ProcessManager processManager, + required this._processManager, required Platform platform, required OperatingSystemUtils operatingSystemUtils, Artifacts? parent, @@ -1002,7 +1027,6 @@ class CachedLocalEngineArtifacts implements Artifacts { targetOutPath: engineOutPath, hostOutPath: _hostEngineOutPath, ), - _processManager = processManager, _platform = platform, _operatingSystemUtils = operatingSystemUtils, _backupCache = @@ -1043,10 +1067,10 @@ class CachedLocalEngineArtifacts implements Artifacts { case HostArtifact.webPlatformKernelFolder: case HostArtifact.webPlatformDDCKernelDill: case HostArtifact.webPlatformDart2JSKernelDill: - case HostArtifact.webPrecompiledAmdCanvaskitSdk: - case HostArtifact.webPrecompiledAmdCanvaskitSdkSourcemaps: - case HostArtifact.webPrecompiledDdcLibraryBundleCanvaskitSdk: - case HostArtifact.webPrecompiledDdcLibraryBundleCanvaskitSdkSourcemaps: + case HostArtifact.webPrecompiledDDCStableSdk: + case HostArtifact.webPrecompiledDDCStableSdkSourcemaps: + case HostArtifact.webPrecompiledDDCCanarySdk: + case HostArtifact.webPrecompiledDDCCanarySdkSourcemaps: case HostArtifact.idevicesyslog: case HostArtifact.idevicescreenshot: case HostArtifact.skyEnginePath: @@ -1092,7 +1116,7 @@ class CachedLocalEngineArtifacts implements Artifacts { case Artifact.flutterMacOSXcframework: return _fileSystem.path.join(localEngineInfo.targetOutPath, artifactFileName); case Artifact.platformKernelDill: - if (platform == TargetPlatform.fuchsia_x64 || platform == TargetPlatform.fuchsia_arm64) { + if (platform?.os == .fuchsia) { return _fileSystem.path.join( localEngineInfo.targetOutPath, 'flutter_runner_patched_sdk', @@ -1125,7 +1149,7 @@ class CachedLocalEngineArtifacts implements Artifacts { // what was specified in [mode] argument because local engine will // have only one flutter_patched_sdk in standard location, that // is happen to be what debug(non-release) mode is using. - if (platform == TargetPlatform.fuchsia_x64 || platform == TargetPlatform.fuchsia_arm64) { + if (platform?.os == .fuchsia) { return _fileSystem.path.join(localEngineInfo.targetOutPath, 'flutter_runner_patched_sdk'); } return _getFlutterPatchedSdkPath(BuildMode.debug); @@ -1150,8 +1174,6 @@ class CachedLocalEngineArtifacts implements Artifacts { ); case Artifact.fontSubset: return _fileSystem.path.join(_hostEngineOutPath, artifactFileName); - case Artifact.constFinder: - return _fileSystem.path.join(_hostEngineOutPath, 'gen', artifactFileName); case Artifact.linuxDesktopPath: case Artifact.linuxHeaders: case Artifact.windowsDesktopPath: @@ -1239,16 +1261,12 @@ class CachedLocalEngineArtifacts implements Artifacts { class CachedLocalWebSdkArtifacts implements Artifacts { CachedLocalWebSdkArtifacts({ - required Artifacts parent, - required String webSdkPath, - required FileSystem fileSystem, - required Platform platform, - required OperatingSystemUtils operatingSystemUtils, - }) : _parent = parent, - _webSdkPath = webSdkPath, - _fileSystem = fileSystem, - _platform = platform, - _operatingSystemUtils = operatingSystemUtils; + required this._parent, + required this._webSdkPath, + required this._fileSystem, + required this._platform, + required this._operatingSystemUtils, + }); final Artifacts _parent; final String _webSdkPath; @@ -1306,7 +1324,6 @@ class CachedLocalWebSdkArtifacts implements Artifacts { case Artifact.fuchsiaKernelCompiler: case Artifact.fuchsiaFlutterRunner: case Artifact.fontSubset: - case Artifact.constFinder: case Artifact.flutterToolsFileGenerators: break; } @@ -1332,10 +1349,10 @@ class CachedLocalWebSdkArtifacts implements Artifacts { case HostArtifact.webPlatformKernelFolder: case HostArtifact.webPlatformDDCKernelDill: case HostArtifact.webPlatformDart2JSKernelDill: - case HostArtifact.webPrecompiledAmdCanvaskitSdk: - case HostArtifact.webPrecompiledAmdCanvaskitSdkSourcemaps: - case HostArtifact.webPrecompiledDdcLibraryBundleCanvaskitSdk: - case HostArtifact.webPrecompiledDdcLibraryBundleCanvaskitSdkSourcemaps: + case HostArtifact.webPrecompiledDDCStableSdk: + case HostArtifact.webPrecompiledDDCStableSdkSourcemaps: + case HostArtifact.webPrecompiledDDCCanarySdk: + case HostArtifact.webPrecompiledDDCCanarySdkSourcemaps: return _resolveWebArtifact(artifact, _getFlutterWebSdkPath(), _fileSystem, _platform); case HostArtifact.iosDeploy: case HostArtifact.idevicesyslog: @@ -1378,70 +1395,6 @@ class CachedLocalWebSdkArtifacts implements Artifacts { LocalEngineInfo? get localEngineInfo => _parent.localEngineInfo; } -/// An implementation of [Artifacts] that provides individual overrides. -/// -/// If an artifact is not provided, the lookup delegates to the parent. -class OverrideArtifacts implements Artifacts { - /// Creates a new [OverrideArtifacts]. - /// - /// [parent] must be provided. - OverrideArtifacts({ - required this.parent, - this.frontendServer, - this.engineDartBinary, - this.platformKernelDill, - this.flutterPatchedSdk, - }); - - final Artifacts parent; - final File? frontendServer; - final File? engineDartBinary; - final File? platformKernelDill; - final File? flutterPatchedSdk; - - @override - LocalEngineInfo? get localEngineInfo => parent.localEngineInfo; - - @override - String getArtifactPath( - Artifact artifact, { - TargetPlatform? platform, - BuildMode? mode, - EnvironmentType? environmentType, - }) { - if (artifact == Artifact.engineDartBinary && engineDartBinary != null) { - return engineDartBinary!.path; - } - if (artifact == Artifact.frontendServerSnapshotForEngineDartSdk && frontendServer != null) { - return frontendServer!.path; - } - if (artifact == Artifact.platformKernelDill && platformKernelDill != null) { - return platformKernelDill!.path; - } - if (artifact == Artifact.flutterPatchedSdkPath && flutterPatchedSdk != null) { - return flutterPatchedSdk!.path; - } - return parent.getArtifactPath( - artifact, - platform: platform, - mode: mode, - environmentType: environmentType, - ); - } - - @override - String getEngineType(TargetPlatform platform, [BuildMode? mode]) => - parent.getEngineType(platform, mode); - - @override - bool get usesLocalArtifacts => parent.usesLocalArtifacts; - - @override - FileSystemEntity getHostArtifact(HostArtifact artifact) { - return parent.getHostArtifact(artifact); - } -} - /// Locate the Dart SDK. String _dartSdkPath(Cache cache) { return cache.getRoot().childDirectory('dart-sdk').path; @@ -1546,20 +1499,15 @@ FileSystemEntity _resolveWebArtifact( return fileSystem.file( fileSystem.path.join(webSdkPath, 'kernel', artifact.getFileName(platform)), ); - case HostArtifact.webPrecompiledAmdCanvaskitSdk: - case HostArtifact.webPrecompiledAmdCanvaskitSdkSourcemaps: + case HostArtifact.webPrecompiledDDCStableSdk: + case HostArtifact.webPrecompiledDDCStableSdkSourcemaps: return fileSystem.file( - fileSystem.path.join(webSdkPath, 'kernel', 'amd-canvaskit', artifact.getFileName(platform)), + fileSystem.path.join(webSdkPath, 'kernel', 'ddc', 'stable', artifact.getFileName(platform)), ); - case HostArtifact.webPrecompiledDdcLibraryBundleCanvaskitSdk: - case HostArtifact.webPrecompiledDdcLibraryBundleCanvaskitSdkSourcemaps: + case HostArtifact.webPrecompiledDDCCanarySdk: + case HostArtifact.webPrecompiledDDCCanarySdkSourcemaps: return fileSystem.file( - fileSystem.path.join( - webSdkPath, - 'kernel', - 'ddcLibraryBundle-canvaskit', - artifact.getFileName(platform), - ), + fileSystem.path.join(webSdkPath, 'kernel', 'ddc', 'canary', artifact.getFileName(platform)), ); case HostArtifact.iosDeploy: case HostArtifact.idevicesyslog: diff --git a/packages/flutter_tools/lib/src/asset.dart b/packages/flutter_tools/lib/src/asset.dart index e50eba98..b9d00d3f 100644 --- a/packages/flutter_tools/lib/src/asset.dart +++ b/packages/flutter_tools/lib/src/asset.dart @@ -72,8 +72,8 @@ class FlutterHookResult { static bool _wasAnyFileModifiedSince(FileSystem fileSystem, DateTime since, List uris) { for (final uri in uris) { - final DateTime modified = fileSystem.statSync(uri.toFilePath()).modified; - if (modified.isAfter(since)) { + final FileStat stat = fileSystem.statSync(fileSystem.path.fromUri(uri)); + if (stat.type == FileSystemEntityType.notFound || stat.modified.isAfter(since)) { return true; } } @@ -201,14 +201,11 @@ abstract class AssetBundle { class _ManifestAssetBundleFactory implements AssetBundleFactory { _ManifestAssetBundleFactory({ - required Logger logger, - required FileSystem fileSystem, - required Platform platform, - bool splitDeferredAssets = false, - }) : _logger = logger, - _fileSystem = fileSystem, - _platform = platform, - _splitDeferredAssets = splitDeferredAssets; + required this._logger, + required this._fileSystem, + required this._platform, + this._splitDeferredAssets = false, + }); final Logger _logger; final FileSystem _fileSystem; @@ -230,16 +227,12 @@ class ManifestAssetBundle implements AssetBundle { /// Constructs an [ManifestAssetBundle] that gathers the set of assets from the /// pubspec.yaml manifest. ManifestAssetBundle({ - required Logger logger, + required this._logger, required FileSystem fileSystem, - required Platform platform, - required String flutterRoot, - bool splitDeferredAssets = false, - }) : _logger = logger, - _fileSystem = fileSystem, - _platform = platform, - _flutterRoot = flutterRoot, - _splitDeferredAssets = splitDeferredAssets, + required this._platform, + required this._flutterRoot, + this._splitDeferredAssets = false, + }) : _fileSystem = fileSystem, _licenseCollector = LicenseCollector(fileSystem: fileSystem), _lastHookResult = FlutterHookResult.empty(); @@ -344,7 +337,7 @@ class ManifestAssetBundle implements AssetBundle { // device. _lastBuildTimestamp = DateTime.now(); _lastHookResult = flutterHookResult ?? FlutterHookResult.empty(); - if (flutterManifest.isEmpty) { + if (flutterManifest.isEmpty && (flutterHookResult?.dataAssets.isEmpty ?? true)) { final ByteData emptyAssetManifest = const StandardMessageCodec().encodeMessage( {}, )!; @@ -422,10 +415,9 @@ class ManifestAssetBundle implements AssetBundle { // dependencies. // To avoid bundling assets from dev_dependencies and other pub workspace // packages, we compute the set of transitive dependencies. - final List transitiveDependencies = computeTransitiveDependencies( - flutterProject, - packageConfig, - ); + final List transitiveDependencies = flutterManifest.isEmpty + ? [] + : computeTransitiveDependencies(flutterProject, packageConfig); final additionalLicenseFiles = >{}; for (final dependency in transitiveDependencies) { if (!includeAssetsFromDevDependencies && dependency.isExclusiveDevDependency) { @@ -538,7 +530,7 @@ class ManifestAssetBundle implements AssetBundle { final Package package = packageConfig[dataAsset.package]!; final Uri fileUri = dataAsset.file; - final String filePath = fileUri.toFilePath(); + final String filePath = _fileSystem.path.fromUri(fileUri); final asset = _Asset( baseDir: _fileSystem.path.dirname(filePath), @@ -1207,11 +1199,43 @@ class ManifestAssetBundle implements AssetBundle { }) { final String directoryPath; _ensureAssetPathIsValid(assetsBaseDir: assetBase, assetUri: assetUri, packageName: packageName); - directoryPath = _fileSystem.path.join( + + final String localDirectoryPath = _fileSystem.path.join( assetBase, assetUri.toFilePath(windows: _platform.isWindows), ); + final bool isPackageAsset = + assetUri.pathSegments.length >= 2 && + assetUri.pathSegments[1].isNotEmpty && + assetUri.pathSegments.first == 'packages' && + !_fileSystem.directory(localDirectoryPath).existsSync(); + + String? packageLibPath; + String? resolvedPackageName; + + if (isPackageAsset) { + final _Asset? packageAsset = _resolvePackageAsset( + assetUri, + packageConfig, + attributedPackage, + flavors: flavors, + platforms: platforms, + transformers: transformers, + ); + if (packageAsset == null) { + return; + } + directoryPath = _fileSystem.path.join( + packageAsset.baseDir, + packageAsset.relativeUri.toFilePath(windows: _platform.isWindows), + ); + packageLibPath = packageAsset.baseDir; + resolvedPackageName = assetUri.pathSegments[1]; + } else { + directoryPath = localDirectoryPath; + } + if (!_fileSystem.directory(directoryPath).existsSync()) { _logger.printError('Error: unable to find directory entry in pubspec.yaml: $directoryPath'); return; @@ -1221,8 +1245,15 @@ class ManifestAssetBundle implements AssetBundle { final Iterable files = entities.whereType(); for (final file in files) { - final String relativePath = _fileSystem.path.relative(file.path, from: assetBase); - final uri = Uri.file(relativePath, windows: _platform.isWindows); + final Uri uri; + if (isPackageAsset) { + final String relativePathToLib = _fileSystem.path.relative(file.path, from: packageLibPath); + final List parts = _fileSystem.path.split(relativePathToLib); + uri = Uri(pathSegments: ['packages', resolvedPackageName!, ...parts]); + } else { + final String relativePath = _fileSystem.path.relative(file.path, from: assetBase); + uri = Uri.file(relativePath, windows: _platform.isWindows); + } _parseAssetFromFile( packageConfig, @@ -1425,7 +1456,11 @@ class ManifestAssetBundle implements AssetBundle { required Set platforms, required List transformers, }) { - _ensureAssetPathIsValid(assetsBaseDir: assetsBaseDir, assetUri: assetUri, packageName: packageName); + _ensureAssetPathIsValid( + assetsBaseDir: assetsBaseDir, + assetUri: assetUri, + packageName: packageName, + ); if (assetUri.pathSegments.first == 'packages' && !_fileSystem.isFileSync( _fileSystem.path.join(assetsBaseDir, _fileSystem.path.fromUri(assetUri)), diff --git a/packages/flutter_tools/lib/src/base/analyze_size.dart b/packages/flutter_tools/lib/src/base/analyze_size.dart index 129a68a6..bf21140e 100644 --- a/packages/flutter_tools/lib/src/base/analyze_size.dart +++ b/packages/flutter_tools/lib/src/base/analyze_size.dart @@ -17,14 +17,11 @@ import 'terminal.dart'; /// A class to analyze APK and AOT snapshot and generate a breakdown of the data. class SizeAnalyzer { SizeAnalyzer({ - required FileSystem fileSystem, - required Logger logger, - required Analytics analytics, - Pattern appFilenamePattern = 'libapp.so', - }) : _analytics = analytics, - _fileSystem = fileSystem, - _logger = logger, - _appFilenamePattern = appFilenamePattern; + required this._fileSystem, + required this._logger, + required this._analytics, + this._appFilenamePattern = 'libapp.so', + }); final FileSystem _fileSystem; final Logger _logger; diff --git a/packages/flutter_tools/lib/src/base/bot_detector.dart b/packages/flutter_tools/lib/src/base/bot_detector.dart index 08c7b59a..f3a88bd0 100644 --- a/packages/flutter_tools/lib/src/base/bot_detector.dart +++ b/packages/flutter_tools/lib/src/base/bot_detector.dart @@ -14,11 +14,9 @@ import 'platform.dart'; class BotDetector { BotDetector({ required HttpClientFactory httpClientFactory, - required Platform platform, - required PersistentToolState persistentToolState, - }) : _platform = platform, - _azureDetector = AzureDetector(httpClientFactory: httpClientFactory), - _persistentToolState = persistentToolState; + required this._platform, + required this._persistentToolState, + }) : _azureDetector = AzureDetector(httpClientFactory: httpClientFactory); final Platform _platform; final AzureDetector _azureDetector; @@ -33,16 +31,10 @@ class BotDetector { // When set, GA logs to a local file (normally for tests) so we don't need to filter. || _platform.environment.containsKey('FLUTTER_ANALYTICS_LOG_FILE')) { - _persistentToolState.setIsRunningOnBot(false); return false; } - if (_persistentToolState.isRunningOnBot != null) { - return _persistentToolState.isRunningOnBot!; - } - - final bool result = - _platform.environment['BOT'] == 'true' + if (_platform.environment['BOT'] == 'true' // https://docs.travis-ci.com/user/environment-variables/#Default-Environment-Variables || _platform.environment['TRAVIS'] == 'true' || @@ -72,10 +64,17 @@ class BotDetector { // Property when running on borg. || _platform.environment.containsKey('BORG_ALLOC_DIR') - // Property when running on Azure. + // https://learn.microsoft.com/en-us/azure/devops/pipelines/build/variables || - await _azureDetector.isRunningOnAzure; + _platform.environment.containsKey('TF_BUILD')) { + return true; + } + + if (_persistentToolState.isRunningOnBot != null) { + return _persistentToolState.isRunningOnBot!; + } + final bool result = await _azureDetector.isRunningOnAzure; _persistentToolState.setIsRunningOnBot(result); return result; } @@ -85,8 +84,7 @@ class BotDetector { // https://docs.microsoft.com/en-us/azure/virtual-machines/linux/instance-metadata-service @visibleForTesting class AzureDetector { - AzureDetector({required HttpClientFactory httpClientFactory}) - : _httpClientFactory = httpClientFactory; + AzureDetector({required this._httpClientFactory}); static const _serviceUrl = 'http://169.254.169.254/metadata/instance'; diff --git a/packages/flutter_tools/lib/src/base/build.dart b/packages/flutter_tools/lib/src/base/build.dart index d8367852..816a186c 100644 --- a/packages/flutter_tools/lib/src/base/build.dart +++ b/packages/flutter_tools/lib/src/base/build.dart @@ -27,11 +27,10 @@ class SnapshotType { /// Interface to the gen_snapshot command-line tool. class GenSnapshot { GenSnapshot({ - required Artifacts artifacts, + required this._artifacts, required ProcessManager processManager, required Logger logger, - }) : _artifacts = artifacts, - _processUtils = ProcessUtils(logger: logger, processManager: processManager); + }) : _processUtils = ProcessUtils(logger: logger, processManager: processManager); final Artifacts _artifacts; final ProcessUtils _processUtils; @@ -72,8 +71,7 @@ class GenSnapshot { // architecture (iOS: armv7, arm64; macOS: x86_64, arm64). Select the right // one for the target architecture in question. Artifact genSnapshotArtifact; - if (snapshotType.platform == TargetPlatform.ios || - snapshotType.platform == TargetPlatform.darwin) { + if (snapshotType.platform.os case .ios || .macos) { genSnapshotArtifact = cpuArch == CpuArch.arm64 ? Artifact.genSnapshotArm64 : Artifact.genSnapshotX64; @@ -92,14 +90,14 @@ class GenSnapshot { class AOTSnapshotter { AOTSnapshotter({ + required Artifacts artifacts, + required this._fileSystem, required Logger logger, - required FileSystem fileSystem, - required Xcode xcode, required ProcessManager processManager, - required Artifacts artifacts, + this._xcode, + this._windowsLinkerPath, }) : _logger = logger, - _fileSystem = fileSystem, - _xcode = xcode, + _processUtils = ProcessUtils(logger: logger, processManager: processManager), _genSnapshot = GenSnapshot( artifacts: artifacts, processManager: processManager, @@ -108,7 +106,9 @@ class AOTSnapshotter { final Logger _logger; final FileSystem _fileSystem; - final Xcode _xcode; + final Xcode? _xcode; + final String? _windowsLinkerPath; + final ProcessUtils _processUtils; final GenSnapshot _genSnapshot; /// Builds an architecture-specific ahead-of-time compiled snapshot of the specified script. @@ -136,21 +136,21 @@ class AOTSnapshotter { final genSnapshotArgs = ['--deterministic']; - final bool targetingApplePlatform = - platform == TargetPlatform.ios || platform == TargetPlatform.darwin; + final bool targetingApplePlatform = platform.os == .ios || platform.os == .macos; _logger.printTrace('targetingApplePlatform = $targetingApplePlatform'); final bool extractAppleDebugSymbols = buildMode == BuildMode.profile || buildMode == BuildMode.release; _logger.printTrace('extractAppleDebugSymbols = $extractAppleDebugSymbols'); - final bool targetingAndroidPlatform = - platform == TargetPlatform.android || - platform == TargetPlatform.android_arm || - platform == TargetPlatform.android_arm64 || - platform == TargetPlatform.android_x64; + final targetingAndroidPlatform = platform.os == .android; _logger.printTrace('targetingAndroidPlatform = $targetingAndroidPlatform'); + // TODO(team-windows): Support PE/COFF AOT snapshots for Windows Arm64. + // See: https://github.com/flutter/flutter/issues/190897 + final targetingWindowsX64 = platform == TargetPlatform.windows_x64; + _logger.printTrace('targetingWindowsX64 = $targetingWindowsX64'); + // We strip snapshot by default, but allow to suppress this behavior // by supplying --no-strip in extraGenSnapshotOptions. var shouldStrip = true; @@ -166,6 +166,8 @@ class AOTSnapshotter { } String aotSharedLibrary = _fileSystem.path.join(outputDir.path, 'app.so'); + final String windowsAotObject = _fileSystem.path.join(outputDir.path, 'app.o'); + final String defaultWindowsPdbFile = _fileSystem.path.join(outputDir.path, 'app.pdb'); String? frameworkPath; if (targetingApplePlatform) { // On iOS and macOS, we use Xcode to compile the snapshot into a dynamic @@ -195,6 +197,11 @@ class AOTSnapshotter { '--macho-rpath=@executable_path/Frameworks,@loader_path/Frameworks', '--macho-install-name=@rpath/$frameworkName/$frameworkSnapshotName', ]); + } else if (targetingWindowsX64) { + genSnapshotArgs.addAll([ + '--snapshot_kind=app-aot-pecoff-obj', + '--coff=$windowsAotObject', + ]); } else { genSnapshotArgs.addAll(['--snapshot_kind=app-aot-elf', '--elf=$aotSharedLibrary']); } @@ -219,10 +226,6 @@ class AOTSnapshotter { } if (platform == TargetPlatform.android_arm) { - // Use softfp for Android armv7 devices. - // TODO(cbracken): eliminate this when we fix https://github.com/flutter/flutter/issues/17489 - genSnapshotArgs.add('--no-sim-use-hardfp'); - // Not supported by the Pixel in 32-bit mode. genSnapshotArgs.add('--no-use-integer-division'); } @@ -233,13 +236,25 @@ class AOTSnapshotter { final String archName = platform.getName(cpuArch: cpuArch); final debugFilename = 'app.$archName.symbols'; final bool shouldSplitDebugInfo = splitDebugInfo?.isNotEmpty ?? false; + final String? windowsSplitDebugInfoPdb = shouldSplitDebugInfo && targetingWindowsX64 + ? _fileSystem.path.join(splitDebugInfo!, 'app.$archName.pdb') + : null; + final String windowsPdbFile = windowsSplitDebugInfoPdb ?? defaultWindowsPdbFile; if (shouldSplitDebugInfo) { _fileSystem.directory(splitDebugInfo).createSync(recursive: true); + if (windowsSplitDebugInfoPdb != null) { + final File staleDefaultWindowsPdb = _fileSystem.file(defaultWindowsPdbFile); + if (staleDefaultWindowsPdb.existsSync()) { + staleDefaultWindowsPdb.deleteSync(); + } + _logger.printTrace('Windows AOT PE/COFF uses PDB for split debug info.'); + } } + final bool emitDwarfDebugInfo = shouldSplitDebugInfo && !targetingWindowsX64; // Debugging information. genSnapshotArgs.addAll([ - if (shouldSplitDebugInfo) ...[ + if (emitDwarfDebugInfo) ...[ '--dwarf-stack-traces', '--resolve-dwarf-paths', '--save-debugging-info=${_fileSystem.path.join(splitDebugInfo!, debugFilename)}', @@ -260,9 +275,39 @@ class AOTSnapshotter { return genSnapshotExitCode; } + if (targetingWindowsX64) { + final String? linkerPath = _windowsLinkerPath; + if (linkerPath == null || !_fileSystem.file(linkerPath).existsSync()) { + _logger.printError('Unable to find Visual Studio link.exe required to build Windows AOT.'); + return 1; + } + final int linkerExitCode = await _processUtils.stream([ + linkerPath, + '/NOLOGO', + '/DLL', + '/NOENTRY', + '/MACHINE:X64', + '/DEBUG', + '/INCREMENTAL:NO', + '/OUT:$aotSharedLibrary', + '/PDB:$windowsPdbFile', + '/EXPORT:_kDartSnapshotData,DATA', + '/EXPORT:_kDartSnapshotText,DATA', + windowsAotObject, + ]); + if (linkerExitCode != 0) { + _logger.printError('Windows AOT linker failed with exit code $linkerExitCode'); + return linkerExitCode; + } + } + if (targetingApplePlatform) { if (extractAppleDebugSymbols) { - final RunResult dsymResult = await _xcode.dsymutil([ + final Xcode? xcode = _xcode; + if (xcode == null) { + throw StateError('Xcode must be provided when targeting Apple platforms.'); + } + final RunResult dsymResult = await xcode.dsymutil([ '-o', '$frameworkPath.dSYM', aotSharedLibrary, @@ -276,7 +321,7 @@ class AOTSnapshotter { if (stripAfterBuild) { // See https://www.unix.com/man-page/osx/1/strip/ for arguments - final RunResult stripResult = await _xcode.strip([ + final RunResult stripResult = await xcode.strip([ '-x', aotSharedLibrary, '-o', diff --git a/packages/flutter_tools/lib/src/base/command_help.dart b/packages/flutter_tools/lib/src/base/command_help.dart index 7b215ad7..1fca7096 100644 --- a/packages/flutter_tools/lib/src/base/command_help.dart +++ b/packages/flutter_tools/lib/src/base/command_help.dart @@ -14,14 +14,11 @@ const maxLineWidth = 84; /// Encapsulates the help text construction and printing. class CommandHelp { CommandHelp({ - required Logger logger, - required Terminal terminal, - required Platform platform, - required OutputPreferences outputPreferences, - }) : _logger = logger, - _terminal = terminal, - _platform = platform, - _outputPreferences = outputPreferences; + required this._logger, + required this._terminal, + required this._platform, + required this._outputPreferences, + }); final Logger _logger; @@ -166,14 +163,11 @@ class CommandHelpOption { this.key, this.description, { this.inParenthesis = '', - required Logger logger, - required Terminal terminal, - required Platform platform, - required OutputPreferences outputPreferences, - }) : _logger = logger, - _terminal = terminal, - _platform = platform, - _outputPreferences = outputPreferences; + required this._logger, + required this._terminal, + required this._platform, + required this._outputPreferences, + }); final Logger _logger; diff --git a/packages/flutter_tools/lib/src/base/common.dart b/packages/flutter_tools/lib/src/base/common.dart index 81f3d4e7..ebd556d3 100644 --- a/packages/flutter_tools/lib/src/base/common.dart +++ b/packages/flutter_tools/lib/src/base/common.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + /// Throws a specialized exception to exit with an actionable [message]. /// /// A [ToolExit] is interpreted by the `flutter` tool to mean "exit the tool @@ -46,3 +48,45 @@ final class ToolExit implements Exception { @override String toString() => 'Error: $message'; } + +/// [ToolExit] extensions on [Completer]. +extension CompleterToolExit on Completer { + /// Completes this completer with a [ToolExit] error carrying [message] and + /// [exitCode]. + /// + /// Equivalent to catching the exception thrown by [throwToolExit] and + /// forwarding it to [Completer.completeError]. + void completeWithToolExit(String? message, {int? exitCode}) { + completeError(ToolExit._(message, exitCode: exitCode), StackTrace.current); + } +} + +/// Error handling extensions on [Future]. +extension FutureErrorHandling on Future { + /// Handles errors on this future without requiring an empty `.then` callback. + /// + /// Useful for guarding background sink/socket completion futures + /// (e.g., `socket.done`, `sink.done`) against unhandled zone exceptions. + /// + /// This uses `Future.then` with a statically typed `(Object, StackTrace)` + /// `onError` callback rather than `Future.catchError` or `Future.onError`, + /// which are banned across `flutter_tools` by `AvoidFutureCatchError` + /// (`dev/bots/custom_rules/avoid_future_catcherror.dart`, added in + /// https://github.com/flutter/flutter/pull/130662) because they are not + /// statically type-safe (see https://github.com/dart-lang/sdk/issues/51248). + Future handleError( + void Function(Object error, StackTrace stackTrace) onError, { + bool Function(Object error)? test, + }) { + return then( + (_) {}, + onError: (Object error, StackTrace stackTrace) { + if (test == null || test(error)) { + onError(error, stackTrace); + } else { + Error.throwWithStackTrace(error, stackTrace); + } + }, + ); + } +} diff --git a/packages/flutter_tools/lib/src/base/dds.dart b/packages/flutter_tools/lib/src/base/dds.dart index e498bdef..744910b1 100644 --- a/packages/flutter_tools/lib/src/base/dds.dart +++ b/packages/flutter_tools/lib/src/base/dds.dart @@ -22,19 +22,18 @@ import 'utils.dart'; export 'package:dds/dds.dart' show DartDevelopmentServiceException, ExistingDartDevelopmentServiceException; -typedef DDSLauncherCallback = - Future Function({ - required Uri remoteVmServiceUri, - String? appName, - Uri? serviceUri, - bool enableAuthCodes, - bool serveDevTools, - Uri? devToolsServerAddress, - bool enableServicePortFallback, - List cachedUserTags, - String? dartExecutable, - String? google3WorkspaceRoot, - }); +typedef DDSLauncherCallback = Future Function({ + required Uri remoteVmServiceUri, + String? appName, + Uri? serviceUri, + bool enableAuthCodes, + bool serveDevTools, + Uri? devToolsServerAddress, + bool enableServicePortFallback, + List cachedUserTags, + String? dartExecutable, + String? google3WorkspaceRoot, +}); typedef StartChromeCallback = Future Function(List urls, {List args}); @@ -177,7 +176,15 @@ mixin DartDevelopmentServiceLocalOperationsMixin { } assert(devToolsUri != null); logger.printStatus('Launching Flutter DevTools for ${device.device!.name} at $devToolsUri'); - unawaited(startChrome([devToolsUri!.toString()])); + Future launchChrome() async { + try { + await startChrome([devToolsUri!.toString()]); + } on Exception catch (error) { + logger.printError('Failed to launch DevTools in browser: $error'); + } + } + + unawaited(launchChrome()); return true; } diff --git a/packages/flutter_tools/lib/src/base/error_handling_io.dart b/packages/flutter_tools/lib/src/base/error_handling_io.dart index e1a96d2f..aa13cd24 100644 --- a/packages/flutter_tools/lib/src/base/error_handling_io.dart +++ b/packages/flutter_tools/lib/src/base/error_handling_io.dart @@ -35,9 +35,6 @@ import 'platform.dart'; // ToolExit and a message that is more clear than the FileSystemException by // itself. -/// On Windows this is error code 0: ERROR_SUCCESS. -const int kSystemCodeSuccess = 0; - /// On Windows this is error code 1: ERROR_INVALID_FUNCTION. const int kSystemCodeInvalidFunction = 1; @@ -59,10 +56,6 @@ const int kSystemCodeSharingViolation = 32; /// On Windows this is error code 33: ERROR_LOCK_VIOLATION. const int kSystemCodeLockViolation = 33; -/// On Windows this is error code 112: ERROR_DISK_FULL, and on -/// macOS/Linux, it is error code 28/ENOSPC: No space left on device. -const int kSystemCodeDeviceFull = 112; - /// On Windows this is error code 1224: ERROR_USER_MAPPED_FILE. const int kSystemCodeUserMappedSectionOpened = 1224; @@ -80,9 +73,8 @@ const int kSystemCodePrivilegeNotHeld = 1314; /// example, the tool should generally be able to continue executing even if it /// fails to delete a file. class ErrorHandlingFileSystem extends ForwardingFileSystem { - ErrorHandlingFileSystem({required FileSystem delegate, required Platform platform}) - : _platform = platform, - super(delegate); + ErrorHandlingFileSystem({required FileSystem delegate, required this._platform}) + : super(delegate); FileSystem get fileSystem => delegate; @@ -224,8 +216,7 @@ class ErrorHandlingFileSystem extends ForwardingFileSystem { } class ErrorHandlingFile extends ForwardingFileSystemEntity with ForwardingFile { - ErrorHandlingFile({required Platform platform, required this.fileSystem, required this.delegate}) - : _platform = platform; + ErrorHandlingFile({required this._platform, required this.fileSystem, required this.delegate}); @override final io.File delegate; @@ -626,10 +617,10 @@ class ErrorHandlingFile extends ForwardingFileSystemEntity with F class ErrorHandlingDirectory extends ForwardingFileSystemEntity with ForwardingDirectory { ErrorHandlingDirectory({ - required Platform platform, + required this._platform, required this.fileSystem, required this.delegate, - }) : _platform = platform; + }); @override final io.Directory delegate; @@ -845,8 +836,7 @@ class ErrorHandlingDirectory extends ForwardingFileSystemEntity with ForwardingLink { - ErrorHandlingLink({required Platform platform, required this.fileSystem, required this.delegate}) - : _platform = platform; + ErrorHandlingLink({required this._platform, required this.fileSystem, required this.delegate}); @override final io.Link delegate; @@ -1189,16 +1179,14 @@ T _runSync( /// * [ErrorHandlingFileSystem], for a similar file system strategy. class ErrorHandlingProcessManager extends ProcessManager { ErrorHandlingProcessManager({ - required ProcessManager delegate, - required Platform platform, + required this._delegate, + required this._platform, /// A lazy callback to prevent eager circular dependency cycles during early /// bootstrapping of the Flutter CLI (where `Analytics` depends on /// `FlutterVersion`, which executes git process commands during construction). - required Analytics Function() analytics, - }) : _delegate = delegate, - _platform = platform, - _analytics = analytics; + required this._analytics, + }); final ProcessManager _delegate; final Platform _platform; @@ -1238,9 +1226,28 @@ class ErrorHandlingProcessManager extends ProcessManager { } // Propagate the unified analytics suppression flag. - if (analytics != null) { - environmentResult[DashEnvVar.suppressAnalytics.name] = (!analytics.telemetryEnabled) - .toString(); + // + // If suppression was requested, either by this process's environment or by + // the caller-supplied environment, always propagate it so that sub-tools + // never report when a root tool has asked them not to. + // + // Otherwise (including an inherited or caller-supplied value other than + // `true`), suppress sub-tools whenever this tool would not send analytics + // itself. Unlike `telemetryEnabled`, `okToSend` is also false until this + // tool's consent message has been shown, including for the rest of the + // invocation that shows it (a first run, or a consent message version + // bump), and when analytics are suppressed for this invocation + // (`--suppress-analytics`). + // + // Like `package:unified_analytics`, only the exact (case-sensitive) string + // `true` is treated as a request to suppress analytics. + final String suppressKey = DashEnvVar.suppressAnalytics.name; + final bool suppressionRequested = + _platform.environment[suppressKey] == 'true' || environmentResult[suppressKey] == 'true'; + if (suppressionRequested) { + environmentResult[suppressKey] = 'true'; + } else if (analytics != null) { + environmentResult[suppressKey] = (!analytics.okToSend).toString(); } // Propagate the parent tool identifier down to the spawned process. @@ -1374,8 +1381,9 @@ void _handlePosixException( // https://github.com/apple/darwin-xnu/blob/main/bsd/dev/dtrace/scripts/errno.d const eperm = 1; const enoent = 2; - const enospc = 28; const eacces = 13; + const enospc = 28; + const erofs = 30; // Catch errors and bail when: final String? errorMessage = switch (errorCode) { enoent => @@ -1387,6 +1395,17 @@ void _handlePosixException( '$message. The target device is full.' '\n$e\n' 'Free up space and try again.', + erofs => () { + final errorBuffer = StringBuffer(); + if (message != null && message.isNotEmpty) { + errorBuffer.writeln('$message.'); + } + errorBuffer.writeln( + 'The file system is read-only. Please ensure that the SDK and/or project ' + 'is installed in a location with write permissions.', + ); + return errorBuffer.toString().trim(); + }(), eperm || eacces => () { final errorBuffer = StringBuffer(); if (message != null && message.isNotEmpty) { @@ -1450,13 +1469,18 @@ void _handleWindowsException(Exception e, String? message, int errorCode) { // https://docs.microsoft.com/en-us/windows/win32/debug/system-error-codes const kFileNotFound = 2; const kPathNotFound = 3; - const kDeviceFull = 112; + const kAccessDenied = 5; + const kWriteProtect = 19; const kSharingViolation = 32; const kLockViolation = 33; - const kUserMappedSectionOpened = 1224; - const kAccessDenied = 5; - const kFatalDeviceHardwareError = 483; + const kDeviceFull = 112; const kDeviceDoesNotExist = 433; + const kSystemIntegrityPolicyViolation = 454; + const kFatalDeviceHardwareError = 483; + const kUserMappedSectionOpened = 1224; + const kAccessDisabledByPolicy = 1260; + const kPrivilegeNotHeld = 1314; + const kApplicationControlPolicyBlocked = 4551; // Catch errors and bail when: final String? errorMessage = switch (errorCode) { @@ -1465,7 +1489,7 @@ void _handleWindowsException(Exception e, String? message, int errorCode) { '\n$e\n' 'This can sometimes happen if the file was deleted or moved while the tool was running.' ' Try running "flutter clean" and try again.', - kAccessDenied => + kAccessDenied || kWriteProtect || kPrivilegeNotHeld => '$message. The flutter tool cannot access the file or directory.\n' 'Please ensure that the SDK and/or project is installed in a location ' 'that has read/write permissions for the current user.', @@ -1485,6 +1509,13 @@ void _handleWindowsException(Exception e, String? message, int errorCode) { '$message. The device was not found.' '\n$e\n' 'Verify the device is mounted and try again.', + kApplicationControlPolicyBlocked || + kAccessDisabledByPolicy || + kSystemIntegrityPolicyViolation => + '${message != null ? "$message. " : ""}An Application Control policy or security policy has blocked execution.\n' + '$e\n' + 'Please verify your Windows Security Smart App Control, Application Control policy (WDAC), ' + 'or Group Policy settings, or add an exclusion for the Flutter SDK directory.', _ => null, }; _throwFileSystemException(errorMessage); diff --git a/packages/flutter_tools/lib/src/base/file_system.dart b/packages/flutter_tools/lib/src/base/file_system.dart index 53724f70..1ff24241 100644 --- a/packages/flutter_tools/lib/src/base/file_system.dart +++ b/packages/flutter_tools/lib/src/base/file_system.dart @@ -33,9 +33,7 @@ class FileNotFoundException implements IOException { /// Various convenience file system methods. class FileSystemUtils { - FileSystemUtils({required FileSystem fileSystem, required Platform platform}) - : _fileSystem = fileSystem, - _platform = platform; + FileSystemUtils({required this._fileSystem, required this._platform}); final FileSystem _fileSystem; diff --git a/packages/flutter_tools/lib/src/base/fingerprint.dart b/packages/flutter_tools/lib/src/base/fingerprint.dart index 1400f25b..ec02fe90 100644 --- a/packages/flutter_tools/lib/src/base/fingerprint.dart +++ b/packages/flutter_tools/lib/src/base/fingerprint.dart @@ -19,11 +19,9 @@ class Fingerprinter { Fingerprinter({ required this.fingerprintPath, required Iterable paths, - required FileSystem fileSystem, - required Logger logger, - }) : _paths = paths.toList(), - _logger = logger, - _fileSystem = fileSystem; + required this._fileSystem, + required this._logger, + }) : _paths = paths.toList(); final String fingerprintPath; final List _paths; diff --git a/packages/flutter_tools/lib/src/base/io.dart b/packages/flutter_tools/lib/src/base/io.dart index 31ee7fc9..540264f8 100644 --- a/packages/flutter_tools/lib/src/base/io.dart +++ b/packages/flutter_tools/lib/src/base/io.dart @@ -124,10 +124,7 @@ export 'dart:io' /// [ProcessSignal] instances are available on this class (e.g. "send"). class ProcessSignal { @visibleForTesting - const ProcessSignal( - this._delegate, { - @visibleForTesting Platform platform = const LocalPlatform(), - }) : _platform = platform; + const ProcessSignal(this._delegate, {@visibleForTesting this._platform = const LocalPlatform()}); static const ProcessSignal sighup = PosixProcessSignal(io.ProcessSignal.sighup); static const ProcessSignal sigwinch = PosixProcessSignal(io.ProcessSignal.sigwinch); @@ -294,26 +291,28 @@ class Stdio { bool get supportsAnsiEscapes => hasTerminal && stdout.supportsAnsiEscapes; /// Writes [message] to [stderr], falling back on [fallback] if the write - /// throws any exception. The default fallback calls [print] on [message]. + /// throws any exception. The default fallback prints [message] to the console, + /// ignoring any errors. void stderrWrite(String message, {void Function(String, dynamic, StackTrace)? fallback}) { if (!_stderrDone) { _stdioWrite(stderr, message, fallback: fallback); return; } fallback == null - ? print(message) + ? _safePrint(message) : fallback(message, const io.StdoutException('stderr is done'), StackTrace.current); } /// Writes [message] to [stdout], falling back on [fallback] if the write - /// throws any exception. The default fallback calls [print] on [message]. + /// throws any exception. The default fallback prints [message] to the console, + /// ignoring any errors. void stdoutWrite(String message, {void Function(String, dynamic, StackTrace)? fallback}) { if (!_stdoutDone) { _stdioWrite(stdout, message, fallback: fallback); return; } fallback == null - ? print(message) + ? _safePrint(message) : fallback(message, const io.StdoutException('stdout is done'), StackTrace.current); } @@ -329,7 +328,7 @@ class Stdio { }, onError: (Object error, StackTrace stackTrace) { if (fallback == null) { - print(message); + _safePrint(message); } else { fallback(message, error, stackTrace); } @@ -337,6 +336,14 @@ class Stdio { ); } + void _safePrint(String message) { + try { + print(message); + } on Exception catch (_) { + // If print also throws (e.g. broken pipe on stdout), we ignore it. + } + } + /// Adds [stream] to [stdout]. Future addStdoutStream(Stream> stream) => stdout.addStream(stream); @@ -412,12 +419,11 @@ class NetworkInterface implements io.NetworkInterface { String toString() => "NetworkInterface('$name', $addresses)"; } -typedef NetworkInterfaceLister = - Future> Function({ - bool includeLoopback, - bool includeLinkLocal, - io.InternetAddressType type, - }); +typedef NetworkInterfaceLister = Future> Function({ + bool includeLoopback, + bool includeLinkLocal, + io.InternetAddressType type, +}); NetworkInterfaceLister? _networkInterfaceListerOverride; diff --git a/packages/flutter_tools/lib/src/base/logger.dart b/packages/flutter_tools/lib/src/base/logger.dart index 7a821215..d32b893d 100644 --- a/packages/flutter_tools/lib/src/base/logger.dart +++ b/packages/flutter_tools/lib/src/base/logger.dart @@ -427,12 +427,10 @@ T asLogger(Logger logger) { class StdoutLogger extends Logger { StdoutLogger({ required this.terminal, - required Stdio stdio, - required OutputPreferences outputPreferences, - StopwatchFactory stopwatchFactory = const StopwatchFactory(), - }) : _stdio = stdio, - _outputPreferences = outputPreferences, - _stopwatchFactory = stopwatchFactory; + required this._stdio, + required this._outputPreferences, + this._stopwatchFactory = const StopwatchFactory(), + }); @override final Terminal terminal; @@ -747,22 +745,19 @@ class WindowsStdoutLogger extends StdoutLogger { class BufferLogger extends Logger { BufferLogger({ required this.terminal, - required OutputPreferences outputPreferences, - StopwatchFactory stopwatchFactory = const StopwatchFactory(), - bool verbose = false, - }) : _outputPreferences = outputPreferences, - _stopwatchFactory = stopwatchFactory, - _verbose = verbose; + required this._outputPreferences, + this._stopwatchFactory = const StopwatchFactory(), + this._verbose = false, + }); /// Create a [BufferLogger] with test preferences. BufferLogger.test({ Terminal? terminal, OutputPreferences? outputPreferences, - bool verbose = false, + this._verbose = false, }) : terminal = terminal ?? Terminal.test(), _outputPreferences = outputPreferences ?? OutputPreferences.test(), - _stopwatchFactory = const StopwatchFactory(), - _verbose = verbose; + _stopwatchFactory = const StopwatchFactory(); @override final OutputPreferences _outputPreferences; @@ -1156,10 +1151,7 @@ typedef SlowWarningCallback = String Function(); /// Generally, consider `logger.startProgress` instead of directly creating /// a [Status] or one of its subclasses. abstract class Status { - Status({VoidCallback? onFinish, required Stopwatch stopwatch, Duration? timeout}) - : _timeout = timeout, - _onFinish = onFinish, - _stopwatch = stopwatch; + Status({this._onFinish, required this._stopwatch, this._timeout}); final VoidCallback? _onFinish; final Duration? _timeout; @@ -1219,19 +1211,17 @@ const _kTimePadding = 8; // should fit "99,999ms" /// A version of [Status] that just outputs a summary. class SummaryStatus extends Status { - /// Writes [message] to [stdio]. + /// Writes [_message] to [_stdio]. /// /// On [cancel] or [stop], will call [onFinish]. /// On [stop], will additionally print out summary information. SummaryStatus({ - String message = '', + this._message = '', required super.stopwatch, - int padding = kDefaultStatusPadding, + this._padding = kDefaultStatusPadding, super.onFinish, - required Stdio stdio, - }) : _padding = padding, - _message = message, - _stdio = stdio; + required this._stdio, + }); final String _message; final int _padding; @@ -1289,15 +1279,12 @@ class AnonymousSpinnerStatus extends Status { AnonymousSpinnerStatus({ super.onFinish, required super.stopwatch, - required Stdio stdio, + required this._stdio, required Terminal terminal, - SlowWarningCallback? slowWarningCallback, - TerminalColor? warningColor, + this._slowWarningCallback, + this._warningColor, super.timeout, - }) : _warningColor = warningColor, - _slowWarningCallback = slowWarningCallback, - _stdio = stdio, - _terminal = terminal, + }) : _terminal = terminal, _animation = _selectAnimation(terminal); final Stdio _stdio; @@ -1440,7 +1427,7 @@ class AnonymousSpinnerStatus extends Status { /// An animated version of [Status]. class SpinnerStatus extends AnonymousSpinnerStatus { - /// Writes [message] to [stdio] with padding, then starts an + /// Writes [_message] to [stdio] with padding, then starts an /// indeterminate progress indicator animation. /// /// On [cancel] or [stop], will call [onFinish]. @@ -1448,14 +1435,13 @@ class SpinnerStatus extends AnonymousSpinnerStatus { /// /// Call [pause] before outputting any text while this is running. SpinnerStatus({ - required String message, - int padding = kDefaultStatusPadding, + required this._message, + this._padding = kDefaultStatusPadding, super.onFinish, required super.stopwatch, required super.stdio, required super.terminal, - }) : _padding = padding, - _message = message; + }); final String _message; final int _padding; diff --git a/packages/flutter_tools/lib/src/base/multi_root_file_system.dart b/packages/flutter_tools/lib/src/base/multi_root_file_system.dart index 445e129c..4310b849 100644 --- a/packages/flutter_tools/lib/src/base/multi_root_file_system.dart +++ b/packages/flutter_tools/lib/src/base/multi_root_file_system.dart @@ -35,10 +35,9 @@ import 'package:path/path.dart' as p; // flutter_ignore: package_path_import class MultiRootFileSystem extends ForwardingFileSystem { MultiRootFileSystem({ required FileSystem delegate, - required String scheme, + required this._scheme, required List roots, }) : assert(roots.isNotEmpty), - _scheme = scheme, _roots = roots.map((String root) => delegate.path.normalize(root)).toList(), super(delegate); diff --git a/packages/flutter_tools/lib/src/base/net.dart b/packages/flutter_tools/lib/src/base/net.dart index 24882490..03828671 100644 --- a/packages/flutter_tools/lib/src/base/net.dart +++ b/packages/flutter_tools/lib/src/base/net.dart @@ -22,10 +22,8 @@ typedef UrlTunneller = Future Function(String url); class Net { /// If [httpClientFactory] is `null`, a default [HttpClient] is used. - Net({HttpClientFactory? httpClientFactory, required Logger logger, required Platform platform}) - : _httpClientFactory = httpClientFactory ?? (() => HttpClient()), - _logger = logger, - _platform = platform; + Net({HttpClientFactory? httpClientFactory, required this._logger, required this._platform}) + : _httpClientFactory = httpClientFactory ?? (() => HttpClient()); final HttpClientFactory _httpClientFactory; diff --git a/packages/flutter_tools/lib/src/base/os.dart b/packages/flutter_tools/lib/src/base/os.dart index 190fae88..e38a5196 100644 --- a/packages/flutter_tools/lib/src/base/os.dart +++ b/packages/flutter_tools/lib/src/base/os.dart @@ -62,16 +62,13 @@ abstract class OperatingSystemUtils { } OperatingSystemUtils._private({ - required FileSystem fileSystem, + required this._fileSystem, required Logger logger, - required Platform platform, + required this._platform, required ProcessManager processManager, - required Abi currentAbi, - }) : _fileSystem = fileSystem, - _logger = logger, - _platform = platform, + required this._currentAbi, + }) : _logger = logger, _processManager = processManager, - _currentAbi = currentAbi, _processUtils = ProcessUtils(logger: logger, processManager: processManager); final Abi _currentAbi; @@ -664,7 +661,3 @@ enum HostPlatform { }; } } - -// flutter_ignore: deprecation_syntax (see analyze.dart) -@Deprecated('Use HostPlatform.cliName instead') -String getNameForHostPlatform(HostPlatform platform) => platform.cliName; diff --git a/packages/flutter_tools/lib/src/base/process.dart b/packages/flutter_tools/lib/src/base/process.dart index 2bcaa39d..c3124cd5 100644 --- a/packages/flutter_tools/lib/src/base/process.dart +++ b/packages/flutter_tools/lib/src/base/process.dart @@ -183,6 +183,10 @@ abstract class ProcessUtils { /// /// If [timeout] is supplied, the command will be retried [timeoutRetries] times /// if it times out. + /// + /// The child process's stdout and stderr are decoded with [encoding], which + /// defaults to the system encoding. When [timeout] is supplied, they are + /// always decoded as UTF-8. Future run( List cmd, { bool throwOnError = false, @@ -192,6 +196,7 @@ abstract class ProcessUtils { Map? environment, Duration? timeout, int timeoutRetries = 0, + Encoding encoding = systemEncoding, }); /// Run the command and block waiting for its result. @@ -336,9 +341,7 @@ abstract class ProcessUtils { } class _DefaultProcessUtils implements ProcessUtils { - _DefaultProcessUtils({required ProcessManager processManager, required Logger logger}) - : _processManager = processManager, - _logger = logger; + _DefaultProcessUtils({required this._processManager, required this._logger}); final ProcessManager _processManager; @@ -354,6 +357,7 @@ class _DefaultProcessUtils implements ProcessUtils { Map? environment, Duration? timeout, int timeoutRetries = 0, + Encoding encoding = systemEncoding, }) async { if (cmd.isEmpty) { throw ArgumentError('cmd must be a non-empty list'); @@ -370,6 +374,8 @@ class _DefaultProcessUtils implements ProcessUtils { cmd, workingDirectory: workingDirectory, environment: _environment(allowReentrantFlutter, environment), + stdoutEncoding: encoding, + stderrEncoding: encoding, ); final runResult = RunResult(results, cmd); _logger.printTrace(runResult.toString()); diff --git a/packages/flutter_tools/lib/src/base/task_queue.dart b/packages/flutter_tools/lib/src/base/task_queue.dart deleted file mode 100644 index 76362af9..00000000 --- a/packages/flutter_tools/lib/src/base/task_queue.dart +++ /dev/null @@ -1,97 +0,0 @@ -// Copyright 2014 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:collection'; - -import '../globals.dart' as globals; - -/// A closure type used by the [TaskQueue]. -typedef TaskQueueClosure = Future Function(); - -/// A task queue of Futures to be completed in parallel, throttling -/// the number of simultaneous tasks. -/// -/// The tasks return results of type T. -class TaskQueue { - /// Creates a task queue with a maximum number of simultaneous jobs. - /// The [maxJobs] parameter defaults to the number of CPU cores on the - /// system. - TaskQueue({int? maxJobs}) : maxJobs = maxJobs ?? globals.platform.numberOfProcessors; - - /// The maximum number of jobs that this queue will run simultaneously. - final int maxJobs; - - final _pendingTasks = Queue<_TaskQueueItem>(); - final _activeTasks = <_TaskQueueItem>{}; - final _completeListeners = >{}; - - /// Returns a future that completes when all tasks in the [TaskQueue] are - /// complete. - Future get tasksComplete { - // In case this is called when there are no tasks, we want it to - // signal complete immediately. - if (_activeTasks.isEmpty && _pendingTasks.isEmpty) { - return Future.value(); - } - final completer = Completer(); - _completeListeners.add(completer); - return completer.future; - } - - /// Adds a single closure to the task queue, returning a future that - /// completes when the task completes. - Future add(TaskQueueClosure task) { - final completer = Completer(); - _pendingTasks.add(_TaskQueueItem(task, completer)); - if (_activeTasks.length < maxJobs) { - _processTask(); - } - return completer.future; - } - - // Process a single task. - void _processTask() { - if (_pendingTasks.isNotEmpty && _activeTasks.length <= maxJobs) { - final _TaskQueueItem item = _pendingTasks.removeFirst(); - _activeTasks.add(item); - item.onComplete = () { - _activeTasks.remove(item); - _processTask(); - }; - item.run(); - } else { - _checkForCompletion(); - } - } - - void _checkForCompletion() { - if (_activeTasks.isEmpty && _pendingTasks.isEmpty) { - for (final Completer completer in _completeListeners) { - if (!completer.isCompleted) { - completer.complete(); - } - } - _completeListeners.clear(); - } - } -} - -class _TaskQueueItem { - _TaskQueueItem(this._closure, this._completer); - - final TaskQueueClosure _closure; - final Completer _completer; - void Function()? onComplete; - - Future run() async { - try { - _completer.complete(await _closure()); - } catch (e) { - _completer.completeError(e); - } finally { - onComplete?.call(); - } - } -} diff --git a/packages/flutter_tools/lib/src/base/terminal.dart b/packages/flutter_tools/lib/src/base/terminal.dart index 244e951d..b00825c6 100644 --- a/packages/flutter_tools/lib/src/base/terminal.dart +++ b/packages/flutter_tools/lib/src/base/terminal.dart @@ -162,14 +162,12 @@ abstract class Terminal { class AnsiTerminal implements Terminal { AnsiTerminal({ - required io.Stdio stdio, - required Platform platform, + required this._stdio, + required this._platform, DateTime? now, // Time used to determine preferredStyle. Defaults to 0001-01-01 00:00. bool defaultCliAnimationEnabled = true, ShutdownHooks? shutdownHooks, - }) : _stdio = stdio, - _platform = platform, - _now = now ?? DateTime(1), + }) : _now = now ?? DateTime(1), _isCliAnimationEnabled = defaultCliAnimationEnabled { shutdownHooks?.addShutdownHook(() { singleCharMode = false; diff --git a/packages/flutter_tools/lib/src/base/time.dart b/packages/flutter_tools/lib/src/base/time.dart index d0778566..14bb6c59 100644 --- a/packages/flutter_tools/lib/src/base/time.dart +++ b/packages/flutter_tools/lib/src/base/time.dart @@ -27,17 +27,3 @@ class _FixedTimeClock extends SystemClock { @override DateTime now() => _fixedTime; } - -/// Format time as 'yyyy-MM-dd HH:mm:ss Z' where Z is the difference between the -/// timezone of t and UTC formatted according to RFC 822. -String formatDateTime(DateTime t) { - final sign = t.timeZoneOffset.isNegative ? '-' : '+'; - final Duration tzOffset = t.timeZoneOffset.abs(); - final int hoursOffset = tzOffset.inHours; - final int minutesOffset = tzOffset.inMinutes - (Duration.minutesPerHour * hoursOffset); - assert(hoursOffset < 24); - assert(minutesOffset < 60); - - String twoDigits(int n) => (n >= 10) ? '$n' : '0$n'; - return '$t $sign${twoDigits(hoursOffset)}${twoDigits(minutesOffset)}'; -} diff --git a/packages/flutter_tools/lib/src/base/user_messages.dart b/packages/flutter_tools/lib/src/base/user_messages.dart index 40d56b8f..608e75b5 100644 --- a/packages/flutter_tools/lib/src/base/user_messages.dart +++ b/packages/flutter_tools/lib/src/base/user_messages.dart @@ -161,8 +161,8 @@ class UserMessages { String pkgConfigTooOld(String minimumVersion) => 'pkg-config $minimumVersion or later is required.'; String get gtkLibrariesMissing => - 'GLib development libraries are required for Linux development.\n' - 'They are likely available from your distribution (e.g.: apt install libglib2.0-dev)'; + 'GTK 3.0 development libraries are required for Linux development.\n' + 'They are likely available from your distribution (e.g.: apt install libgtk-3-dev)'; String get eglinfoMissing => "Unable to access driver information using 'eglinfo'.\n" 'It is likely available from your distribution (e.g.: apt install mesa-utils)'; diff --git a/packages/flutter_tools/lib/src/base/utils.dart b/packages/flutter_tools/lib/src/base/utils.dart index 1fb41841..9cb792f6 100644 --- a/packages/flutter_tools/lib/src/base/utils.dart +++ b/packages/flutter_tools/lib/src/base/utils.dart @@ -2,11 +2,13 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +/// @docImport 'common.dart'; /// @docImport 'terminal.dart'; library; import 'dart:async'; import 'dart:math' as math; +import 'dart:typed_data'; import 'package:file/file.dart'; import 'package:intl/intl.dart' as intl; @@ -647,3 +649,57 @@ List formatTable(List> table, {String separator = ' • ', return '$indentString$formatted'; }).toList(); } + +/// Decodes a list of bytes into a string, supporting UTF-8 (with or without +/// BOM) and UTF-16 LE/BE (with BOM). +/// +/// Inspects leading Byte Order Mark (BOM) signatures in [bytes] to determine +/// the encoding: +/// +/// * **UTF-16 LE** (`0xFF, 0xFE`): Strips the 2-byte BOM and decodes the +/// remaining payload as 16-bit little-endian code units. +/// * **UTF-16 BE** (`0xFE, 0xFF`): Strips the 2-byte BOM and decodes the +/// remaining payload as 16-bit big-endian code units. +/// * **UTF-8 with BOM** (`0xEF, 0xBB, 0xBF`): Strips the 3-byte BOM and decodes +/// the remaining payload as strict UTF-8. +/// * **Default UTF-8** (no BOM): Decodes the entire byte list as strict UTF-8. +/// +/// Throws a [FormatException] if a UTF-16 byte payload has an odd length after +/// stripping the BOM, or a [ToolExit] if strict UTF-8 decoding fails. +String decodeUtf8OrUtf16(List bytes) { + // Avoid using list pattern matching here (e.g., `[0xFF, 0xFE, ...final payload]`) + // as the rest pattern allocates a copied sublist for the payload. + if (bytes.length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE) { + return _decodeUtf16(bytes, 2, Endian.little); + } + if (bytes.length >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF) { + return _decodeUtf16(bytes, 2, Endian.big); + } + if (bytes.length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF) { + return utf8.decode(bytes.sublist(3)); + } + return utf8.decode(bytes); +} + +/// Decodes a UTF-16 byte list [bytes] starting from [offset] after its BOM has +/// been stripped. +/// +/// Reads 16-bit integers according to the specified byte [endian] (either +/// [Endian.little] or [Endian.big]). +/// +/// Throws a [FormatException] if the payload length has an odd number of bytes, +/// as each UTF-16 code unit requires exactly 2 bytes. +String _decodeUtf16(List bytes, int offset, Endian endian) { + final int length = bytes.length - offset; + if (length.isOdd) { + throw const FormatException('UTF-16 data length must be even after BOM'); + } + final Uint8List uint8List = bytes is Uint8List ? bytes : Uint8List.fromList(bytes); + final byteData = ByteData.sublistView(uint8List, offset); + final int count = length ~/ 2; + final codeUnits = Uint16List(count); + for (var i = 0; i < count; i++) { + codeUnits[i] = byteData.getUint16(i * 2, endian); + } + return String.fromCharCodes(codeUnits); +} diff --git a/packages/flutter_tools/lib/src/build_info.dart b/packages/flutter_tools/lib/src/build_info.dart index 6f2b277c..f43c6cde 100644 --- a/packages/flutter_tools/lib/src/build_info.dart +++ b/packages/flutter_tools/lib/src/build_info.dart @@ -5,6 +5,7 @@ /// @docImport 'build_system/build_system.dart'; library; +import 'package:flutter_tools_core/flutter_tools_core.dart' as tools_core; import 'package:meta/meta.dart'; import 'package:package_config/package_config_types.dart'; @@ -14,11 +15,9 @@ import 'base/config.dart'; import 'base/file_system.dart'; import 'base/logger.dart'; import 'base/os.dart'; -import 'base/utils.dart'; import 'convert.dart'; import 'darwin/darwin.dart'; import 'globals.dart' as globals; -import 'runner/flutter_command.dart' show FlutterOptions; /// Whether icon font subsetting is enabled by default. const kIconTreeShakerEnabledDefault = true; @@ -33,8 +32,6 @@ class BuildInfo { List? extraFrontEndOptions, List? extraGenSnapshotOptions, List? fileSystemRoots, - this.androidProjectArgs = const [], - this.androidGradleProjectCacheDir, this.fileSystemScheme, this.buildNumber, this.buildName, @@ -46,17 +43,14 @@ class BuildInfo { this.performanceMeasurementFile, required this.packageConfigPath, this.codeSizeDirectory, - this.androidGradleDaemon = true, - this.androidSkipBuildDependencyValidation = false, - this.androidEnableHcpp, - this.explicitAndroidEnableHcpp, + this.androidGradleConfig = const AndroidGradleConfig(), this.packageConfig = PackageConfig.empty, this.initializeFromDill, this.assumeInitializeFromDillUpToDate = false, this.buildNativeAssets = true, this.useLocalCanvasKit = false, this.includeUnsupportedPlatformLibraryStubs = false, - this.webEnableHotReload = false, + this.deprecatedJsInterop, }) : extraFrontEndOptions = extraFrontEndOptions ?? const [], extraGenSnapshotOptions = extraGenSnapshotOptions ?? const [], fileSystemRoots = fileSystemRoots ?? const [], @@ -81,8 +75,6 @@ class BuildInfo { extraFrontEndOptions: extraFrontEndOptions ?? this.extraFrontEndOptions, extraGenSnapshotOptions: extraGenSnapshotOptions, fileSystemRoots: fileSystemRoots ?? this.fileSystemRoots, - androidProjectArgs: androidProjectArgs, - androidGradleProjectCacheDir: androidGradleProjectCacheDir, fileSystemScheme: fileSystemScheme ?? this.fileSystemScheme, buildNumber: buildNumber, buildName: buildName, @@ -93,10 +85,7 @@ class BuildInfo { performanceMeasurementFile: performanceMeasurementFile, packageConfigPath: packageConfigPath ?? this.packageConfigPath, codeSizeDirectory: codeSizeDirectory, - androidGradleDaemon: androidGradleDaemon, - androidSkipBuildDependencyValidation: androidSkipBuildDependencyValidation, - androidEnableHcpp: androidEnableHcpp, - explicitAndroidEnableHcpp: explicitAndroidEnableHcpp, + androidGradleConfig: androidGradleConfig, packageConfig: packageConfig ?? this.packageConfig, initializeFromDill: initializeFromDill ?? this.initializeFromDill, assumeInitializeFromDillUpToDate: assumeInitializeFromDillUpToDate, @@ -104,7 +93,7 @@ class BuildInfo { useLocalCanvasKit: useLocalCanvasKit, includeUnsupportedPlatformLibraryStubs: includeUnsupportedPlatformLibraryStubs ?? this.includeUnsupportedPlatformLibraryStubs, - webEnableHotReload: webEnableHotReload, + deprecatedJsInterop: deprecatedJsInterop, treeShakeIcons: treeShakeIcons, ); } @@ -187,50 +176,8 @@ class BuildInfo { /// will be written for code size profiling. final String? codeSizeDirectory; - /// Whether to enable the Gradle daemon when performing an Android build. - /// - /// Starting the daemon is the default behavior of the gradle wrapper script created - /// in a Flutter project. Setting this value to false will cause the tool to pass - /// `--no-daemon` to the gradle wrapper script, preventing it from spawning a daemon - /// process. - /// - /// For one-off builds or CI systems, preventing the daemon from spawning will - /// reduce system resource usage, at the cost of any subsequent builds starting - /// up slightly slower. - /// - /// The Gradle daemon may also be disabled in the Android application's properties file. - final bool androidGradleDaemon; - - /// Whether to skip checking of individual versions of our Android build time - /// dependencies. - final bool androidSkipBuildDependencyValidation; - - /// The default `enable-hcpp` value (currently false unless the CLI flag was - /// passed), given to Gradle so the Flutter Gradle Plugin can inject the - /// corresponding manifest metadata if absent. - /// - /// The injection only happens for application projects, and only when the - /// merged manifest does not already contain the - /// `io.flutter.embedding.android.EnableHcpp` metadata, so a value in the - /// app's manifest takes priority over this one. Module (aar) manifests are - /// never injected; the add-to-app host's manifest is the source of truth. - /// When null, no property is passed and no injection happens. - final bool? androidEnableHcpp; - - /// The explicit `--[no-]enable-hcpp` value passed by the user on the CLI, or - /// null if the user did not pass the flag explicitly. - /// - /// Passed to Gradle, which writes it into the merged manifest over any value - /// already there, so it takes priority over both [androidEnableHcpp] and the - /// app's manifest. When null, the manifest decides. - final bool? explicitAndroidEnableHcpp; - - /// Additional key value pairs that are passed directly to the gradle project via the `-P` - /// flag. - final List androidProjectArgs; - - /// Specifies Gradle's project-specific cache directory. - final String? androidGradleProjectCacheDir; + /// Android and Gradle-specific configuration for the build. + final AndroidGradleConfig androidGradleConfig; /// The package configuration for the loaded application. /// @@ -258,8 +205,15 @@ class BuildInfo { /// If set, web builds will use the locally built CanvasKit instead of using the CDN final bool useLocalCanvasKit; - /// If set, web builds with DDC will run with support for hot reload. - final bool webEnableHotReload; + /// Whether the web compilers (dart2js and DDC) allow the deprecated JS + /// interop libraries, such as `dart:html` and `dart:js`. + /// + /// When `false`, importing these libraries is a compile-time error and + /// conditional imports on them resolve to `false`. When `null`, no flag is + /// passed and the compiler's default is used. + /// + /// See [deprecatedJsInteropCompilerFlags]. + final bool? deprecatedJsInterop; /// Can be used when the actual information is not needed. static const dummy = BuildInfo( @@ -339,43 +293,6 @@ class BuildInfo { /// so the uncapitalized flavor name is used to compute the output file name String? get uncapitalizedFlavor => _uncapitalize(flavor); - /// The module system DDC is targeting, or null if not using DDC or the - /// associated flag isn't present. - // TODO(markzipan): delete this when DDC's AMD module system is deprecated, https://github.com/flutter/flutter/issues/142060. - DdcModuleFormat get ddcModuleFormat { - final DdcModuleFormat moduleFormat = webEnableHotReload - ? DdcModuleFormat.ddc - : DdcModuleFormat.amd; - final DdcModuleFormat? parsedFormat = _ddcModuleFormatAndCanaryFeaturesFromFrontEndArgs( - extraFrontEndOptions, - ).ddcModuleFormat; - if (parsedFormat != null && moduleFormat != parsedFormat) { - throw Exception( - 'Unsupported option combination:\n' - '${FlutterOptions.kWebExperimentalHotReload}: $webEnableHotReload\n' - '${FlutterOptions.kExtraFrontEndOptions}: --dartdevc-module-format=${parsedFormat.name}', - ); - } - return moduleFormat; - } - - /// Whether to enable canary features when using DDC, or null if not using - /// DDC or the associated flag isn't present. - bool get canaryFeatures { - final bool canaryEnabled = webEnableHotReload; - final bool? parsedCanary = _ddcModuleFormatAndCanaryFeaturesFromFrontEndArgs( - extraFrontEndOptions, - ).canaryFeatures; - if (parsedCanary != null && canaryEnabled != parsedCanary) { - throw Exception( - 'Unsupported option combination:\n' - '${FlutterOptions.kWebExperimentalHotReload}: $webEnableHotReload\n' - '${FlutterOptions.kExtraFrontEndOptions}: --dartdevc-canary=$parsedCanary', - ); - } - return canaryEnabled; - } - /// Convert to a structured string encoded structure appropriate for usage /// in build system [Environment.defines]. /// @@ -411,48 +328,80 @@ class BuildInfo { return { if (dartDefines.isNotEmpty) 'DART_DEFINES': encodeDartDefines(dartDefines), 'DART_OBFUSCATION': dartObfuscation.toString(), - if (frontendServerStarterPath != null) - 'FRONTEND_SERVER_STARTER_PATH': frontendServerStarterPath!, + 'FRONTEND_SERVER_STARTER_PATH': ?frontendServerStarterPath, if (extraFrontEndOptions.isNotEmpty) 'EXTRA_FRONT_END_OPTIONS': extraFrontEndOptions.join(','), if (extraGenSnapshotOptions.isNotEmpty) 'EXTRA_GEN_SNAPSHOT_OPTIONS': extraGenSnapshotOptions.join(','), + 'BUILD_NAME': ?buildName, + 'BUILD_NUMBER': ?buildNumber, 'SPLIT_DEBUG_INFO': ?splitDebugInfoPath, 'TRACK_WIDGET_CREATION': trackWidgetCreation.toString(), 'TREE_SHAKE_ICONS': treeShakeIcons.toString(), - if (performanceMeasurementFile != null) - 'PERFORMANCE_MEASUREMENT_FILE': performanceMeasurementFile!, + 'PERFORMANCE_MEASUREMENT_FILE': ?performanceMeasurementFile, 'PACKAGE_CONFIG': packageConfigPath, 'CODE_SIZE_DIRECTORY': ?codeSizeDirectory, 'FLAVOR': ?flavor, }; } +} - /// Convert this config to a series of project level arguments to be passed - /// on the command line to gradle. - List toGradleConfig() { - // PACKAGE_CONFIG not currently supported. - return [ - if (dartDefines.isNotEmpty) '-Pdart-defines=${encodeDartDefines(dartDefines)}', - '-Pdart-obfuscation=$dartObfuscation', - if (frontendServerStarterPath != null) - '-Pfrontend-server-starter-path=$frontendServerStarterPath', - if (extraFrontEndOptions.isNotEmpty) - '-Pextra-front-end-options=${extraFrontEndOptions.join(',')}', - if (extraGenSnapshotOptions.isNotEmpty) - '-Pextra-gen-snapshot-options=${extraGenSnapshotOptions.join(',')}', - if (splitDebugInfoPath != null) '-Psplit-debug-info=$splitDebugInfoPath', - '-Ptrack-widget-creation=$trackWidgetCreation', - '-Ptree-shake-icons=$treeShakeIcons', - if (performanceMeasurementFile != null) - '-Pperformance-measurement-file=$performanceMeasurementFile', - if (codeSizeDirectory != null) '-Pcode-size-directory=$codeSizeDirectory', - if (androidEnableHcpp != null) '-Penable-hcpp=$androidEnableHcpp', - if (explicitAndroidEnableHcpp != null) '-Pexplicit-enable-hcpp=$explicitAndroidEnableHcpp', - for (final String projectArg in androidProjectArgs) '-P$projectArg', - if (androidGradleProjectCacheDir != null) '--project-cache-dir=$androidGradleProjectCacheDir', - ]; - } +/// Android and Gradle-specific configuration for a build. +@immutable +class AndroidGradleConfig { + const AndroidGradleConfig({ + this.projectArgs = const [], + this.projectCacheDir, + this.gradleDaemon = true, + this.skipBuildDependencyValidation = false, + this.enableHcpp, + this.explicitEnableHcpp, + }); + + /// Additional key value pairs that are passed directly to the gradle project via the `-P` + /// flag. + final List projectArgs; + + /// Specifies Gradle's project-specific cache directory. + final String? projectCacheDir; + + /// Whether to enable the Gradle daemon when performing an Android build. + /// + /// Starting the daemon is the default behavior of the gradle wrapper script created + /// in a Flutter project. Setting this value to false will cause the tool to pass + /// `--no-daemon` to the gradle wrapper script, preventing it from spawning a daemon + /// process. + /// + /// For one-off builds or CI systems, preventing the daemon from spawning will + /// reduce system resource usage, at the cost of any subsequent builds starting + /// up slightly slower. + /// + /// The Gradle daemon may also be disabled in the Android application's properties file. + final bool gradleDaemon; + + /// Whether to skip checking of individual versions of our Android build time + /// dependencies. + final bool skipBuildDependencyValidation; + + /// The default `enable-hcpp` value (currently false unless the CLI flag was + /// passed), given to Gradle so the Flutter Gradle Plugin can inject the + /// corresponding manifest metadata if absent. + /// + /// The injection only happens for application projects, and only when the + /// merged manifest does not already contain the + /// `io.flutter.embedding.android.EnableHcpp` metadata, so a value in the + /// app's manifest takes priority over this one. Module (aar) manifests are + /// never injected; the add-to-app host's manifest is the source of truth. + /// When null, no property is passed and no injection happens. + final bool? enableHcpp; + + /// The explicit `--[no-]enable-hcpp` value passed by the user on the CLI, or + /// null if the user did not pass the flag explicitly. + /// + /// Passed to Gradle, which writes it into the merged manifest over any value + /// already there, so it takes priority over both [enableHcpp] and the + /// app's manifest. When null, the manifest decides. + final bool? explicitEnableHcpp; } /// Information about an Android build to be performed or used. @@ -461,6 +410,7 @@ class AndroidBuildInfo { this.buildInfo, { this.targetArchs = const [.armv7, .arm64, .x64], this.splitPerAbi = false, + this.releaseManifestEngineShellArgs, }); // The build info containing the mode and flavor. @@ -475,66 +425,54 @@ class AndroidBuildInfo { /// The target platforms for the build. final Iterable targetArchs; -} - -/// A summary of the compilation strategy used for Dart. -enum BuildMode { - /// Built in JIT mode with no optimizations, enabled asserts, and a VM service. - debug, - - /// Built in AOT mode with some optimizations and a VM service. - profile, - - /// Built in AOT mode with all optimizations and no VM service. - release, - - /// Built in JIT mode with all optimizations and no VM service. - jitRelease; - - factory BuildMode.fromCliName(String value) => values.singleWhere( - (BuildMode element) => element.cliName == value, - orElse: () => throw ArgumentError('$value is not a supported build mode'), - ); - - static const releaseModes = {release, jitRelease}; - static const jitModes = {debug, jitRelease}; - - /// Whether this mode is considered release. - /// - /// Useful for determining whether we should enable/disable asserts or - /// other development features. - bool get isRelease => releaseModes.contains(this); - - /// Whether this mode is using the JIT runtime. - bool get isJit => jitModes.contains(this); - - /// Whether this mode is using the precompiled runtime. - bool get isPrecompiled => !isJit; - - /// [name] formatted in snake case. - /// - /// (e.g. debug, profile, release, jit_release) - String get cliName => snakeCase(name); - /// [cliName] formatted in sentence case. + /// Engine shell arguments to be injected into the application's AndroidManifest.xml. /// - /// (e.g. Debug, Profile, Release, Jit_release) - String get uppercaseName => sentenceCase(cliName); + /// This is exclusively used in release mode to allow the Flutter CLI to pass debugging + /// options to the engine without relying on Intent extras. It is only relevant when building + /// from source (i.e., not using a prebuilt application binary). + final List? releaseManifestEngineShellArgs; - /// [cliName] with `_` replaced with a space. + /// Converts [buildInfo], [BuildInfo.androidGradleConfig], and [splitPerAbi] + /// into project-level arguments to be passed on the command line to Gradle. /// - /// (e.g. debug, profile, release, jit release) - String get friendlyName => cliName.replaceAll('_', ' '); - - /// [friendlyName] formatted in sentence case. - /// - /// (e.g. Debug, Profile, Release, Jit release) - String get uppercaseFriendlyName => sentenceCase(friendlyName); - - @override - String toString() => cliName; + /// Note that [targetArchs] (`-Ptarget-platform`) is resolved by the Gradle + /// builder so `--local-engine` can override it from the local engine path. + List toGradleConfig() { + final AndroidGradleConfig gradleConfig = buildInfo.androidGradleConfig; + // PACKAGE_CONFIG not currently supported. + return [ + if (buildInfo.dartDefines.isNotEmpty) + '-Pdart-defines=${encodeDartDefines(buildInfo.dartDefines)}', + '-Pdart-obfuscation=${buildInfo.dartObfuscation}', + if (buildInfo.frontendServerStarterPath != null) + '-Pfrontend-server-starter-path=${buildInfo.frontendServerStarterPath}', + if (buildInfo.extraFrontEndOptions.isNotEmpty) + '-Pextra-front-end-options=${buildInfo.extraFrontEndOptions.join(',')}', + if (buildInfo.extraGenSnapshotOptions.isNotEmpty) + '-Pextra-gen-snapshot-options=${buildInfo.extraGenSnapshotOptions.join(',')}', + if (buildInfo.splitDebugInfoPath != null) + '-Psplit-debug-info=${buildInfo.splitDebugInfoPath}', + '-Ptrack-widget-creation=${buildInfo.trackWidgetCreation}', + '-Ptree-shake-icons=${buildInfo.treeShakeIcons}', + if (buildInfo.performanceMeasurementFile != null) + '-Pperformance-measurement-file=${buildInfo.performanceMeasurementFile}', + if (buildInfo.codeSizeDirectory != null) + '-Pcode-size-directory=${buildInfo.codeSizeDirectory}', + if (gradleConfig.enableHcpp != null) '-Penable-hcpp=${gradleConfig.enableHcpp}', + if (gradleConfig.explicitEnableHcpp != null) + '-Pexplicit-enable-hcpp=${gradleConfig.explicitEnableHcpp}', + for (final String projectArg in gradleConfig.projectArgs) '-P$projectArg', + if (gradleConfig.projectCacheDir != null) + '--project-cache-dir=${gradleConfig.projectCacheDir}', + if (splitPerAbi) '-Psplit-per-abi=true', + ]; + } } +/// A summary of the compilation strategy used for Dart. +typedef BuildMode = tools_core.BuildMode; + /// Environment type of the target device. enum EnvironmentType { physical, simulator } @@ -546,7 +484,7 @@ String? validatedBuildNumberForPlatform( if (buildNumber == null) { return null; } - if (targetPlatform == TargetPlatform.ios || targetPlatform == TargetPlatform.darwin) { + if (targetPlatform.os case .ios || .macos) { // See CFBundleVersion at https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html final disallowed = RegExp(r'[^\d\.]'); String tmpBuildNumber = buildNumber.replaceAll(disallowed, ''); @@ -569,9 +507,7 @@ String? validatedBuildNumberForPlatform( } return tmpBuildNumber; } - if (targetPlatform == TargetPlatform.android_arm || - targetPlatform == TargetPlatform.android_arm64 || - targetPlatform == TargetPlatform.android_x64) { + if (targetPlatform.os == .android) { // See versionCode at https://developer.android.com/studio/publish/versioning final disallowed = RegExp(r'[^\d]'); String tmpBuildNumberStr = buildNumber.replaceAll(disallowed, ''); @@ -599,7 +535,7 @@ String? validatedBuildNameForPlatform( if (buildName == null) { return null; } - if (targetPlatform == TargetPlatform.ios || targetPlatform == TargetPlatform.darwin) { + if (targetPlatform.os case .ios || .macos) { // See CFBundleShortVersionString at https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html final disallowed = RegExp(r'[^\d\.]'); String tmpBuildName = buildName.replaceAll(disallowed, ''); @@ -622,10 +558,7 @@ String? validatedBuildNameForPlatform( } return tmpBuildName; } - if (targetPlatform == TargetPlatform.android || - targetPlatform == TargetPlatform.android_arm || - targetPlatform == TargetPlatform.android_arm64 || - targetPlatform == TargetPlatform.android_x64) { + if (targetPlatform.os == .android) { // See versionName at https://developer.android.com/studio/publish/versioning return buildName; } @@ -721,6 +654,31 @@ enum CpuArch { }; } +/// The operating system (or runtime environment) that a [TargetPlatform] +/// targets, independent of CPU architecture. +/// +/// Use [TargetPlatform.os] to obtain the operating system of a target platform. +/// This is preferable to switching over [TargetPlatform] directly when the +/// decision being made only depends on the operating system, since it avoids +/// having to enumerate every architecture-specific variant. +enum TargetOperatingSystem { + android, + ios, + macos, + linux, + windows, + fuchsia, + + /// The web platform. + web, + + /// The `flutter_tester` desktop embedder used by `flutter test`. + tester, + + /// An unsupported target. See [TargetPlatform.unsupported]. + unsupported, +} + enum TargetPlatform { android('android'), ios('ios'), @@ -796,16 +754,30 @@ enum TargetPlatform { unsupported => throw UnsupportedError('Unexpected Fuchsia platform $this'), }; - String get osName => switch (this) { - linux_x64 || linux_arm64 || linux_riscv64 => 'linux', - darwin => 'macos', - windows_x64 || windows_arm64 => 'windows', - android || android_arm || android_arm64 || android_x64 => 'android', - fuchsia_arm64 || fuchsia_x64 => 'fuchsia', - ios => 'ios', - tester => 'flutter-tester', - web_javascript => 'web', - unsupported => throw UnsupportedError('Unexpected target platform $this'), + /// The operating system this platform targets, independent of CPU + /// architecture. + TargetOperatingSystem get os => switch (this) { + android || android_arm || android_arm64 || android_x64 => .android, + ios => .ios, + darwin => .macos, + linux_x64 || linux_arm64 || linux_riscv64 => .linux, + windows_x64 || windows_arm64 => .windows, + fuchsia_arm64 || fuchsia_x64 => .fuchsia, + web_javascript => .web, + tester => .tester, + unsupported => .unsupported, + }; + + String get osName => switch (os) { + .linux => 'linux', + .macos => 'macos', + .windows => 'windows', + .android => 'android', + .fuchsia => 'fuchsia', + .ios => 'ios', + .tester => 'flutter-tester', + .web => 'web', + .unsupported => throw UnsupportedError('Unexpected target platform $this'), }; String get simpleName => switch (this) { @@ -921,7 +893,7 @@ String getBuildDirectory([Config? config, FileSystem? fileSystem]) { final String buildDir = localConfig.getValue('build-dir') as String? ?? 'build'; if (localFilesystem.path.isAbsolute(buildDir)) { - throw Exception('build-dir config setting in ${globals.config.configPath} must be relative'); + throw Exception('build-dir config setting in ${localConfig.configPath} must be relative'); } return buildDir; } @@ -932,11 +904,6 @@ String getAndroidBuildDirectory() { return getBuildDirectory(); } -/// Returns the AOT build output directory. -String getAotBuildDirectory() { - return globals.fs.path.join(getBuildDirectory(), 'aot'); -} - /// Returns the asset build output directory. String getAssetBuildDirectory([Config? config, FileSystem? fileSystem]) { return (fileSystem ?? globals.fs).path.join( @@ -966,8 +933,8 @@ String getMacOSBuildDirectory({Config? config, FileSystem? fileSystem}) { } /// Returns the web build output directory. -String getWebBuildDirectory() { - return globals.fs.path.join(getBuildDirectory(), 'web'); +String getWebBuildDirectory({required Config config, required FileSystem fileSystem}) { + return fileSystem.path.join(getBuildDirectory(config, fileSystem), 'web'); } /// Returns the Linux build output directory. @@ -996,11 +963,6 @@ String getWindowsBuildDirectory(TargetPlatform targetPlatform, [String? flavor]) return globals.fs.path.join(getBuildDirectory(), subDirs); } -/// Returns the Fuchsia build output directory. -String getFuchsiaBuildDirectory() { - return globals.fs.path.join(getBuildDirectory(), 'fuchsia'); -} - /// Defines specified via the `--dart-define` command-line option. /// /// These values are URI-encoded and then combined into a comma-separated string. @@ -1148,26 +1110,13 @@ const kBuildNumber = 'BuildNumber'; const kXcodeAction = 'Action'; // The define of the Xcode Build Script. -/// This may be [kXcodeBuildScriptValuePrepare], [kXcodeBuildScriptValueBuild], or [kXcodeBuildScriptValueEmbed]. +/// This may be [kXcodeBuildScriptValuePrepare]. const kXcodeBuildScript = 'XcodeBuildScript'; /// When [kXcodeBuildScript] equals this value, that indicates that the target was trigged to run /// by a scheme pre-action. const kXcodeBuildScriptValuePrepare = 'prepare'; -/// When [kXcodeBuildScript] equals this value, that indicates that the target was trigged to run -/// by the first Run Script in the Xcode build process that happens before compiling. -const kXcodeBuildScriptValueBuild = 'build'; - -/// When [kXcodeBuildScript] equals this value, that indicates that the target was trigged to run -/// by the second Run Script in the Xcode build process that happens after compiling, linking, and -/// embedding. -const kXcodeBuildScriptValueEmbed = 'embed'; - -/// When [kXcodeBuildScript] equals this value, that indicates that the target was trigged to run -/// by a Run Script in the Xcode build process in a native app (add-to-app). -const kXcodeBuildScriptValueAddToAppBuild = 'build-add-to-app'; - /// Whether the build is originating from the `flutter build swift-package` command. /// /// Expects value of "true". @@ -1210,29 +1159,19 @@ List decodeDartDefines(Map environmentDefines, String ke .toList(); } -/// Indicates the module system DDC is targeting. -enum DdcModuleFormat { amd, ddc } - -// TODO(markzipan): delete this when DDC's AMD module system is deprecated, https://github.com/flutter/flutter/issues/142060. -({DdcModuleFormat? ddcModuleFormat, bool? canaryFeatures}) -_ddcModuleFormatAndCanaryFeaturesFromFrontEndArgs(List? extraFrontEndArgs) { - DdcModuleFormat? ddcModuleFormat; - bool? canaryFeatures; - if (extraFrontEndArgs != null) { - const ddcModuleFormatArg = '--dartdevc-module-format='; - const canaryFeaturesArg = '--dartdevc-canary'; - for (final String flag in extraFrontEndArgs) { - if (flag.startsWith(ddcModuleFormatArg)) { - final String moduleFormatString = flag.substring(ddcModuleFormatArg.length, flag.length); - assert(ddcModuleFormat == null); - ddcModuleFormat = DdcModuleFormat.values.byName(moduleFormatString); - } else if (flag == canaryFeaturesArg) { - canaryFeatures = true; - } - } - } - return (ddcModuleFormat: ddcModuleFormat, canaryFeatures: canaryFeatures); -} +/// Returns the compiler flags that select whether the deprecated JS interop +/// libraries (such as `dart:html` and `dart:js`) may be used. +/// +/// Both dart2js and the frontend server (for the `dartdevc` target) accept +/// these flags. Returns no flags when [deprecatedJsInterop] is `null`, so +/// that the compiler's default is used and Dart SDKs without the flag keep +/// working. +List deprecatedJsInteropCompilerFlags(bool? deprecatedJsInterop) => + switch (deprecatedJsInterop) { + null => const [], + true => const ['--deprecated-js-interop'], + false => const ['--no-deprecated-js-interop'], + }; String _getCurrentHostPlatformArchName() { final HostPlatform hostPlatform = getCurrentHostPlatform(); @@ -1245,15 +1184,3 @@ String? _uncapitalize(String? s) { } return s.substring(0, 1).toLowerCase() + s.substring(1); } - -// flutter_ignore: deprecation_syntax (see analyze.dart) -@Deprecated('Use TargetPlatform.getName() instead') -String getNameForTargetPlatform(TargetPlatform platform, {CpuArch? cpuArch}) { - return platform.getName(cpuArch: cpuArch); -} - -// flutter_ignore: deprecation_syntax (see analyze.dart) -@Deprecated('Use TargetPlatform.fromName() instead') -TargetPlatform getTargetPlatformForName(String platform) { - return TargetPlatform.fromName(platform); -} diff --git a/packages/flutter_tools/lib/src/build_system/build_system.dart b/packages/flutter_tools/lib/src/build_system/build_system.dart index 9abcda6d..657704e8 100644 --- a/packages/flutter_tools/lib/src/build_system/build_system.dart +++ b/packages/flutter_tools/lib/src/build_system/build_system.dart @@ -96,12 +96,12 @@ class BuildSystemConfig { /// /// ### Targets should only depend on files that are provided as inputs /// -/// Example: gen_snapshot must be provided as an input to the aot_elf +/// Example: gen_snapshot must be provided as an input to the aot_snapshot /// build steps, even though it isn't a source file. This ensures that changes /// to the gen_snapshot binary (during a local engine build) correctly /// trigger a corresponding build update. /// -/// Example: aot_elf has a dependency on the dill and packages file +/// Example: aot_snapshot has a dependency on the dill and packages file /// produced by the kernel_snapshot step. /// /// ### Targets should declare all outputs produced @@ -620,12 +620,10 @@ abstract class BuildSystem { class FlutterBuildSystem extends BuildSystem { const FlutterBuildSystem({ - required FileSystem fileSystem, - required Platform platform, - required Logger logger, - }) : _fileSystem = fileSystem, - _platform = platform, - _logger = logger; + required this._fileSystem, + required this._platform, + required this._logger, + }); final FileSystem _fileSystem; final Platform _platform; @@ -788,7 +786,6 @@ class FlutterBuildSystem extends BuildSystem { /// files, if these were not already covered by the built-in cleanup. This /// cleanup is only necessary when multiple different build configurations /// output to the same directory. - @visibleForTesting void trackSharedBuildDirectory( Environment environment, FileSystem fileSystem, @@ -830,15 +827,56 @@ class FlutterBuildSystem extends BuildSystem { } final List lastOutputs = (json.decode(outputsFile.readAsStringSync()) as List) .cast(); - for (final lastOutput in lastOutputs) { - if (!currentOutputs.containsKey(lastOutput)) { - final File lastOutputFile = fileSystem.file(lastOutput); - if (preservedOutputFilePaths.contains(lastOutputFile.path)) { - continue; - } - ErrorHandlingFileSystem.deleteIfExists(lastOutputFile); - } + _deleteStaleOutputs( + currentOutputs: currentOutputs, + environment: environment, + fileSystem: fileSystem, + preservedOutputFilePaths: preservedOutputFilePaths, + previousOutputs: lastOutputs, + ); + } +} + +/// Deletes stale output files from previous builds or build stages that are no +/// longer part of the current build configuration or output graph. +/// +/// Files located in the shared hooks runner output directory +/// (`.dart_tool/hooks_runner/shared/`) and files in [preservedOutputFilePaths] +/// are explicitly spared from deletion. Shared native asset hook outputs manage +/// their own caching and invalidation lifecycles independently, so cleaning +/// them up when switching to build configurations or stages that do not invoke +/// native assets hooks would incorrectly cause subsequent hook-enabled builds +/// to skip running hooks (due to valid metadata) while failing to locate the +/// deleted output files. +void _deleteStaleOutputs({ + required Map currentOutputs, + required Environment environment, + required FileSystem fileSystem, + required Set preservedOutputFilePaths, + required Iterable previousOutputs, +}) { + String projectDirPath; + try { + projectDirPath = environment.projectDir.resolveSymbolicLinksSync(); + } on FileSystemException { + projectDirPath = environment.projectDir.absolute.path; + } + final String sharedHooksPath = fileSystem.path.canonicalize( + fileSystem.path.join(projectDirPath, '.dart_tool', 'hooks_runner', 'shared'), + ); + for (final previousOutput in previousOutputs) { + if (currentOutputs.containsKey(previousOutput)) { + continue; } + final File previousFile = fileSystem.file(previousOutput); + if (preservedOutputFilePaths.contains(previousFile.path) || + fileSystem.path.isWithin( + sharedHooksPath, + fileSystem.path.canonicalize(previousFile.path), + )) { + continue; + } + ErrorHandlingFileSystem.deleteIfExists(previousFile); } } @@ -953,16 +991,13 @@ class _BuildInstance { // Delete outputs from previous stages that are no longer a part of the // build. - for (final String previousOutput in node.previousOutputs) { - if (outputFiles.containsKey(previousOutput)) { - continue; - } - final File previousFile = fileSystem.file(previousOutput); - if (preservedOutputFilePaths.contains(previousFile.path)) { - continue; - } - ErrorHandlingFileSystem.deleteIfExists(previousFile); - } + _deleteStaleOutputs( + currentOutputs: outputFiles, + environment: environment, + fileSystem: fileSystem, + preservedOutputFilePaths: preservedOutputFilePaths, + previousOutputs: node.previousOutputs, + ); } on Exception catch (exception, stackTrace) { node.target.clearStamp(environment); succeeded = false; @@ -1042,26 +1077,6 @@ void checkCycles(Target initial) { checkInternal(initial, {}, {}); } -/// Verifies that all files exist and are in a subdirectory of [Environment.buildDir]. -void verifyOutputDirectories(List outputs, Environment environment, Target target) { - final String buildDirectory = environment.buildDir.resolveSymbolicLinksSync(); - final String projectDirectory = environment.projectDir.resolveSymbolicLinksSync(); - final missingOutputs = []; - for (final sourceFile in outputs) { - if (!sourceFile.existsSync()) { - missingOutputs.add(sourceFile); - continue; - } - final String path = sourceFile.path; - if (!path.startsWith(buildDirectory) && !path.startsWith(projectDirectory)) { - throw MisplacedOutputException(path, target.name); - } - } - if (missingOutputs.isNotEmpty) { - throw MissingOutputException(missingOutputs, target.name); - } -} - /// A node in the build graph. class Node { factory Node( diff --git a/packages/flutter_tools/lib/src/build_system/build_targets.dart b/packages/flutter_tools/lib/src/build_system/build_targets.dart index 82347033..68b3cbd5 100644 --- a/packages/flutter_tools/lib/src/build_system/build_targets.dart +++ b/packages/flutter_tools/lib/src/build_system/build_targets.dart @@ -24,6 +24,8 @@ abstract class BuildTargets { } /// BuildTargets that return NoOpTarget for every action. +/// +/// This class is required by internal google3 tooling (`mobile/flutter/cli/context`). class NoOpBuildTargets extends BuildTargets { const NoOpBuildTargets(); diff --git a/packages/flutter_tools/lib/src/build_system/depfile.dart b/packages/flutter_tools/lib/src/build_system/depfile.dart index ad4f3dfa..c79833ef 100644 --- a/packages/flutter_tools/lib/src/build_system/depfile.dart +++ b/packages/flutter_tools/lib/src/build_system/depfile.dart @@ -8,9 +8,7 @@ import '../base/logger.dart'; /// A service for creating and parsing [Depfile]s. class DepfileService { - DepfileService({required Logger logger, required FileSystem fileSystem}) - : _logger = logger, - _fileSystem = fileSystem; + DepfileService({required this._logger, required this._fileSystem}); final Logger _logger; final FileSystem _fileSystem; @@ -104,6 +102,7 @@ class DepfileService { } List _processList(String rawText, [Directory? baseDirectory]) { + final isWindows = _fileSystem.path.style.separator == r'\'; return rawText // Put every file on right-hand side on the separate line .replaceAllMapped(_separatorExpr, (Match match) => '${match.group(1)}\n') @@ -119,6 +118,9 @@ class DepfileService { .toSet() // Normalize the path before creating a file object. .map((String path) { + if (isWindows && path.length >= 5 && path.substring(0, 5).toLowerCase() == r'\unc\') { + path = r'\\' + path.substring(5); + } if (baseDirectory != null && _fileSystem.path.isRelative(path)) { return _fileSystem.file( _fileSystem.path.normalize(_fileSystem.path.join(baseDirectory.path, path)), diff --git a/packages/flutter_tools/lib/src/build_system/file_store.dart b/packages/flutter_tools/lib/src/build_system/file_store.dart index 0df5bca6..8a1d2a80 100644 --- a/packages/flutter_tools/lib/src/build_system/file_store.dart +++ b/packages/flutter_tools/lib/src/build_system/file_store.dart @@ -87,12 +87,10 @@ enum FileStoreStrategy { /// The format of the file store is subject to change and not part of its API. class FileStore { FileStore({ - required File cacheFile, - required Logger logger, - FileStoreStrategy strategy = FileStoreStrategy.hash, - }) : _logger = logger, - _strategy = strategy, - _cacheFile = cacheFile; + required this._cacheFile, + required this._logger, + this._strategy = FileStoreStrategy.hash, + }); final File _cacheFile; final Logger _logger; diff --git a/packages/flutter_tools/lib/src/build_system/source.dart b/packages/flutter_tools/lib/src/build_system/source.dart index b9978040..0b3493a6 100644 --- a/packages/flutter_tools/lib/src/build_system/source.dart +++ b/packages/flutter_tools/lib/src/build_system/source.dart @@ -7,6 +7,7 @@ import '../base/file_system.dart'; import '../build_info.dart'; import '../project.dart'; import 'build_system.dart'; +import 'depfile.dart'; import 'exceptions.dart'; ////////////////////////////////////////////////////////////////////// @@ -53,49 +54,25 @@ class SourceVisitor implements ResolvedFiles { bool get containsNewDepfile => _containsNewDepfile; var _containsNewDepfile = false; - /// Visit a depfile which contains both input and output files. + /// Visit a depfile. /// - /// If the file is missing, this visitor is marked as [containsNewDepfile]. - /// This is used by the [Node] class to tell the [BuildSystem] to + /// If the depfile does not exist, marks [containsNewDepfile] as true and + /// returns. This is used by the [Node] class to tell the [BuildSystem] to /// defer hash computation until after executing the target. - // depfile logic adopted from https://github.com/flutter/flutter/blob/7065e4330624a5a216c8ffbace0a462617dc1bf5/dev/devicelab/lib/framework/apk_utils.dart#L390 void visitDepfile(String name) { final File depfile = environment.buildDir.childFile(name); if (!depfile.existsSync()) { _containsNewDepfile = true; return; } - final String contents = depfile.readAsStringSync(); - final List colonSeparated = contents.split(': '); - if (colonSeparated.length != 2) { - environment.logger.printError('Invalid depfile: ${depfile.path}'); - return; - } + final Depfile parsedDepfile = environment.depFileService.parse(depfile); if (inputs) { - sources.addAll(_processList(colonSeparated[1].trim())); + sources.addAll(parsedDepfile.inputs); } else { - sources.addAll(_processList(colonSeparated[0].trim())); + sources.addAll(parsedDepfile.outputs); } } - final _separatorExpr = RegExp(r'([^\\]) '); - final _escapeExpr = RegExp(r'\\(.)'); - - Iterable _processList(String rawText) { - return rawText - // Put every file on right-hand side on the separate line - .replaceAllMapped(_separatorExpr, (Match match) => '${match.group(1)}\n') - .split('\n') - // Expand escape sequences, so that '\ ', for example,ß becomes ' ' - .map( - (String path) => - path.replaceAllMapped(_escapeExpr, (Match match) => match.group(1)!).trim(), - ) - .where((String path) => path.isNotEmpty) - .toSet() - .map(environment.fileSystem.file); - } - /// Visit a [Source] which contains a file URL. /// /// The URL may include constants defined in an [Environment]. If diff --git a/packages/flutter_tools/lib/src/build_system/targets/android.dart b/packages/flutter_tools/lib/src/build_system/targets/android.dart index 76c2c31f..5861f31e 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/android.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/android.dart @@ -8,7 +8,6 @@ import '../../base/deferred_component.dart'; import '../../base/file_system.dart'; import '../../build_info.dart'; import '../../devfs.dart'; -import '../../globals.dart' as globals show xcode; import '../../isolated/native_assets/dart_hook_result.dart'; import '../../project.dart'; import '../build_system.dart'; @@ -141,7 +140,7 @@ class ProfileAndroidApplication extends CopyFlutterAotBundle { @override List get dependencies => const [ - AotElfProfile(TargetPlatform.android_arm), + AotSnapshotProfile(TargetPlatform.android_arm), AotAndroidAssetBundle(), ]; } @@ -155,7 +154,7 @@ class ReleaseAndroidApplication extends CopyFlutterAotBundle { @override List get dependencies => const [ - AotElfRelease(TargetPlatform.android_arm), + AotSnapshotRelease(TargetPlatform.android_arm), AotAndroidAssetBundle(), ]; } @@ -171,7 +170,7 @@ class ReleaseAndroidApplication extends CopyFlutterAotBundle { /// /// It will produce an 'app.so` in the build directory under a folder named with /// the matching Android ABI. -class AndroidAot extends AotElfBase { +class AndroidAot extends AotSnapshotBase { /// Create an [AndroidAot] implementation for a given [targetPlatform] and [buildMode]. const AndroidAot(this.targetPlatform, this.buildMode); @@ -186,6 +185,7 @@ class AndroidAot extends AotElfBase { '${targetPlatform.getName()}'; /// The specific Android ABI we are building for. + @override final TargetPlatform targetPlatform; /// The selected build mode. @@ -218,7 +218,6 @@ class AndroidAot extends AotElfBase { final snapshotter = AOTSnapshotter( fileSystem: environment.fileSystem, logger: environment.logger, - xcode: globals.xcode!, processManager: environment.processManager, artifacts: environment.artifacts, ); @@ -371,9 +370,8 @@ const androidx64ReleaseBundle = AndroidAotBundle(androidx64Release); class AndroidAotDeferredComponentsBundle extends Target { /// Create an [AndroidAotDeferredComponentsBundle] implementation for a given [targetPlatform] and [BuildInfo.mode]. /// - /// If [components] is not provided, it will be read from the `pubspec.yaml` manifest. - AndroidAotDeferredComponentsBundle(this.dependency, {List? components}) - : _components = components; + /// If [_components] is not provided, it will be read from the `pubspec.yaml` manifest. + AndroidAotDeferredComponentsBundle(this.dependency, {this._components}); /// The [AndroidAotBundle] instance this bundle rule depends on. final AndroidAotBundle dependency; @@ -413,7 +411,9 @@ class AndroidAotDeferredComponentsBundle extends Target { @override Future build(Environment environment) async { - _components ??= FlutterProject.current().manifest.deferredComponents ?? []; + _components ??= + FlutterProject.fromDirectory(environment.projectDir).manifest.deferredComponents ?? + []; final abis = [_androidAbiName]; final List generatedLoadingUnits = LoadingUnit.parseGeneratedLoadingUnits( environment.outputDir, @@ -427,7 +427,7 @@ class AndroidAotDeferredComponentsBundle extends Target { environment, _components!, generatedLoadingUnits, - environment.projectDir.childDirectory('build'), + FlutterProject.fromDirectory(environment.projectDir).buildDirectory, abis, dependency.buildMode, ); diff --git a/packages/flutter_tools/lib/src/build_system/targets/assets.dart b/packages/flutter_tools/lib/src/build_system/targets/assets.dart index dcbe48c2..27c47b47 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/assets.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/assets.dart @@ -13,6 +13,7 @@ import '../../dart/package_map.dart'; import '../../devfs.dart'; import '../../flutter_manifest.dart'; import '../../isolated/native_assets/dart_hook_result.dart'; +import '../../project.dart'; import '../build_system.dart'; import '../depfile.dart'; import '../exceptions.dart'; @@ -86,6 +87,7 @@ Future copyAssets( logger: environment.logger, fileSystem: environment.fileSystem, artifacts: environment.artifacts, + platform: environment.platform, ); final assetTransformer = AssetTransformer( processManager: environment.processManager, @@ -207,8 +209,8 @@ Future copyAssets( assetBundle.deferredComponentsEntries.entries.map>(( MapEntry> componentEntries, ) async { - final Directory componentOutputDir = environment.projectDir - .childDirectory('build') + final Directory componentOutputDir = FlutterProject.fromDirectory(environment.projectDir) + .buildDirectory .childDirectory(componentEntries.key) .childDirectory('intermediates') .childDirectory('flutter'); diff --git a/packages/flutter_tools/lib/src/build_system/targets/common.dart b/packages/flutter_tools/lib/src/build_system/targets/common.dart index 6356a52b..ad8a5b06 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/common.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/common.dart @@ -13,9 +13,10 @@ import '../../compile.dart'; import '../../dart/package_map.dart'; import '../../darwin/darwin.dart'; import '../../devfs.dart'; -import '../../globals.dart' as globals show xcode; +import '../../globals.dart' as globals show os; import '../../isolated/native_assets/dart_hook_result.dart'; import '../../project.dart'; +import '../../windows/visual_studio.dart'; import '../build_system.dart'; import '../depfile.dart'; import '../exceptions.dart'; @@ -227,8 +228,7 @@ class KernelSnapshot extends Target { final String? fileSystemScheme = environment.defines[kFileSystemScheme]; TargetModel targetModel = TargetModel.flutter; - if (targetPlatform == TargetPlatform.fuchsia_x64 || - targetPlatform == TargetPlatform.fuchsia_arm64) { + if (targetPlatform.os == .fuchsia) { targetModel = TargetModel.flutterRunner; } // Force linking of the platform for desktop embedder targets since these @@ -257,20 +257,15 @@ class KernelSnapshot extends Target { TargetPlatform.throwUnsupportedTarget(); } - final String? targetOS = switch (targetPlatform) { - TargetPlatform.fuchsia_arm64 || TargetPlatform.fuchsia_x64 => 'fuchsia', - TargetPlatform.android || - TargetPlatform.android_arm || - TargetPlatform.android_arm64 || - TargetPlatform.android_x64 => 'android', - TargetPlatform.darwin => 'macos', - TargetPlatform.ios => 'ios', - TargetPlatform.linux_arm64 || - TargetPlatform.linux_riscv64 || - TargetPlatform.linux_x64 => 'linux', - TargetPlatform.windows_arm64 || TargetPlatform.windows_x64 => 'windows', - TargetPlatform.tester || TargetPlatform.web_javascript => null, - TargetPlatform.unsupported => TargetPlatform.throwUnsupportedTarget(), + final String? targetOS = switch (targetPlatform.os) { + .fuchsia => 'fuchsia', + .android => 'android', + .macos => 'macos', + .ios => 'ios', + .linux => 'linux', + .windows => 'windows', + .tester || .web => null, + .unsupported => TargetPlatform.throwUnsupportedTarget(), }; final PackageConfig packageConfig = await loadPackageConfigWithLogging( @@ -357,30 +352,25 @@ class KernelSnapshot extends Target { } } -/// Supports compiling a dart kernel file to an ELF binary. -abstract class AotElfBase extends Target { - const AotElfBase(); +/// Supports compiling a dart kernel file to a native AOT binary. +abstract class AotSnapshotBase extends Target { + const AotSnapshotBase(); @override String get analyticsName => 'android_aot'; + TargetPlatform get targetPlatform; + @override Future build(Environment environment) async { - final snapshotter = AOTSnapshotter( - fileSystem: environment.fileSystem, - logger: environment.logger, - xcode: globals.xcode!, - processManager: environment.processManager, - artifacts: environment.artifacts, - ); final String outputPath = environment.buildDir.path; final String? buildModeEnvironment = environment.defines[kBuildMode]; if (buildModeEnvironment == null) { - throw MissingDefineException(kBuildMode, 'aot_elf'); + throw MissingDefineException(kBuildMode, 'aot_snapshot'); } final String? targetPlatformEnvironment = environment.defines[kTargetPlatform]; if (targetPlatformEnvironment == null) { - throw MissingDefineException(kTargetPlatform, 'aot_elf'); + throw MissingDefineException(kTargetPlatform, 'aot_snapshot'); } final List extraGenSnapshotOptions = decodeCommaSeparated( environment.defines, @@ -388,6 +378,24 @@ abstract class AotElfBase extends Target { ); final buildMode = BuildMode.fromCliName(buildModeEnvironment); final targetPlatform = TargetPlatform.fromName(targetPlatformEnvironment); + // TODO(team-windows): Support PE/COFF AOT snapshots for Windows Arm64. + // See: https://github.com/flutter/flutter/issues/190897 + final String? windowsLinkerPath = targetPlatform == TargetPlatform.windows_x64 + ? VisualStudio( + fileSystem: environment.fileSystem, + processManager: environment.processManager, + platform: environment.platform, + logger: environment.logger, + osUtils: globals.os, + ).linkPath + : null; + final snapshotter = AOTSnapshotter( + fileSystem: environment.fileSystem, + logger: environment.logger, + processManager: environment.processManager, + artifacts: environment.artifacts, + windowsLinkerPath: windowsLinkerPath, + ); final String? splitDebugInfo = environment.defines[kSplitDebugInfo]; final dartObfuscation = environment.defines[kDartObfuscation] == 'true'; final String? codeSizeDirectory = environment.defines[kCodeSizeDirectory]; @@ -418,12 +426,21 @@ abstract class AotElfBase extends Target { } } -/// Generate an ELF binary from a dart kernel file in profile mode. -class AotElfProfile extends AotElfBase { - const AotElfProfile(this.targetPlatform); +List _aotOutputsForTarget(TargetPlatform targetPlatform) { + return [ + const Source.pattern('{BUILD_DIR}/app.so'), + if (targetPlatform == TargetPlatform.windows_x64) ...const [ + Source.pattern('{BUILD_DIR}/app.o'), + ], + ]; +} + +/// Generate a native AOT binary from a dart kernel file in profile mode. +class AotSnapshotProfile extends AotSnapshotBase { + const AotSnapshotProfile(this.targetPlatform); @override - String get name => 'aot_elf_profile'; + String get name => 'aot_snapshot_profile'; @override List get inputs => [ @@ -437,20 +454,21 @@ class AotElfProfile extends AotElfBase { ]; @override - List get outputs => const [Source.pattern('{BUILD_DIR}/app.so')]; + List get outputs => _aotOutputsForTarget(targetPlatform); @override List get dependencies => const [KernelSnapshot()]; + @override final TargetPlatform targetPlatform; } -/// Generate an ELF binary from a dart kernel file in release mode. -class AotElfRelease extends AotElfBase { - const AotElfRelease(this.targetPlatform); +/// Generate a native AOT binary from a dart kernel file in release mode. +class AotSnapshotRelease extends AotSnapshotBase { + const AotSnapshotRelease(this.targetPlatform); @override - String get name => 'aot_elf_release'; + String get name => 'aot_snapshot_release'; @override List get inputs => [ @@ -464,11 +482,12 @@ class AotElfRelease extends AotElfBase { ]; @override - List get outputs => const [Source.pattern('{BUILD_DIR}/app.so')]; + List get outputs => _aotOutputsForTarget(targetPlatform); @override List get dependencies => const [KernelSnapshot()]; + @override final TargetPlatform targetPlatform; } diff --git a/packages/flutter_tools/lib/src/build_system/targets/dart_plugin_registrant.dart b/packages/flutter_tools/lib/src/build_system/targets/dart_plugin_registrant.dart index 26b717f5..c5b06905 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/dart_plugin_registrant.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/dart_plugin_registrant.dart @@ -56,9 +56,10 @@ class DartPluginRegistrantTarget extends Target { // code should just do the right thing on every platform. // Failing that, consider throwing if `targetPlatform` isn't set and finding // all violations, as it's not consistently set here. - return targetPlatform == TargetPlatform.fuchsia_arm64 || - targetPlatform == TargetPlatform.fuchsia_x64 || - targetPlatform == TargetPlatform.web_javascript; + return switch (targetPlatform?.os) { + .fuchsia || .web => true, + _ => false, + }; } @override diff --git a/packages/flutter_tools/lib/src/build_system/targets/darwin.dart b/packages/flutter_tools/lib/src/build_system/targets/darwin.dart index 0d1ad6c0..a611769f 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/darwin.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/darwin.dart @@ -57,6 +57,28 @@ abstract class UnpackDarwin extends Target { '${result.stdout}\n---\n${result.stderr}', ); } + final String copiedPath = environment.outputDir + .childDirectory(environment.fileSystem.path.basename(basePath)) + .path; + try { + final ProcessResult chmodResult = await environment.processManager.run([ + 'chmod', + '-R', + 'u+w', + copiedPath, + ]); + if (chmodResult.exitCode != 0) { + printXcodeWarning( + 'Failed to make the framework writable. This may cause the build to ' + 'fail when using lipo.\nError: $copiedPath: ${chmodResult.stderr}', + ); + } + } on ProcessException catch (e) { + printXcodeWarning( + 'Failed to make the framework writable. This may cause the build to ' + 'fail when using lipo.\nError: $copiedPath: $e', + ); + } } /// Verifies and destructively thins the framework binary found at [frameworkBinaryPath] diff --git a/packages/flutter_tools/lib/src/build_system/targets/deferred_components.dart b/packages/flutter_tools/lib/src/build_system/targets/deferred_components.dart index 8dfdc168..8cb4299a 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/deferred_components.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/deferred_components.dart @@ -5,6 +5,7 @@ import 'package:meta/meta.dart'; import '../../android/deferred_components_gen_snapshot_validator.dart'; +import '../../android/deferred_components_validator.dart'; import '../../base/deferred_component.dart'; import '../../build_info.dart'; import '../../project.dart'; @@ -67,10 +68,14 @@ class DeferredComponentsGenSnapshotValidatorTarget extends Target { @override Future build(Environment environment) async { + final FlutterProject project = FlutterProject.fromDirectory(environment.projectDir); validator = DeferredComponentsGenSnapshotValidator( environment, title: title, exitOnFail: exitOnFail, + outputDir: project.buildDirectory.childDirectory( + DeferredComponentsValidator.kDeferredComponentsTempDirectory, + ), ); final List generatedLoadingUnits = LoadingUnit.parseGeneratedLoadingUnits( @@ -81,7 +86,7 @@ class DeferredComponentsGenSnapshotValidatorTarget extends Target { validator! ..checkAppAndroidManifestComponentLoadingUnitMapping( - FlutterProject.current().manifest.deferredComponents ?? [], + project.manifest.deferredComponents ?? [], generatedLoadingUnits, ) ..checkAgainstLoadingUnitsCache(generatedLoadingUnits) diff --git a/packages/flutter_tools/lib/src/build_system/targets/icon_tree_shaker.dart b/packages/flutter_tools/lib/src/build_system/targets/icon_tree_shaker.dart index 3f7356a4..525c3557 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/icon_tree_shaker.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/icon_tree_shaker.dart @@ -25,7 +25,7 @@ List> _getList(Object? object, String errorMessage) { throw IconTreeShakerException._(errorMessage); } -/// A class that wraps the functionality of the const finder package and the +/// A class that wraps the functionality of recorded uses and the /// font subset utility to tree shake unused icons from fonts. class IconTreeShaker { /// Creates a wrapper for icon font subsetting. @@ -38,17 +38,14 @@ class IconTreeShaker { IconTreeShaker( this._environment, DevFSStringContent? fontManifest, { - required ProcessManager processManager, + required this._processManager, required Logger logger, required FileSystem fileSystem, - required Artifacts artifacts, - required TargetPlatform targetPlatform, - }) : _processManager = processManager, - _logger = logger, + required this._artifacts, + required this._targetPlatform, + }) : _logger = logger, _fs = fileSystem, - _artifacts = artifacts, - _fontManifest = fontManifest?.string, - _targetPlatform = targetPlatform { + _fontManifest = fontManifest?.string { if (_environment.defines[kIconTreeShakerFlag] == 'true' && _environment.defines[kBuildMode] == 'debug') { logger.printError( @@ -130,7 +127,7 @@ class IconTreeShaker { final Recordings recordings = await _readRecordings(file); combinedRecordings = combinedRecordings == null ? recordings - : combinedRecordings.merge(recordings); + : combinedRecordings + recordings; } final Map> iconData = _parseRecordings(combinedRecordings!); @@ -141,9 +138,12 @@ class IconTreeShaker { familyKeys, ); - if (fonts.length != iconData.length) { + final Set missingFonts = iconData.keys + .where((String key) => !fonts.containsKey(key)) + .toSet(); + if (missingFonts.isNotEmpty) { environment.logger.printStatus( - 'Expected to find fonts for ${iconData.keys}, but found ' + 'Expected to find fonts for $missingFonts, but found ' '${fonts.keys}. This usually means you are referring to ' 'font families in an IconData class but not including them ' 'in the assets section of your pubspec.yaml, are missing ' @@ -155,7 +155,9 @@ class IconTreeShaker { final result = {}; const kSpacePoint = 32; for (final MapEntry(:key, :value) in fonts.entries) { - final List? codePoints = iconData[key]; + final int? fallbackCodePoint = _kKnownIconFontFallbackCodePoints[key]; + final List? codePoints = + iconData[key] ?? (fallbackCodePoint != null ? [fallbackCodePoint] : null); if (codePoints == null) { throw IconTreeShakerException._( 'Expected to font code points for $key, but none were found.', @@ -269,6 +271,14 @@ class IconTreeShaker { 'by providing the --no-tree-shake-icons flag when building your app.'; } + /// Known icon font families that should be subsetted even if 0 icons are recorded. + /// Subsetting unused icon fonts to a single dummy icon ensures that unused fonts + /// are not bundled in their entirety. + static const Map _kKnownIconFontFallbackCodePoints = { + 'MaterialIcons': 57415, // 0xe047, Icons.add + 'packages/cupertino_icons/CupertinoIcons': 62418, // 0xf3d2, CupertinoIcons.chevron_left + }; + /// Returns a map of { fontFamily: relativePath } pairs. Future> _parseFontJson(String fontManifestData, Set families) async { final result = {}; @@ -285,7 +295,8 @@ class IconTreeShaker { 'got: ${map['family']}.', ); } - if (!families.contains(familyKey)) { + if (!families.contains(familyKey) && + !_kKnownIconFontFallbackCodePoints.containsKey(familyKey)) { continue; } final List> fonts = _getList( @@ -465,30 +476,3 @@ class IconTreeShakerException implements Exception { 'To disable icon tree shaking, pass --no-tree-shake-icons to the requested ' 'flutter build command'; } - -extension on Recordings { - /// Returns a new [Recordings] containing all usages from both `this` and - /// [other]. - /// - /// If a definition is present in both recordings, its usages from both - /// are combined in the returned [Recordings]. - Recordings merge(Recordings other) { - final newCalls = >{}; - for (final MapEntry(:key, :value) in calls.entries) { - newCalls[key] = [...value]; - } - for (final MapEntry(:key, :value) in other.calls.entries) { - newCalls.putIfAbsent(key, () => []).addAll(value); - } - - final newInstances = >{}; - for (final MapEntry(:key, :value) in instances.entries) { - newInstances[key] = [...value]; - } - for (final MapEntry(:key, :value) in other.instances.entries) { - newInstances.putIfAbsent(key, () => []).addAll(value); - } - - return Recordings(calls: newCalls, instances: newInstances); - } -} diff --git a/packages/flutter_tools/lib/src/build_system/targets/ios.dart b/packages/flutter_tools/lib/src/build_system/targets/ios.dart index 9535489b..664aa9d0 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/ios.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/ios.dart @@ -46,7 +46,7 @@ abstract class AotAssemblyBase extends Target { final snapshotter = AOTSnapshotter( fileSystem: environment.fileSystem, logger: environment.logger, - xcode: globals.xcode!, + xcode: globals.xcode, artifacts: environment.artifacts, processManager: environment.processManager, ); @@ -79,7 +79,7 @@ abstract class AotAssemblyBase extends Target { throw Exception('aot_assembly is only supported for iOS applications.'); } - final EnvironmentType? environmentType = environmentTypeFromSdkroot( + final EnvironmentType environmentType = environmentTypeFromSdkroot( sdkRoot, environment.fileSystem, ); @@ -277,7 +277,7 @@ abstract class UnpackIOS extends UnpackDarwin { } // Copy Flutter framework. - final EnvironmentType? environmentType = environmentTypeFromSdkroot( + final EnvironmentType environmentType = environmentTypeFromSdkroot( sdkRoot, environment.fileSystem, ); @@ -531,7 +531,7 @@ class DebugIosLLDBInit extends Target { if (sdkRoot == null) { throw MissingDefineException(kSdkRoot, name); } - final EnvironmentType? environmentType = environmentTypeFromSdkroot( + final EnvironmentType environmentType = environmentTypeFromSdkroot( sdkRoot, environment.fileSystem, ); @@ -886,7 +886,7 @@ Future _createStubAppFramework( static const int Moo = 88; '''); - final EnvironmentType? environmentType = environmentTypeFromSdkroot(sdkRoot, fileSystem); + final EnvironmentType environmentType = environmentTypeFromSdkroot(sdkRoot, fileSystem); await globals.xcode!.clang([ '-x', diff --git a/packages/flutter_tools/lib/src/build_system/targets/linux.dart b/packages/flutter_tools/lib/src/build_system/targets/linux.dart index 796bd7da..65e359db 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/linux.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/linux.dart @@ -173,8 +173,8 @@ class LinuxAotBundle extends Target { /// Create a [LinuxAotBundle] wrapper for [aotTarget]. const LinuxAotBundle(this.aotTarget); - /// The [AotElfBase] subclass that produces the app.so. - final AotElfBase aotTarget; + /// The [AotSnapshotBase] subclass that produces the app.so. + final AotSnapshotBase aotTarget; @override String get name => 'linux_aot_bundle'; @@ -226,7 +226,7 @@ class ProfileBundleLinuxAssets extends BundleLinuxAssets { @override List get dependencies => [ ...super.dependencies, - LinuxAotBundle(AotElfProfile(targetPlatform)), + LinuxAotBundle(AotSnapshotProfile(targetPlatform)), ]; } @@ -242,6 +242,6 @@ class ReleaseBundleLinuxAssets extends BundleLinuxAssets { @override List get dependencies => [ ...super.dependencies, - LinuxAotBundle(AotElfRelease(targetPlatform)), + LinuxAotBundle(AotSnapshotRelease(targetPlatform)), ]; } diff --git a/packages/flutter_tools/lib/src/build_system/targets/localizations.dart b/packages/flutter_tools/lib/src/build_system/targets/localizations.dart index b9d758d2..e2944246 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/localizations.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/localizations.dart @@ -70,9 +70,9 @@ class GenerateLocalizationsTarget extends Target { processManager: environment.processManager, ); - final dependencies = - json.decode(environment.buildDir.childFile(_kDependenciesFileName).readAsStringSync()) - as Map; + final dependencies = json.decode( + environment.buildDir.childFile(_kDependenciesFileName).readAsStringSync(), + ) as Map; final inputs = dependencies['inputs'] as List?; final outputs = dependencies['outputs'] as List?; final depfile = Depfile( diff --git a/packages/flutter_tools/lib/src/build_system/targets/macos.dart b/packages/flutter_tools/lib/src/build_system/targets/macos.dart index e42533a8..d639e239 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/macos.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/macos.dart @@ -295,7 +295,7 @@ class CompileMacOSFramework extends Target { final snapshotter = AOTSnapshotter( fileSystem: environment.fileSystem, logger: environment.logger, - xcode: globals.xcode!, + xcode: globals.xcode, artifacts: environment.artifacts, processManager: environment.processManager, ); 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 f729baee..b8c13699 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 @@ -32,10 +32,7 @@ enum HookPlatform { native, web } /// Runs the dart build of the app. class BuildHooks extends Target { - const BuildHooks({ - this.platform = HookPlatform.native, - @visibleForTesting FlutterNativeAssetsBuildRunner? buildRunner, - }) : _buildRunner = buildRunner; + const BuildHooks({this.platform = HookPlatform.native, @visibleForTesting this._buildRunner}); final FlutterNativeAssetsBuildRunner? _buildRunner; @@ -129,8 +126,8 @@ class LinkHooks extends Target { const LinkHooks({ this.platform = HookPlatform.native, this.extraDependencies = const [], - FlutterNativeAssetsBuildRunner? buildRunner, - }) : _buildRunner = buildRunner; + this._buildRunner, + }); final HookPlatform platform; final List extraDependencies; @@ -319,10 +316,9 @@ class InstallCodeAssets extends Target { final Uri nativeAssetsFileUri = environment.buildDir.childFile(nativeAssetsFilename).uri; Uri targetUri = environment.outputDir.childDirectory('native_assets').uri; - final String osName = targetPlatform.osName; - if (osName == 'linux' || osName == 'windows') { + if (targetPlatform.os case .linux || .windows) { // Avoid needing migration for CMake files, keep old directory structure. - targetUri = targetUri.resolve('$osName/'); + targetUri = targetUri.resolve('${targetPlatform.osName}/'); } final List installedFiles = await installCodeAssets( @@ -413,14 +409,32 @@ Future createFlutterNativeAssetsBuildRunner( final BuildMode buildMode = buildModeEnvironment == null ? BuildMode.debug : BuildMode.fromCliName(buildModeEnvironment); - final bool includeDevDependencies = !buildMode.isRelease; + final bool includeDevDependencies = !buildMode.isRelease || _isTestTarget(environment); return FlutterNativeAssetsBuildRunnerImpl( environment.packageConfigPath, packageConfig, fileSystem, environment.logger, + environment.platform, runPackageName, includeDevDependencies: includeDevDependencies, pubspecPath, ); } + +bool _isTestTarget(Environment environment) { + final String? targetFile = environment.defines[kTargetFile]; + if (targetFile == null) { + return false; + } + final String absoluteTargetPath = environment.fileSystem.path.isAbsolute(targetFile) + ? targetFile + : environment.fileSystem.path.join(environment.projectDir.path, targetFile); + final String relativePath = environment.fileSystem.path.relative( + environment.fileSystem.path.normalize(absoluteTargetPath), + from: environment.projectDir.path, + ); + final List parts = environment.fileSystem.path.split(relativePath); + return parts.isNotEmpty && + const {'integration_test', 'test_driver', 'test'}.contains(parts.first); +} diff --git a/packages/flutter_tools/lib/src/build_system/targets/web.dart b/packages/flutter_tools/lib/src/build_system/targets/web.dart index 12ee35e1..80d76c60 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/web.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/web.dart @@ -12,6 +12,7 @@ import 'package:unified_analytics/unified_analytics.dart'; import '../../artifacts.dart'; import '../../base/common.dart'; import '../../base/file_system.dart'; +import '../../base/logger.dart'; import '../../base/process.dart'; import '../../build_info.dart'; import '../../cache.dart'; @@ -25,6 +26,7 @@ import '../../isolated/native_assets/dart_hook_result.dart'; import '../../project.dart'; import '../../web/bootstrap.dart'; import '../../web/compile.dart'; +import '../../web/content_hash.dart'; import '../../web/file_generators/flutter_service_worker_js.dart'; import '../../web/file_generators/main_dart.dart' as main_dart; import '../../web/web_constants.dart'; @@ -40,6 +42,10 @@ import 'native_assets.dart'; const String _kBundledFallbackRobotoFamily = 'Roboto'; const String _kBundledFallbackRobotoAsset = 'fonts/fallback/Roboto-Regular.ttf'; const String _kFontManifestJsonFile = 'FontManifest.json'; +const String _kLegacyWebDeprecationWarning = + 'dart:html, dart:js, and legacy JS interop libraries are deprecated and planned for removal ' + 'from the Dart SDK in a future release. Migrate your project to ' + 'package:web and dart:js_interop.'; /// Generates an entry point for a web target. // Keep this in sync with build_runner/resident_web_runner.dart @@ -114,12 +120,53 @@ class WebEntrypointTarget extends Target { } } +@visibleForTesting +String hashAndRenameWebOutput({required File file, File? sourceMapFile}) => + _hashAndRenameWebOutput(file: file, sourceMapFile: sourceMapFile); + +String _hashAndRenameWebOutput({required File file, File? sourceMapFile}) { + if (!file.existsSync()) { + return file.basename; + } + + // Hash and rename the source map first so its hashed filename can be + // written into the JS/MJS `sourceMappingURL` comment before hashing the + // code file itself. This ensures the code file's final on-disk bytes match + // the content hash in its filename. A `.wasm` binary embeds its map name in + // a binary custom section that cannot be rewritten here, so its map keeps + // the unhashed name. + final isWasm = file.fileSystem.path.extension(file.path) == '.wasm'; + if (sourceMapFile != null && sourceMapFile.existsSync() && !isWasm) { + final String oldMapBasename = sourceMapFile.basename; + final String mapHash = computeShortContentHash(sourceMapFile.readAsBytesSync()); + final String newMapBasename = computeHashedBasename(oldMapBasename, mapHash, file.fileSystem); + sourceMapFile.renameSync(sourceMapFile.parent.childFile(newMapBasename).path); + + final String content = file.readAsStringSync(); + final mapDirectiveRegex = RegExp( + r'//[#@]\s*sourceMappingURL=' + RegExp.escape(oldMapBasename) + r'(?=\s*$)', + multiLine: true, + ); + if (mapDirectiveRegex.hasMatch(content)) { + file.writeAsStringSync( + content.replaceFirst(mapDirectiveRegex, '//# sourceMappingURL=$newMapBasename'), + ); + } + } + + final String contentHash = computeShortContentHash(file.readAsBytesSync()); + final String newBasename = computeHashedBasename(file.basename, contentHash, file.fileSystem); + file.renameSync(file.parent.childFile(newBasename).path); + return newBasename; +} + abstract class Dart2WebTarget extends Target { const Dart2WebTarget(); WebCompilerConfig get compilerConfig; Map get buildConfig; + Map getBuildConfig(Environment environment) => buildConfig; Iterable buildFiles(Environment environment); Iterable get buildPatternStems; @@ -161,12 +208,65 @@ abstract class Dart2WebTarget extends Target { @override String get buildKey => compilerConfig.buildKey; + + String _resolveHashedBasename(Directory dir, RegExp pattern, String defaultName) { + if (!dir.existsSync()) { + return defaultName; + } + final List candidates = dir + .listSync() + .whereType() + .where((File f) => pattern.hasMatch(f.basename)) + .toList(); + if (candidates.isEmpty) { + return defaultName; + } + final List hashedCandidates = candidates + .where((File f) => f.basename != defaultName) + .toList(); + final targetCandidates = hashedCandidates.isNotEmpty ? hashedCandidates : candidates; + if (targetCandidates.length == 1) { + return targetCandidates.first.basename; + } + targetCandidates.sort((File a, File b) => b.lastModifiedSync().compareTo(a.lastModifiedSync())); + return targetCandidates.first.basename; + } + + void _cleanStaleBuildOutputs(Directory dir, List patterns) { + if (!dir.existsSync()) { + return; + } + for (final File file in dir.listSync().whereType()) { + if (patterns.any((RegExp pattern) => pattern.hasMatch(file.basename))) { + file.deleteSync(); + } + } + } + + void _checkNoDeferredParts(Directory dir, RegExp partRegex) { + final bool hasDeferredParts = dir.listSync().whereType().any( + (File file) => partRegex.hasMatch(file.basename), + ); + if (hasDeferredParts) { + throwToolExit( + '"--web-content-hash" does not yet support deferred imports: ' + 'deferred part files keep unhashed names and can be served stale ' + 'from the browser cache alongside a new entrypoint. Remove the ' + 'deferred imports or build without "--web-content-hash".', + ); + } + } } /// Compiles a web entry point with dart2js. class Dart2JSTarget extends Dart2WebTarget { Dart2JSTarget(this.compilerConfig); + static final RegExp _mainJsRegex = RegExp(r'^main\.dart(\.[a-f0-9]+)?\.js$'); + static final RegExp _mainJsMapRegex = RegExp(r'^main\.dart(\.[a-f0-9]+)?\.js\.map$'); + static final RegExp _partFileRegex = RegExp(r'^main\.dart\.js_[0-9].*\.part\.js$'); + static final RegExp _partFileMapRegex = RegExp(r'^main\.dart\.js_[0-9].*\.part\.js\.map$'); + @override final JsCompilerConfig compilerConfig; @@ -183,6 +283,19 @@ class Dart2JSTarget extends Dart2WebTarget { throw MissingDefineException(kBuildMode, name); } final buildMode = BuildMode.fromCliName(buildModeEnvironment); + + if (compilerConfig.webContentHash) { + // Delete previously compiled JS entrypoints and deferred part files so + // that stale hashed outputs do not remain in buildDir and leftover + // .part.js files from prior builds do not trigger a false-positive + // deferred loading error. + _cleanStaleBuildOutputs(environment.buildDir, [ + _mainJsRegex, + _mainJsMapRegex, + _partFileRegex, + _partFileMapRegex, + ]); + } final Artifacts artifacts = environment.artifacts; final String platformBinariesPath = artifacts .getHostArtifact(HostArtifact.webPlatformKernelFolder) @@ -220,11 +333,11 @@ class Dart2JSTarget extends Dart2WebTarget { // Run the dart2js compilation in two stages, so that icon tree shaking can // parse the kernel file for web builds. - await processUtils.run(cfeCompilationArgs, throwOnError: true); + await _runDart2js(processUtils, environment.logger, cfeCompilationArgs); final File outputJSFile = environment.buildDir.childFile('main.dart.js'); - await processUtils.run(throwOnError: true, [ + await _runDart2js(processUtils, environment.logger, [ ...sharedCommandOptions, ...compilerConfig.toCommandOptions(buildMode), '-o', @@ -247,14 +360,57 @@ class Dart2JSTarget extends Dart2WebTarget { ); return; } + var finalOutputFile = outputJSFile; + if (compilerConfig.webContentHash) { + _checkNoDeferredParts(environment.buildDir, _partFileRegex); + final String newBasename = _hashAndRenameWebOutput( + file: outputJSFile, + sourceMapFile: compilerConfig.sourceMaps + ? environment.buildDir.childFile('main.dart.js.map') + : null, + ); + finalOutputFile = environment.buildDir.childFile(newBasename); + } final DepfileService depFileService = environment.depFileService; final Depfile depFile = depFileService.parseDart2js( environment.buildDir.childFile('app.dill.deps'), - outputJSFile, + finalOutputFile, ); depFileService.writeToFile(depFile, environment.buildDir.childFile('dart2js.d')); } + /// Runs one phase of the dart2js compilation with [args]. + /// + /// dart2js reports its diagnostics, such as compile errors and the import + /// paths that reach deprecated JS interop libraries, on stdout. If the + /// compilation fails, that output is shown to the user before failing the + /// target. + static Future _runDart2js( + ProcessUtils processUtils, + Logger logger, + List args, + ) async { + final RunResult result = await processUtils.run(args, encoding: utf8); + if (result.exitCode == 0) { + return; + } + _printCompilerOutput(logger, result); + throwToolExit('Failed to compile application for the Web.'); + } + + /// Prints the stdout and stderr of [result] as errors. + /// + /// dart2js writes its diagnostics to stdout, but they are surfaced on the + /// tool's stderr, consistent with frontend server (DDC) diagnostics. + static void _printCompilerOutput(Logger logger, RunResult result) { + for (final output in [result.stdout, result.stderr]) { + final String trimmed = output.trimRight(); + if (trimmed.isNotEmpty) { + logger.printError(trimmed); + } + } + } + @override Map get buildConfig => { 'compileTarget': 'dart2js', @@ -263,47 +419,100 @@ class Dart2JSTarget extends Dart2WebTarget { }; @override - Iterable buildFiles(Environment environment) => - environment.buildDir.listSync(recursive: true).whereType().where((File file) { - if (file.basename == 'main.dart.js') { - return true; - } - if (file.basename == 'main.dart.js.map') { - return compilerConfig.sourceMaps; - } - final partFileRegex = RegExp(r'main\.dart\.js_[0-9].*\.part\.js'); - if (partFileRegex.hasMatch(file.basename)) { - return true; - } + Map getBuildConfig(Environment environment) { + final String mainJsPath = compilerConfig.webContentHash + ? _resolveHashedBasename(environment.buildDir, _mainJsRegex, 'main.dart.js') + : 'main.dart.js'; + return { + 'compileTarget': 'dart2js', + 'renderer': compilerConfig.renderer.name, + 'mainJsPath': mainJsPath, + }; + } - if (compilerConfig.sourceMaps) { - final partFileSourceMapRegex = RegExp(r'main\.dart\.js_[0-9].*.part\.js\.map'); - if (partFileSourceMapRegex.hasMatch(file.basename)) { - return true; - } - } + @override + Iterable buildFiles(Environment environment) { + final String mainJsName = + (getBuildConfig(environment)['mainJsPath'] as String?) ?? 'main.dart.js'; + final String? mainJsMapName = !compilerConfig.sourceMaps + ? null + : compilerConfig.webContentHash + ? _resolveHashedBasename(environment.buildDir, _mainJsMapRegex, 'main.dart.js.map') + : '$mainJsName.map'; + return environment.buildDir.listSync(recursive: true).whereType().where((File file) { + if (file.basename == mainJsName) { + return true; + } + if (mainJsMapName != null && file.basename == mainJsMapName) { + return true; + } + if (_partFileRegex.hasMatch(file.basename)) { + return true; + } - if (compilerConfig.dumpInfo) { - if (file.basename == 'main.dart.js.info.json') { - return true; - } + if (compilerConfig.sourceMaps && _partFileMapRegex.hasMatch(file.basename)) { + return true; + } + + if (compilerConfig.dumpInfo) { + if (file.basename == 'main.dart.js.info.json') { + return true; } - return false; - }); + } + return false; + }); + } @override Iterable get buildPatternStems => [ - 'main.dart.js', + if (compilerConfig.webContentHash) 'main.dart.*.js' else 'main.dart.js', 'main.dart.js_*.part.js', - if (compilerConfig.sourceMaps) ...['main.dart.js.map', 'main.dart.js_*.part.js.map'], + if (compilerConfig.sourceMaps) ...[ + if (compilerConfig.webContentHash) 'main.dart.*.js.map' else 'main.dart.js.map', + 'main.dart.js_*.part.js.map', + ], if (featureFlags.isRecordUseEnabled) LinkHooks.recordedUsesJsFileName, ]; } +/// The classification of a wasm dry-run compile, derived from the compiler's +/// exit code and output in [Dart2WasmTarget._classifyDryRunResult]. +/// +/// dart2wasm exits with code 254 when dry-run analysis completes with issues; +/// any other non-zero exit code is unexpected. +enum _DryRunOutcome { + /// The compiler exited with an unexpected exit code (anything other than 0 + /// or 254), e.g. an internal compiler error. + crash, + + /// The compiler exited with code 0: the app is wasm-compatible. + success, + + /// The compiler exited with code 254 and wrote to stderr: compilation + /// failed for reasons other than dry-run findings (e.g. invalid Dart code + /// that does not compile for any web target). + failure, + + /// The compiler exited with code 254 and wrote only to stdout: the + /// expected dry-run report of wasm-incompatible API usages, one finding + /// per line, e.g.: + /// + /// package:bar/some/path.dart 120:5 - dart:js unsupported (1) + findings, + + /// The compiler exited with code 254 but produced no output at all. + unknown, +} + /// Compiles a web entry point with dart2wasm. class Dart2WasmTarget extends Dart2WebTarget { Dart2WasmTarget(this.compilerConfig, this._analytics); + static final RegExp _mainWasmRegex = RegExp(r'^main\.dart(\.[a-f0-9]+)?\.wasm$'); + static final RegExp _mainWasmMapRegex = RegExp(r'^main\.dart(\.[a-f0-9]+)?\.wasm\.map$'); + static final RegExp _mainMjsRegex = RegExp(r'^main\.dart(\.[a-f0-9]+)?\.mjs$'); + static final RegExp _mainMjsMapRegex = RegExp(r'^main\.dart(\.[a-f0-9]+)?\.mjs\.map$'); + @override final WasmCompilerConfig compilerConfig; @@ -345,6 +554,17 @@ class Dart2WasmTarget extends Dart2WebTarget { throw MissingDefineException(kBuildMode, name); } final buildMode = BuildMode.fromCliName(buildModeEnvironment); + + if (compilerConfig.webContentHash) { + _cleanStaleBuildOutputs(environment.buildDir, [ + _mainWasmRegex, + _mainWasmMapRegex, + _mainMjsRegex, + _mainMjsMapRegex, + _partWasmRegex, + _partWasmMapRegex, + ]); + } final Artifacts artifacts = environment.artifacts; final File outputWasmFile = environment.buildDir.childFile('main.dart.wasm'); final File depFile = environment.buildDir.childFile('dart2wasm.d'); @@ -392,6 +612,24 @@ class Dart2WasmTarget extends Dart2WebTarget { environment.logger.printError(runResult.stderr); _checkForLegacyWebImports(environment, runResult.stdout, runResult.stderr); throwToolExit('Failed to compile application for the Web.'); + } else if (compilerConfig.webContentHash) { + _checkNoDeferredParts(environment.buildDir, _partWasmRegex); + final String newWasmBasename = _hashAndRenameWebOutput( + file: outputWasmFile, + sourceMapFile: compilerConfig.sourceMaps + ? environment.buildDir.childFile('main.dart.wasm.map') + : null, + ); + final String newMjsBasename = _hashAndRenameWebOutput( + file: environment.buildDir.childFile('main.dart.mjs'), + sourceMapFile: compilerConfig.sourceMaps + ? environment.buildDir.childFile('main.dart.mjs.map') + : null, + ); + _rewriteDepfileOutputs(environment, depFile, { + 'main.dart.wasm': newWasmBasename, + 'main.dart.mjs': newMjsBasename, + }); } final File recordedUsesFile = environment.buildDir.childFile( LinkHooks.recordedUsesWasmFileName, @@ -401,6 +639,19 @@ class Dart2WasmTarget extends Dart2WebTarget { } } + void _rewriteDepfileOutputs(Environment env, File depFile, Map renamedBasenames) { + if (!depFile.existsSync()) { + return; + } + final Depfile parsed = env.depFileService.parse(depFile); + final List newOutputs = parsed.outputs.map((File f) { + final String? renamed = renamedBasenames[f.basename]; + return renamed != null ? f.parent.childFile(renamed) : f; + }).toList(); + final updatedDepfile = Depfile(parsed.inputs, newOutputs); + env.depFileService.writeToFile(updatedDepfile, depFile); + } + @override String get name => 'dart2wasm'; @@ -417,37 +668,71 @@ class Dart2WasmTarget extends Dart2WebTarget { 'jsSupportRuntimePath': 'main.dart.mjs', }; + @override + Map getBuildConfig(Environment environment) { + if (compilerConfig.dryRun) { + return const {}; + } + final String mainWasmPath = compilerConfig.webContentHash + ? _resolveHashedBasename(environment.buildDir, _mainWasmRegex, 'main.dart.wasm') + : 'main.dart.wasm'; + final String jsSupportRuntimePath = compilerConfig.webContentHash + ? _resolveHashedBasename(environment.buildDir, _mainMjsRegex, 'main.dart.mjs') + : 'main.dart.mjs'; + return { + 'compileTarget': 'dart2wasm', + 'renderer': compilerConfig.renderer.name, + 'mainWasmPath': mainWasmPath, + 'jsSupportRuntimePath': jsSupportRuntimePath, + }; + } + static final RegExp _partWasmRegex = RegExp(r'^main\.dart_module[0-9].*\.wasm$'); static final RegExp _partWasmMapRegex = RegExp(r'^main\.dart_module[0-9].*\.wasm\.map$'); @override - Iterable buildFiles(Environment environment) => compilerConfig.dryRun - ? const [] - : environment.buildDir.listSync(recursive: true).whereType().where((File file) { - if (file.basename == 'main.dart.wasm' || file.basename == 'main.dart.mjs') { - return true; - } - if (compilerConfig.sourceMaps && file.basename == 'main.dart.wasm.map') { - return true; - } - if (_partWasmRegex.hasMatch(file.basename)) { - return true; - } - if (compilerConfig.sourceMaps && _partWasmMapRegex.hasMatch(file.basename)) { - return true; - } - return false; - }); + Iterable buildFiles(Environment environment) { + if (compilerConfig.dryRun) { + return const []; + } + final Map config = getBuildConfig(environment); + final String mainWasmName = (config['mainWasmPath'] as String?) ?? 'main.dart.wasm'; + final String jsSupportName = (config['jsSupportRuntimePath'] as String?) ?? 'main.dart.mjs'; + const mainWasmMapName = 'main.dart.wasm.map'; + final String? jsSupportMapName = !compilerConfig.sourceMaps + ? null + : compilerConfig.webContentHash + ? _resolveHashedBasename(environment.buildDir, _mainMjsMapRegex, 'main.dart.mjs.map') + : '$jsSupportName.map'; + + return environment.buildDir.listSync(recursive: true).whereType().where((File file) { + if (file.basename == mainWasmName || file.basename == jsSupportName) { + return true; + } + if (compilerConfig.sourceMaps && + (file.basename == mainWasmMapName || file.basename == jsSupportMapName)) { + return true; + } + if (_partWasmRegex.hasMatch(file.basename)) { + return true; + } + if (compilerConfig.sourceMaps && _partWasmMapRegex.hasMatch(file.basename)) { + return true; + } + return false; + }); + } @override Iterable get buildPatternStems => compilerConfig.dryRun ? const [] : [ - 'main.dart.wasm', + if (compilerConfig.webContentHash) 'main.dart.*.wasm' else 'main.dart.wasm', 'main.dart_module*.wasm', - 'main.dart.mjs', + if (compilerConfig.webContentHash) 'main.dart.*.mjs' else 'main.dart.mjs', if (compilerConfig.sourceMaps) ...[ 'main.dart.wasm.map', + if (compilerConfig.webContentHash) 'main.dart.*.mjs.map' else 'main.dart.mjs.map', 'main.dart_module*.wasm.map', ], if (featureFlags.isRecordUseEnabled) LinkHooks.recordedUsesWasmFileName, @@ -456,135 +741,357 @@ class Dart2WasmTarget extends Dart2WebTarget { @visibleForTesting Random? dryRunRandom; + /// Logs the outcome of a dry-run compile to the user and reports it to + /// analytics, including per-error-code package findings when the dry run + /// produced them. Future _handleDryRunResult(Environment environment, RunResult runResult) async { final int exitCode = runResult.exitCode; final String stdout = runResult.stdout; final String stderr = runResult.stderr; - String? result; - final Map findingsInfo = {}; - if (exitCode != 0 && exitCode != 254) { - environment.logger.printWarning('Unexpected wasm dry run failure ($exitCode):'); - if (stderr.isNotEmpty) { - environment.logger.printWarning(stdout); - environment.logger.printWarning(stderr); - } - result = 'crash'; - } else if (exitCode == 0) { - environment.logger.printWarning( - 'Wasm dry run succeeded. Consider building and testing your application with the ' - '`--wasm` flag. See docs for more info: ' - 'https://docs.flutter.dev/platform-integration/web/wasm', - ); - result = 'success'; - } else if (stderr.isNotEmpty) { - environment.logger.printWarning('Wasm dry run failed:'); - environment.logger.printWarning(stdout); - environment.logger.printWarning(stderr); - result = 'failure'; - } else if (stdout.isNotEmpty) { - environment.logger.printWarning('Wasm dry run findings:'); - environment.logger.printWarning(stdout); - environment.logger.printWarning( - 'Consider addressing these issues to enable wasm builds. See docs for more info: ' - 'https://docs.flutter.dev/platform-integration/web/wasm\n', + final _DryRunOutcome outcome = _classifyDryRunResult( + exitCode: exitCode, + stdout: stdout, + stderr: stderr, + ); + _logDryRunResult( + environment: environment, + outcome: outcome, + exitCode: exitCode, + stdout: stdout, + stderr: stderr, + ); + + final Map findingsInfo; + if (outcome == _DryRunOutcome.findings) { + final Map? findings = await _collectFindingsInfo( + environment: environment, + stdout: stdout, + exitCode: exitCode, + result: outcome.name, ); - result = 'findings'; - final Map> errorCodeToImportUris = {}; - for (final String line in stdout.split('\n')) { - final Uri uri = Uri.parse(line.split(' ')[0]); - final String? errorCode = RegExp(r'\(([0-9]+)\)\s*$').firstMatch(line)?.group(1); - if (errorCode != null) { - (errorCodeToImportUris[errorCode] ??= {}).add(uri); - } + if (findings == null) { + return; } + findingsInfo = findings; + } else { + findingsInfo = const {}; + } - final PackageConfig packageConfigPackages; - try { - packageConfigPackages = await loadPackageConfigWithLogging( - findPackageConfigFileOrDefault(environment.projectDir), - logger: environment.logger, + _analytics.send( + Event.flutterWasmDryRunPackage( + result: outcome.name, + exitCode: exitCode, + findingsInfo: findingsInfo, + ), + ); + } + + /// Classifies the dry-run compile result into a [_DryRunOutcome]; see the + /// enum values for the classification rules. + static _DryRunOutcome _classifyDryRunResult({ + required int exitCode, + required String stdout, + required String stderr, + }) { + if (exitCode != 0 && exitCode != 254) { + return _DryRunOutcome.crash; + } + if (exitCode == 0) { + return _DryRunOutcome.success; + } + if (stderr.isNotEmpty) { + return _DryRunOutcome.failure; + } + if (stdout.isNotEmpty) { + return _DryRunOutcome.findings; + } + return _DryRunOutcome.unknown; + } + + /// Logs the warnings for a dry-run compile with the given [outcome]. + /// + /// When [WasmCompilerConfig.omitDeprecatedJsInteropFindings] is set, the + /// findings already reported by dart2js are left out, and nothing is logged + /// if no other findings remain. + void _logDryRunResult({ + required Environment environment, + required _DryRunOutcome outcome, + required int exitCode, + required String stdout, + required String stderr, + }) { + final String displayedStdout = + outcome == _DryRunOutcome.findings && compilerConfig.omitDeprecatedJsInteropFindings + ? _removeDeprecatedJsInteropFindings(stdout) + : stdout; + if (outcome == _DryRunOutcome.findings && displayedStdout.isEmpty) { + return; + } + final Logger logger = environment.logger; + switch (outcome) { + case _DryRunOutcome.crash: + logger.printWarning('Unexpected wasm dry run failure ($exitCode):'); + _printDryRunStreams(logger, stdout: stdout, stderr: stderr); + case _DryRunOutcome.success: + logger.printWarning( + 'Wasm dry run succeeded. Consider building and testing your application with the ' + '`--wasm` flag. See docs for more info: ' + 'https://docs.flutter.dev/platform-integration/web/wasm', ); - } on ToolExit { - _analytics.send( - Event.flutterWasmDryRunPackage( - result: result, - exitCode: exitCode, - findingsInfo: { - 'error': 'packageConfigNotLoaded', - 'findings': errorCodeToImportUris.keys.join(','), - }, - ), + case _DryRunOutcome.failure: + logger.printWarning('Wasm dry run failed:'); + _printDryRunStreams(logger, stdout: stdout, stderr: stderr); + case _DryRunOutcome.findings: + logger.printWarning('Wasm dry run findings:'); + logger.printWarning(displayedStdout); + final deprecationWarning = _hasDeprecatedJsInteropFindings(displayedStdout) + ? '$_kLegacyWebDeprecationWarning\n' + : ''; + logger.printWarning( + 'Consider addressing these issues to enable wasm builds. ' + '$deprecationWarning' + 'See docs for more info: ' + 'https://docs.flutter.dev/platform-integration/web/wasm\n', ); - return; - } + case _DryRunOutcome.unknown: + break; + } + _checkForLegacyWebImports(environment, displayedStdout, stderr); + logger.printWarning('Use --no-wasm-dry-run to disable these warnings.'); + } - final Map hostedPackages = {}; - final Set privatePackages = {}; - for (final Package package in packageConfigPackages.packages) { - final String packageName = package.name; - if (package.root.toString().contains('hosted/pub.dev')) { - final String? packageVersion = RegExp( - r'([0-9]+\.[0-9]+\.[0-9]+(?:-[\w\.-]+)?)', - ).firstMatch(package.root.toString())?.group(1); - hostedPackages[packageName] = packageVersion ?? '?'; - } else { - privatePackages.add(packageName); - } - } + /// Prints the non-empty [stdout] and [stderr] of a dry run as labeled + /// warnings. + static void _printDryRunStreams(Logger logger, {required String stdout, required String stderr}) { + if (stdout.isNotEmpty) { + logger.printWarning('stdout:'); + logger.printWarning(stdout); + } + if (stderr.isNotEmpty) { + logger.printWarning('stderr:'); + logger.printWarning(stderr); + } + } - errorCodeToImportUris.forEach((String errorCode, Set uris) { - final Set hostedPackageFindings = {}; - // Randomize the URI order so that we - final urisList = [...uris]..shuffle(dryRunRandom); - var hostApp = false; - var privatePackage = false; - for (final uri in urisList) { - final String packageName = uri.pathSegments.first; - final String? hostedPackageVersion = hostedPackages[packageName]; - if (uri.scheme == 'package') { - if (hostedPackageVersion != null) { - hostedPackageFindings.add('$packageName:$hostedPackageVersion'); - continue; - } else if (privatePackages.contains(packageName)) { - privatePackage = true; - continue; - } - } - hostApp = true; - } - final String? hpHint = switch ((hostApp, privatePackage)) { - (true, true) => '-hp', - (true, false) => '-h', - (false, true) => '-p', - _ => null, - }; + /// Builds the per-error-code analytics payload for a dry run that produced + /// findings, mapping each error code (keyed as `E`) to a formatted, + /// truncated summary of the pub-hosted packages it was found in. + /// + /// Returns null if the project's package config could not be loaded; in + /// that case an error event is sent to analytics instead. + Future?> _collectFindingsInfo({ + required Environment environment, + required String stdout, + required int exitCode, + required String result, + }) async { + final Map> errorCodeToImportUris = _parseWasmFindings(stdout); + + final PackageConfig packageConfigPackages; + try { + packageConfigPackages = await loadPackageConfigWithLogging( + findPackageConfigFileOrDefault(environment.projectDir), + logger: environment.logger, + ); + } on ToolExit { + _analytics.send( + Event.flutterWasmDryRunPackage( + result: result, + exitCode: exitCode, + findingsInfo: { + 'error': 'packageConfigNotLoaded', + 'findings': errorCodeToImportUris.keys.join(','), + }, + ), + ); + return null; + } - final findingsBuffer = StringBuffer(hpHint ?? ''); - for (final hostedPackageFinding in hostedPackageFindings) { - // Try to fit as many findings as we can into the 100 character limit imposed - // by google analytics. - final pendingString = '${findingsBuffer.isNotEmpty ? ',' : ''}$hostedPackageFinding'; - if (findingsBuffer.length + pendingString.length <= 100) { - findingsBuffer.write(pendingString); - } - } - findingsInfo['E$errorCode'] = findingsBuffer.toString(); - }); + final (:Map hosted, :Set private) = _categorizePackages( + packageConfigPackages, + ); + final findingsInfo = {}; + for (final MapEntry(key: errorCode, value: uris) in errorCodeToImportUris.entries) { + findingsInfo['E$errorCode'] = _formatFindingsBuffer( + uris: uris, + hostedPackages: hosted, + privatePackages: private, + random: dryRunRandom, + ); } - result ??= 'unknown'; + return findingsInfo; + } - _checkForLegacyWebImports(environment, stdout, stderr); + /// Matches the trailing error code of a dry-run finding line, e.g. the + /// `(1)` in: + /// + /// package:bar/some/path.dart 120:5 - dart:js unsupported (1) + static final RegExp _wasmErrorCodePattern = RegExp(r'\(([0-9]+)\)\s*$'); + + /// The dry-run error codes for imports of the deprecated JS interop + /// libraries, which dart2js reports as errors with + /// `--no-deprecated-js-interop`. + /// + /// These match `_DryRunErrorCode` in dart2wasm's `dry_run.dart`: + /// `noDartHtml` (0, which also covers `dart:indexed_db`, `dart:svg`, + /// `dart:web_audio` and `dart:web_gl`), `noDartJs` (1), `noPackageJs` (14) + /// and `noDartJsUtil` (15). + static const _deprecatedJsInteropErrorCodes = {'0', '1', '14', '15'}; + + /// Returns the trailing error code of a dry-run finding [line], or null if + /// [line] is not a finding. + static String? _findingErrorCode(String line) => _wasmErrorCodePattern.firstMatch(line)?.group(1); + + /// Parses the dry-run findings printed to [stdout], one finding per line + /// in the form ` - ()`, e.g.: + /// + /// package:bar/some/path.dart 120:5 - dart:js unsupported (1) + /// + /// Returns a map from error code (`'1'` above) to the set of URIs that + /// triggered it (`package:bar/some/path.dart` above). Lines without a + /// trailing error code are ignored. + static Map> _parseWasmFindings(String stdout) { + final errorCodeToImportUris = >{}; + for (final String line in stdout.split('\n')) { + final String? errorCode = _findingErrorCode(line); + if (errorCode != null) { + final Uri uri = Uri.parse(line.split(' ')[0]); + (errorCodeToImportUris[errorCode] ??= {}).add(uri); + } + } + return errorCodeToImportUris; + } - environment.logger.printWarning('Use --no-wasm-dry-run to disable these warnings.'); + /// Removes the findings for deprecated JS interop imports from the dry-run + /// [stdout]. + /// + /// Returns an empty string if no other findings remain. + static String _removeDeprecatedJsInteropFindings(String stdout) { + final List remainingLines = stdout + .split('\n') + .where((String line) => !_isDeprecatedJsInteropFinding(line)) + .toList(); + final bool hasRemainingFindings = remainingLines.any( + (String line) => _findingErrorCode(line) != null, + ); + return hasRemainingFindings ? remainingLines.join('\n') : ''; + } - _analytics.send( - Event.flutterWasmDryRunPackage( - result: result, - exitCode: exitCode, - findingsInfo: findingsInfo, - ), + /// Whether the dry-run [stdout] contains a finding for a deprecated JS + /// interop import. + static bool _hasDeprecatedJsInteropFindings(String stdout) => + stdout.split('\n').any(_isDeprecatedJsInteropFinding); + + /// Whether [line] is a dry-run finding for a deprecated JS interop import. + static bool _isDeprecatedJsInteropFinding(String line) => + _deprecatedJsInteropErrorCodes.contains(_findingErrorCode(line)); + + /// Splits the packages in the project's package config into pub-hosted + /// packages (mapped to their resolved version, which is safe to report to + /// analytics) and private packages (path/git dependencies and the like, + /// whose names must not be reported). + static ({Map hosted, Set private}) _categorizePackages( + PackageConfig packageConfigPackages, + ) { + final hostedPackages = {}; + final privatePackages = {}; + for (final Package package in packageConfigPackages.packages) { + final String packageName = package.name; + if (package.root.pathSegments.where((String s) => s.isNotEmpty).toList() + case [..., 'hosted', _, final packageFolder] + when packageFolder.startsWith('$packageName-')) { + // Hosted package directories in .pub-cache follow '-'. + // Substring past the package name and hyphen to extract the version. + hostedPackages[packageName] = packageFolder.substring(packageName.length + 1); + } else { + privatePackages.add(packageName); + } + } + return (hosted: hostedPackages, private: privatePackages); + } + + /// Formats the [uris] associated with a single error code into the + /// analytics value string: an optional leading hint (`-h` if the host app + /// itself had findings, `-p` if a private package did, `-hp` for both) + /// followed by a truncated, comma-separated list of `name:version` entries + /// for the affected pub-hosted packages. + static String _formatFindingsBuffer({ + required Set uris, + required Map hostedPackages, + required Set privatePackages, + Random? random, + }) { + // Shuffle the URIs so that, when the analytics buffer is truncated to + // [_truncateAnalyticsBuffer]'s character limit below, the reported + // packages are a random sample rather than always the first few in + // iteration order. + final urisList = [...uris]..shuffle(random); + final (:Set hostedFindings, :bool hostApp, :bool privatePackage) = _classifyUris( + urisList, + hostedPackages, + privatePackages, ); + final String hpHint = switch ((hostApp, privatePackage)) { + (true, true) => '-hp', + (true, false) => '-h', + (false, true) => '-p', + (false, false) => '', + }; + return _truncateAnalyticsBuffer(hpHint, hostedFindings); + } + + /// Buckets the [uris] from a set of findings by where they came from: + /// `name:version` strings for each affected pub-hosted package + /// (`hostedFindings`), whether any URI belonged to a private package + /// (`privatePackage`), and whether any URI came from outside a package + /// entirely, i.e. from the host app itself (`hostApp`). + /// + /// Only pub-hosted package names are collected; private package names and + /// host app paths are reduced to booleans so nothing identifying is + /// reported to analytics. + static ({Set hostedFindings, bool hostApp, bool privatePackage}) _classifyUris( + Iterable uris, + Map hostedPackages, + Set privatePackages, + ) { + final hostedFindings = {}; + var hostApp = false; + var privatePackage = false; + for (final uri in uris) { + if (uri.scheme == 'package' && uri.pathSegments.isNotEmpty) { + final String packageName = uri.pathSegments.first; + if (hostedPackages.containsKey(packageName)) { + hostedFindings.add('$packageName:${hostedPackages[packageName]}'); + continue; + } + if (privatePackages.contains(packageName)) { + privatePackage = true; + continue; + } + } + hostApp = true; + } + return (hostedFindings: hostedFindings, hostApp: hostApp, privatePackage: privatePackage); + } + + /// Joins [prefix] and as many comma-separated [findings] as fit within + /// [maxLength] characters, silently dropping the rest. + static String _truncateAnalyticsBuffer( + String prefix, + Iterable findings, { + int maxLength = 100, + }) { + final findingsBuffer = StringBuffer(prefix); + for (final finding in findings) { + // Try to fit as many findings as we can into the character limit imposed + // by google analytics. + final pendingString = '${findingsBuffer.isNotEmpty ? ',' : ''}$finding'; + if (findingsBuffer.length + pendingString.length <= maxLength) { + findingsBuffer.write(pendingString); + } + } + return findingsBuffer.toString(); } static final RegExp _kLegacyImportErrorPattern = RegExp( @@ -597,7 +1104,7 @@ class Dart2WasmTarget extends Dart2WebTarget { _kLegacyImportErrorPattern.hasMatch(stderr)) { environment.logger.printStatus( 'Note: WebAssembly compilation failed due to legacy web imports.\n' - 'Migrate your project from dart:html and package:js to package:web and dart:js_interop.\n' + '$_kLegacyWebDeprecationWarning\n' '$kWasmErrorsMoreInfo', ); } @@ -622,6 +1129,7 @@ class WebReleaseBundle extends Target { WebReleaseBundle._({required this.compileTargets}) : templatedFilesTarget = WebTemplatedFiles( compileTargets.map((Dart2WebTarget target) => target.buildConfig).toList(), + compileTargets: compileTargets, ); final List compileTargets; @@ -640,32 +1148,66 @@ class WebReleaseBundle extends Target { Iterable get buildPatternStems => compileTargets.expand((Dart2WebTarget target) => target.buildPatternStems); + bool get _hasWebContentHash => + compileTargets.any((Dart2WebTarget t) => t.compilerConfig.webContentHash); + @override List get inputs => [ const Source.pattern('{PROJECT_DIR}/pubspec.yaml'), const Source.pattern('{BUILD_DIR}/${LinkHooks.resultFilename}'), ...buildPatternStems.map((String file) => Source.pattern('{BUILD_DIR}/$file')), + if (_hasWebContentHash) ...[ + const Source.pattern('{OUTPUT_DIR}/index.html', optional: true), + const Source.pattern('{OUTPUT_DIR}/flutter_bootstrap.js', optional: true), + ], ]; @override List get outputs => [ ...buildPatternStems.map((String file) => Source.pattern('{OUTPUT_DIR}/$file')), + if (_hasWebContentHash) ...[ + const Source.pattern('{OUTPUT_DIR}/index.html'), + const Source.pattern('{OUTPUT_DIR}/flutter_bootstrap.js'), + ], ]; @override List get depfiles => const ['flutter_assets.d', 'web_resources.d']; + /// Matches the compiled entrypoint files (hashed or not) and deferred part + /// files that this bundle copies into the output directory. + static final RegExp _entrypointFileRegex = RegExp( + r'^main\.dart((\.js_[0-9]+.*\.part\.js)|((_module[0-9]+)?(\.[a-f0-9]+)?\.(js|wasm|mjs)))(\.map)?$', + ); + @override Future build(Environment environment) async { final FileSystem fileSystem = environment.fileSystem; - for (final Dart2WebTarget target in compileTargets) { - for (final File outputFile in target.buildFiles(environment)) { - outputFile.copySync( - environment.outputDir.childFile(fileSystem.path.basename(outputFile.path)).path, - ); + final compiledFiles = [ + for (final Dart2WebTarget target in compileTargets) ...target.buildFiles(environment), + ]; + + // Entrypoint filenames change when compiling with content hashes or when + // toggling between build modes, and glob-based [outputs] patterns match + // previous builds' files too. Delete outdated entrypoints before copying + // the current ones so that build/web does not accumulate dead files or + // pollute the service worker cache. + final currentBasenames = {for (final File file in compiledFiles) file.basename}; + if (environment.outputDir.existsSync()) { + for (final File file in environment.outputDir.listSync().whereType()) { + if (_entrypointFileRegex.hasMatch(file.basename) && + !currentBasenames.contains(file.basename)) { + file.deleteSync(); + } } } + for (final outputFile in compiledFiles) { + outputFile.copySync( + environment.outputDir.childFile(fileSystem.path.basename(outputFile.path)).path, + ); + } + final String? buildModeEnvironment = environment.defines[kBuildMode]; if (buildModeEnvironment == null) { throw MissingDefineException(kBuildMode, name); @@ -674,6 +1216,9 @@ class WebReleaseBundle extends Target { createVersionFile(environment, environment.defines); final Directory outputDirectory = environment.outputDir.childDirectory('assets'); + if (outputDirectory.existsSync()) { + outputDirectory.deleteSync(recursive: true); + } outputDirectory.createSync(recursive: true); final DartHooksResult dartHookResult = await LinkHooks.loadHookResult(environment); @@ -684,8 +1229,30 @@ class WebReleaseBundle extends Target { targetPlatform: TargetPlatform.web_javascript, buildMode: buildMode, ); + final Depfile bundledDepfile = _bundleLocalRobotoFallback(environment, depfile); final DepfileService depfileService = environment.depFileService; - depfileService.writeToFile(depfile, environment.buildDir.childFile('flutter_assets.d')); + + final bool webContentHash = compileTargets.any( + (Dart2WebTarget t) => t.compilerConfig.webContentHash, + ); + if (webContentHash) { + final WebAssetHashResult hashResult = hashWebAssets(outputDirectory); + final Map renamedOutputs = hashResult.renamedFiles; + final List updatedOutputs = bundledDepfile.outputs.map((File f) { + final String normalizedPath = environment.fileSystem.path.normalize(f.path); + return renamedOutputs[normalizedPath] ?? renamedOutputs[f.path] ?? f; + }).toList(); + depfileService.writeToFile( + Depfile(bundledDepfile.inputs, updatedOutputs), + environment.buildDir.childFile('flutter_assets.d'), + ); + injectManifestBuildConfig(environment.outputDir, hashResult); + } else { + depfileService.writeToFile( + bundledDepfile, + environment.buildDir.childFile('flutter_assets.d'), + ); + } final Directory webResources = environment.projectDir.childDirectory('web'); final List inputResourceFiles = webResources @@ -717,7 +1284,7 @@ class WebReleaseBundle extends Target { /// Create version.json file that contains data about version for package_info void createVersionFile(Environment environment, Map defines) { final versionInfo = - jsonDecode(FlutterProject.current().getVersionInfo()) as Map; + jsonDecode(FlutterProject.current().getVersionInfo()) as Map; if (defines.containsKey(kBuildNumber)) { versionInfo['build_number'] = defines[kBuildNumber]; @@ -730,18 +1297,73 @@ class WebReleaseBundle extends Target { environment.outputDir.childFile('version.json').writeAsStringSync(jsonEncode(versionInfo)); } - // flutter_zero never bundles the Roboto fallback font (headless engine, no - // txt/third_party/fonts tree), so the upstream `_bundleLocalRobotoFallback` - // hook is removed entirely. + Depfile _bundleLocalRobotoFallback(Environment environment, Depfile depfile) { + if (environment.defines[kUseLocalCanvasKitFlag] != 'true') { + return depfile; + } + + final File fontManifestFile = environment.outputDir + .childDirectory('assets') + .childFile(_kFontManifestJsonFile); + final manifestJson = fontManifestFile.existsSync() + ? (jsonDecode(fontManifestFile.readAsStringSync()) as List) + : []; + + final bool hasRobotoFamily = manifestJson.any((Object? entry) { + return entry is Map && entry['family'] == _kBundledFallbackRobotoFamily; + }); + if (hasRobotoFamily) { + return depfile; + } + + final File sourceRobotoFont = environment.fileSystem.file( + environment.fileSystem.path.join( + Cache.flutterRoot!, + 'engine', + 'src', + 'flutter', + 'txt', + 'third_party', + 'fonts', + 'Roboto-Regular.ttf', + ), + ); + if (!sourceRobotoFont.existsSync()) { + throwToolExit('Failed to find the bundled Roboto font at ${sourceRobotoFont.path}.'); + } + + manifestJson.add({ + 'family': _kBundledFallbackRobotoFamily, + 'fonts': >[ + {'asset': _kBundledFallbackRobotoAsset}, + ], + }); + fontManifestFile.parent.createSync(recursive: true); + fontManifestFile.writeAsStringSync(jsonEncode(manifestJson)); + + final File bundledRobotoFont = environment.outputDir + .childDirectory('assets') + .childFile(_kBundledFallbackRobotoAsset); + bundledRobotoFont.parent.createSync(recursive: true); + sourceRobotoFont.copySync(bundledRobotoFont.path); + + return Depfile( + [...depfile.inputs, sourceRobotoFont], + [...depfile.outputs, fontManifestFile, bundledRobotoFont], + ); + } } class WebTemplatedFiles extends Target { - WebTemplatedFiles(this.buildDescriptions); + WebTemplatedFiles(this.buildDescriptions, {this.compileTargets}); final List> buildDescriptions; + final List? compileTargets; @override - String get buildKey => jsonEncode(buildDescriptions); + String get buildKey => compileTargets != null + ? jsonEncode(compileTargets!.map((Dart2WebTarget target) => target.buildKey).toList()) + : jsonEncode(buildDescriptions); void _emitWebTemplateWarning( Environment environment, @@ -753,7 +1375,29 @@ class WebTemplatedFiles extends Target { ); } - String buildConfigString(Environment environment) { + void _scanDirectoryForWasmHashes( + Directory directory, + Map wasmHashes, { + bool skipCanvasKit = false, + }) { + if (!directory.existsSync()) { + return; + } + for (final File file in directory.listSync(recursive: true).whereType()) { + if (directory.fileSystem.path.extension(file.path) != '.wasm') { + continue; + } + final List segments = directory.fileSystem.path.split( + directory.fileSystem.path.relative(file.path, from: directory.path), + ); + if (skipCanvasKit && segments.first == 'canvaskit') { + continue; + } + wasmHashes[segments.join('/')] = crypto.sha256.convert(file.readAsBytesSync()).toString(); + } + } + + Map _computeWasmHashes(Environment environment) { // Calculate SHA-256 hashes for WASM assets to support Cross-Origin Storage // (https://wicg.github.io/cross-origin-storage/). This assumes that the files will exist in // the output directory at this point. @@ -764,45 +1408,60 @@ class WebTemplatedFiles extends Target { final Directory canvasKitDirectory = globals.fs.directory( globals.fs.path.join(canvasKitPath, 'canvaskit'), ); - if (canvasKitDirectory.existsSync()) { - for (final File file in canvasKitDirectory.listSync(recursive: true).whereType()) { - if (file.path.endsWith('.wasm')) { - final String relativePath = globals.fs.path - .relative(file.path, from: canvasKitDirectory.path) - .replaceAll(r'\', '/'); - wasmHashes[relativePath] = crypto.sha256.convert(file.readAsBytesSync()).toString(); - } - } - } - - final Directory outputDirectory = environment.outputDir; - for (final File file in outputDirectory.listSync(recursive: true).whereType()) { - if (file.path.endsWith('.wasm')) { - final String relativePath = globals.fs.path - .relative(file.path, from: outputDirectory.path) - .replaceAll(r'\', '/'); - // Skip files under the canvaskit/ subdirectory — they are already - // covered by the canvasKit SDK directory scan above with keys that - // match what the JS lookup code actually uses. - if (relativePath.startsWith('canvaskit/')) { - continue; + _scanDirectoryForWasmHashes(canvasKitDirectory, wasmHashes); + + if (compileTargets != null) { + for (final Dart2WebTarget target in compileTargets!) { + for (final File file in target.buildFiles(environment)) { + // CanvasKit WASM assets (canvaskit.wasm, skwasm.wasm) are already + // scanned and hashed separately from the SDK host artifacts above. + // Scope the exclusion check to the relative build directory path so + // parent directory names (e.g. /work/canvaskit/app) do not falsely match. + final String relativePath = environment.fileSystem.path.relative( + file.path, + from: environment.buildDir.path, + ); + if (environment.fileSystem.path.extension(file.path) == '.wasm' && + !environment.fileSystem.path.split(relativePath).contains('canvaskit') && + file.existsSync()) { + wasmHashes[file.basename] = crypto.sha256.convert(file.readAsBytesSync()).toString(); + } } - wasmHashes[relativePath] = crypto.sha256.convert(file.readAsBytesSync()).toString(); } + } else { + _scanDirectoryForWasmHashes(environment.outputDir, wasmHashes, skipCanvasKit: true); } + return wasmHashes; + } + String buildConfigString(Environment environment) { + final List> descriptions = compileTargets != null + ? compileTargets! + .map((Dart2WebTarget target) => target.getBuildConfig(environment)) + .toList() + : buildDescriptions; final buildConfig = { 'engineRevision': globals.flutterVersion.engineRevision, - 'wasmHashes': wasmHashes, - 'builds': buildDescriptions, + 'wasmHashes': _computeWasmHashes(environment), + 'builds': descriptions, if (environment.defines[kUseLocalCanvasKitFlag] == 'true') 'useLocalCanvasKit': true, }; + final bool hasWasmBuild = descriptions.any( + (Map description) => description['compileTarget'] == 'dart2wasm', + ); + final File supportJsFile = environment.buildDir.childFile('main.dart.support.js'); + final String? supportJs = hasWasmBuild && supportJsFile.existsSync() + ? supportJsFile.readAsStringSync().trim() + : null; + final supportsDart2WasmLine = (supportJs != null && supportJs.isNotEmpty) + ? '_flutter.supportsDart2Wasm = $supportJs;\n' + : ''; return ''' if (!window._flutter) { window._flutter = {}; } _flutter.buildConfig = ${jsonEncode(buildConfig)}; -'''; +$supportsDart2WasmLine'''; } @override @@ -890,13 +1549,22 @@ _flutter.buildConfig = ${jsonEncode(buildConfig)}; } @override - List get dependencies => []; + List get dependencies => + compileTargets != null ? [...compileTargets!] : []; @override - List get inputs => const [ - Source.pattern('{PROJECT_DIR}/web/*/index.html'), - Source.pattern('{PROJECT_DIR}/web/flutter_bootstrap.js'), - Source.hostArtifact(HostArtifact.flutterWebSdk), + List get inputs => [ + const Source.pattern('{PROJECT_DIR}/web/*/index.html'), + const Source.pattern('{PROJECT_DIR}/web/flutter_bootstrap.js'), + const Source.hostArtifact(HostArtifact.flutterWebSdk), + if (compileTargets?.any( + (Dart2WebTarget target) => target is Dart2WasmTarget && !target.compilerConfig.dryRun, + ) ?? + false) + const Source.pattern('{BUILD_DIR}/main.dart.support.js', optional: true), + if (compileTargets != null) + for (final Dart2WebTarget target in compileTargets!) + for (final String stem in target.buildPatternStems) Source.pattern('{BUILD_DIR}/$stem'), ]; @override @@ -931,20 +1599,48 @@ class WebBuiltInAssets extends Target { @override List get inputs => const [Source.hostArtifact(HostArtifact.flutterWebSdk)]; + Directory get _canvasKitDirectory => globals.fs.directory( + fileSystem.path.join( + globals.artifacts!.getHostArtifact(HostArtifact.flutterWebSdk).path, + 'canvaskit', + ), + ); + + List get _canvasKitFiles => + _canvasKitDirectory.listSync(recursive: true).whereType().toList(); + + String _filePathRelativeToCanvasKitDirectory(File file) => + fileSystem.path.relative(file.path, from: _canvasKitDirectory.path); + @override - List get outputs => [const Source.pattern('{BUILD_DIR}/flutter.js')]; + List get outputs => [ + const Source.pattern('{BUILD_DIR}/flutter.js'), + const Source.pattern('{BUILD_DIR}/flutter.js.map'), + for (final File file in _canvasKitFiles) + Source.pattern('{BUILD_DIR}/canvaskit/${_filePathRelativeToCanvasKitDirectory(file)}'), + ]; @override Future build(Environment environment) async { - // Write the flutter.js file - final String flutterJsOut = fileSystem.path.join(environment.outputDir.path, 'flutter.js'); - final File flutterJsFile = fileSystem.file( - fileSystem.path.join( - globals.artifacts!.getHostArtifact(HostArtifact.flutterJsDirectory).path, - 'flutter.js', - ), + for (final File file in _canvasKitFiles) { + final String relativePath = _filePathRelativeToCanvasKitDirectory(file); + final String targetPath = fileSystem.path.join( + environment.outputDir.path, + 'canvaskit', + relativePath, + ); + file.copySync(targetPath); + } + + // Write the Flutter loader and its source map. + final Directory flutterJsDirectory = fileSystem.directory( + globals.artifacts!.getHostArtifact(HostArtifact.flutterJsDirectory).path, ); - flutterJsFile.copySync(flutterJsOut); + for (final fileName in ['flutter.js', 'flutter.js.map']) { + final File sourceFile = flutterJsDirectory.childFile(fileName); + final File targetFile = environment.outputDir.childFile(fileName); + sourceFile.copySync(targetFile.path); + } } } @@ -982,12 +1678,21 @@ class WebServiceWorker extends Target { .where( (File file) => !file.path.endsWith('flutter_service_worker.js') && + !file.path.endsWith(kPrecacheManifestFile) && !environment.fileSystem.path.basename(file.path).startsWith('.'), ) .toList(); + final bool webContentHash = compileConfigs.any( + (WebCompilerConfig config) => config.webContentHash, + ); + final File? precacheManifestFile = updatePrecacheManifest( + environment.outputDir, + enabled: webContentHash, + useLocalCanvasKit: environment.defines[kUseLocalCanvasKitFlag] == 'true', + ); final File serviceWorkerFile = environment.outputDir.childFile('flutter_service_worker.js'); - final depfile = Depfile(contents, [serviceWorkerFile]); + final depfile = Depfile(contents, [serviceWorkerFile, ?precacheManifestFile]); final String fileGeneratorsPath = environment.artifacts.getArtifactPath( Artifact.flutterToolsFileGenerators, ); diff --git a/packages/flutter_tools/lib/src/build_system/targets/windows.dart b/packages/flutter_tools/lib/src/build_system/targets/windows.dart index ce23d1f8..789606ef 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/windows.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/windows.dart @@ -25,6 +25,7 @@ const _kWindowsArtifacts = [ 'flutter_export.h', 'flutter_messenger.h', 'flutter_plugin_registrar.h', + 'flutter_texture_registrar.h', 'flutter_windows.h', ]; @@ -169,8 +170,8 @@ class WindowsAotBundle extends Target { /// Create a [WindowsAotBundle] wrapper for [aotTarget]. const WindowsAotBundle(this.aotTarget); - /// The [AotElfBase] subclass that produces the app.so. - final AotElfBase aotTarget; + /// The [AotSnapshotBase] subclass that produces the Windows AOT library. + final AotSnapshotBase aotTarget; @override String get name => 'windows_aot_bundle'; @@ -207,7 +208,7 @@ class ReleaseBundleWindowsAssets extends BundleWindowsAssets { @override List get dependencies => [ ...super.dependencies, - WindowsAotBundle(AotElfRelease(targetPlatform)), + WindowsAotBundle(AotSnapshotRelease(targetPlatform)), ]; } @@ -223,7 +224,7 @@ class ProfileBundleWindowsAssets extends BundleWindowsAssets { @override List get dependencies => [ ...super.dependencies, - WindowsAotBundle(AotElfProfile(targetPlatform)), + WindowsAotBundle(AotSnapshotProfile(targetPlatform)), ]; } diff --git a/packages/flutter_tools/lib/src/build_system/tools/asset_transformer.dart b/packages/flutter_tools/lib/src/build_system/tools/asset_transformer.dart index eb2d63f2..2d9476cb 100644 --- a/packages/flutter_tools/lib/src/build_system/tools/asset_transformer.dart +++ b/packages/flutter_tools/lib/src/build_system/tools/asset_transformer.dart @@ -20,14 +20,11 @@ import '../depfile.dart'; /// Applies a series of user-specified asset-transforming packages to an asset file. final class AssetTransformer { AssetTransformer({ - required ProcessManager processManager, - required FileSystem fileSystem, - required String dartBinaryPath, - required BuildMode buildMode, - }) : _processManager = processManager, - _fileSystem = fileSystem, - _dartBinaryPath = dartBinaryPath, - _buildMode = buildMode; + required this._processManager, + required this._fileSystem, + required this._dartBinaryPath, + required this._buildMode, + }); static const buildModeEnvVar = 'FLUTTER_BUILD_MODE'; @@ -184,12 +181,10 @@ final class AssetTransformer { // A wrapper around [AssetTransformer] to support hot reload of transformed assets. final class DevelopmentAssetTransformer { DevelopmentAssetTransformer({ - required FileSystem fileSystem, - required AssetTransformer transformer, - required Logger logger, - }) : _fileSystem = fileSystem, - _transformer = transformer, - _logger = logger; + required this._fileSystem, + required this._transformer, + required this._logger, + }); final AssetTransformer _transformer; final FileSystem _fileSystem; diff --git a/packages/flutter_tools/lib/src/build_system/tools/shader_compiler.dart b/packages/flutter_tools/lib/src/build_system/tools/shader_compiler.dart index 1fa5cc91..41e49c11 100644 --- a/packages/flutter_tools/lib/src/build_system/tools/shader_compiler.dart +++ b/packages/flutter_tools/lib/src/build_system/tools/shader_compiler.dart @@ -19,19 +19,17 @@ import '../../base/platform.dart'; import '../../build_info.dart'; import '../../convert.dart'; import '../../devfs.dart'; -import '../../globals.dart' as globals; import '../build_system.dart'; import '../depfile.dart'; /// A wrapper around [ShaderCompiler] to support hot reload of shader sources. class DevelopmentShaderCompiler { DevelopmentShaderCompiler({ - required ShaderCompiler shaderCompiler, + required this._shaderCompiler, required FileSystem fileSystem, required Logger logger, @visibleForTesting math.Random? random, - }) : _shaderCompiler = shaderCompiler, - _fileSystem = fileSystem, + }) : _fileSystem = fileSystem, _logger = logger, _depfileService = DepfileService(fileSystem: fileSystem, logger: logger), _random = random ?? math.Random(); @@ -148,24 +146,12 @@ class DevelopmentShaderCompiler { /// impellerc. class ShaderCompiler { ShaderCompiler({ - required ProcessManager processManager, - required Logger logger, + required this._artifacts, required FileSystem fileSystem, - required Artifacts artifacts, - Platform? platform, - }) : _processManager = processManager, - _logger = logger, - _fs = fileSystem, - _artifacts = artifacts, - _platform = platform ?? _lookupPlatform(); - - static Platform _lookupPlatform() { - try { - return globals.platform; - } on UnsupportedError { - return const LocalPlatform(); - } - } + required this._logger, + required this._platform, + required this._processManager, + }) : _fs = fileSystem; final ProcessManager _processManager; final Logger _logger; @@ -173,18 +159,11 @@ class ShaderCompiler { final Artifacts _artifacts; final Platform _platform; bool _hasLoggedSecurityBlockError = false; + final Set _loggedWarningShaders = {}; List _shaderTargetsFromTargetPlatform(TargetPlatform targetPlatform) { - switch (targetPlatform) { - case TargetPlatform.android_x64: - case TargetPlatform.android_arm: - case TargetPlatform.android_arm64: - case TargetPlatform.android: - case TargetPlatform.linux_x64: - case TargetPlatform.linux_arm64: - case TargetPlatform.linux_riscv64: - case TargetPlatform.windows_x64: - case TargetPlatform.windows_arm64: + switch (targetPlatform.os) { + case .android || .linux || .windows: return [ '--sksl', '--runtime-stage-gles', @@ -192,20 +171,18 @@ class ShaderCompiler { '--runtime-stage-vulkan', ]; - case TargetPlatform.ios: + case .ios: return ['--runtime-stage-metal']; - case TargetPlatform.darwin: + case .macos: return ['--sksl', '--runtime-stage-metal']; - case TargetPlatform.fuchsia_arm64: - case TargetPlatform.fuchsia_x64: - case TargetPlatform.tester: + case .fuchsia || .tester: return ['--sksl', '--runtime-stage-vulkan']; - case TargetPlatform.web_javascript: + case .web: return ['--sksl']; - case TargetPlatform.unsupported: + case .unsupported: TargetPlatform.throwUnsupportedTarget(); } } @@ -316,6 +293,12 @@ class ShaderCompiler { } return false; } + final String? stderr = (result.stderr as String?)?.trim(); + if (stderr != null && stderr.isNotEmpty) { + if (_loggedWarningShaders.add(input.path)) { + _logger.printBox(stderr, title: 'Shader Warning'); + } + } } on _SecurityPolicyBlockException catch (_) { _logSecurityBlockError(impellerc.path); if (fatal) { diff --git a/packages/flutter_tools/lib/src/bundle.dart b/packages/flutter_tools/lib/src/bundle.dart index 36b0b54f..12dc082a 100644 --- a/packages/flutter_tools/lib/src/bundle.dart +++ b/packages/flutter_tools/lib/src/bundle.dart @@ -16,13 +16,6 @@ String get defaultMainPath => globals.fs.path.join('lib', 'main.dart'); const defaultManifestPath = 'pubspec.yaml'; String get defaultDepfilePath => globals.fs.path.join(getBuildDirectory(), 'snapshot_blob.bin.d'); -String getDefaultApplicationKernelPath({required bool trackWidgetCreation}) { - return getKernelPathForTransformerOptions( - globals.fs.path.join(getBuildDirectory(), 'app.dill'), - trackWidgetCreation: trackWidgetCreation, - ); -} - String getDefaultCachedKernelPath({ required bool trackWidgetCreation, required List dartDefines, diff --git a/packages/flutter_tools/lib/src/bundle_builder.dart b/packages/flutter_tools/lib/src/bundle_builder.dart index ab8d4c33..f5a717bc 100644 --- a/packages/flutter_tools/lib/src/bundle_builder.dart +++ b/packages/flutter_tools/lib/src/bundle_builder.dart @@ -2,7 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:meta/meta.dart'; import 'package:pool/pool.dart'; import 'package:process/process.dart'; @@ -11,6 +10,7 @@ import 'asset.dart' hide defaultManifestPath; import 'base/common.dart'; import 'base/file_system.dart'; import 'base/logger.dart'; +import 'base/platform.dart'; import 'build_info.dart'; import 'build_system/build_system.dart'; import 'build_system/depfile.dart'; @@ -38,7 +38,7 @@ class BundleBuilder { String? applicationKernelFilePath, String? depfilePath, String? assetDirPath, - @visibleForTesting BuildSystem? buildSystem, + BuildSystem? buildSystem, }) async { project ??= FlutterProject.current(); mainPath ??= defaultMainPath; @@ -141,6 +141,7 @@ Future writeBundle( required Logger logger, required Directory projectDir, required BuildMode buildMode, + required Platform platform, }) async { if (bundleDir.existsSync()) { try { @@ -159,6 +160,7 @@ Future writeBundle( logger: logger, fileSystem: fileSystem, artifacts: artifacts, + platform: platform, ); final assetTransformer = AssetTransformer( diff --git a/packages/flutter_tools/lib/src/cache.dart b/packages/flutter_tools/lib/src/cache.dart index 211b1760..c1ec3b98 100644 --- a/packages/flutter_tools/lib/src/cache.dart +++ b/packages/flutter_tools/lib/src/cache.dart @@ -149,25 +149,25 @@ class DevelopmentArtifact { /// For more details on specific URLs used to download artifacts, see /// [storageBaseUrl] and [cipdBaseUrl]. class Cache { - /// [rootOverride] is configurable for testing. + /// [_rootOverride] is configurable for testing. /// [artifacts] is configurable for testing. + /// [artifactUpdater] is configurable for testing. Cache({ - @protected Directory? rootOverride, + @protected this._rootOverride, @protected List? artifacts, + @visibleForTesting ArtifactUpdater? artifactUpdater, required Logger logger, required FileSystem fileSystem, required Platform platform, - required OperatingSystemUtils osUtils, - Stdio? stdio, - }) : _rootOverride = rootOverride, - _logger = logger, + required this._osUtils, + this._stdio, + }) : _logger = logger, _fileSystem = fileSystem, _platform = platform, - _osUtils = osUtils, - _stdio = stdio, _net = Net(logger: logger, platform: platform), _fsUtils = FileSystemUtils(fileSystem: fileSystem, platform: platform), - _artifacts = artifacts ?? []; + _artifacts = artifacts ?? [], + _artifactUpdaterOverride = artifactUpdater; /// Create a [Cache] for testing. /// @@ -177,6 +177,7 @@ class Cache { factory Cache.test({ Directory? rootOverride, List? artifacts, + ArtifactUpdater? artifactUpdater, Logger? logger, FileSystem? fileSystem, Platform? platform, @@ -199,6 +200,7 @@ class Cache { return Cache( rootOverride: rootOverride ?? fileSystem.currentDirectory, artifacts: artifacts ?? [], + artifactUpdater: artifactUpdater, logger: logger, fileSystem: fileSystem, platform: platform, @@ -224,7 +226,8 @@ class Cache { final Net _net; final FileSystemUtils _fsUtils; - late final ArtifactUpdater _artifactUpdater = _createUpdater(); + final ArtifactUpdater? _artifactUpdaterOverride; + late final ArtifactUpdater _artifactUpdater = _artifactUpdaterOverride ?? _createUpdater(); @visibleForTesting @protected @@ -241,12 +244,7 @@ class Cache { tempStorage: getDownloadDir(), platform: _platform, httpClient: HttpClient(), - allowedBaseUrls: [ - storageBaseUrl, - realmlessStorageBaseUrl, - cipdBaseUrl, - originalStorageUrl, - ], + allowedBaseUrls: [storageBaseUrl, realmlessStorageBaseUrl, cipdBaseUrl], stdio: _stdio, ); } @@ -300,9 +298,8 @@ class Cache { final String Function(String) dirname = fileSystem.path.dirname; if (platform.script.scheme == 'package') { - final String packageConfigPath = Uri.parse( - platform.packageConfig!, - ).toFilePath(windows: platform.isWindows); + final String packageConfigPath = Uri.parse(platform.packageConfig!) + .toFilePath(windows: platform.isWindows); return normalize(dirname(dirname(dirname(dirname(packageConfigPath))))); } @@ -563,8 +560,6 @@ class Cache { return overrideUrl; } - String get originalStorageUrl => 'https://storage.googleapis.com'; - String get realmlessStorageBaseUrl { return storageRealm.isEmpty ? storageBaseUrl : storageBaseUrl.replaceAll('/$storageRealm', ''); } @@ -805,38 +800,45 @@ class Cache { } // Download artifacts and display progress - final int total = artifactsToUpdate.length; - for (var i = 0; i < artifactsToUpdate.length; i++) { - final ArtifactSet artifact = artifactsToUpdate[i]; - final int current = i + 1; - - // Set progress context for the artifact updater - _artifactUpdater.setProgressContext( - artifactIndex: current, - artifactTotal: total, - downloadTotal: artifact.downloadCount, - ); + final int total = artifactsToUpdate + .where((ArtifactSet artifact) => artifact.downloadCount > 0) + .length; + var current = 0; + try { + for (final artifact in artifactsToUpdate) { + if (artifact.downloadCount > 0) { + current += 1; + + // Set progress context for the artifact updater + _artifactUpdater.setProgressContext( + artifactIndex: current, + artifactTotal: total, + downloadTotal: artifact.downloadCount, + ); - // For artifacts containing multiple downloads, print the artifact name - if (artifact.downloadCount > 1) { - _logger.printStatus('[$current/$total] ${artifact.displayName}'); - } + // For artifacts containing multiple downloads, print the artifact name + if (artifact.downloadCount > 1) { + _logger.printStatus('[$current/$total] ${artifact.displayName}'); + } + } - try { - await artifact.update(_artifactUpdater, _logger, _fileSystem, _osUtils, offline: offline); - } on SocketException catch (e) { - if (_hostsBlockedInChina.contains(e.address?.host)) { - _logger.printError( - 'Failed to retrieve Flutter tool dependencies: ${e.message}.\n' - "If you're in China, please see this page: " - 'https://flutter.dev/to/china-setup', - emphasis: true, - ); + try { + await artifact.update(_artifactUpdater, _logger, _fileSystem, _osUtils, offline: offline); + } on SocketException catch (e) { + if (_hostsBlockedInChina.contains(e.address?.host)) { + _logger.printError( + 'Failed to retrieve Flutter tool dependencies: ${e.message}.\n' + "If you're in China, please see this page: " + 'https://flutter.dev/to/china-setup', + emphasis: true, + ); + } + rethrow; } - rethrow; } + } finally { + _artifactUpdater.resetProgressContext(); } - _artifactUpdater.resetProgressContext(); } Future areRemoteArtifactsAvailable({ @@ -906,8 +908,8 @@ abstract class ArtifactSet { /// The number of individual downloads this artifact will perform. /// - /// Defaults to 1. - int get downloadCount => 1; + /// Defaults to 0. + int get downloadCount => 0; } /// An artifact set managed by the cache. @@ -920,6 +922,12 @@ abstract class CachedArtifact extends ArtifactSet { @override final String name; + /// The number of individual downloads this artifact will perform. + /// + /// Defaults to 1 for cached artifacts. + @override + int get downloadCount => 1; + @override String get stampName => name; @@ -964,17 +972,18 @@ abstract class CachedArtifact extends ArtifactSet { ); } } + final String? version = this.version; + if (version == null) { + logger.printWarning( + 'No known version for the artifact name "$name". ' + 'Flutter can continue, but the artifact may be re-downloaded on ' + 'subsequent invocations until the problem is resolved.', + ); + return; + } await updateInner(artifactUpdater, fileSystem, operatingSystemUtils); try { - if (version == null) { - logger.printWarning( - 'No known version for the artifact name "$name". ' - 'Flutter can continue, but the artifact may be re-downloaded on ' - 'subsequent invocations until the problem is resolved.', - ); - } else { - cache.setStampFor(stampName, version!); - } + cache.setStampFor(stampName, version); } on FileSystemException catch (err) { logger.printWarning( 'The new artifact "$name" was downloaded, but Flutter failed to update ' @@ -1125,22 +1134,15 @@ abstract class EngineCachedArtifact extends CachedArtifact { /// additional source code. class ArtifactUpdater { ArtifactUpdater({ - required OperatingSystemUtils operatingSystemUtils, - required Logger logger, - required FileSystem fileSystem, - required Directory tempStorage, - required HttpClient httpClient, - required Platform platform, - required List allowedBaseUrls, - Stdio? stdio, - }) : _operatingSystemUtils = operatingSystemUtils, - _httpClient = httpClient, - _logger = logger, - _fileSystem = fileSystem, - _tempStorage = tempStorage, - _platform = platform, - _allowedBaseUrls = allowedBaseUrls, - _stdio = stdio; + required this._operatingSystemUtils, + required this._logger, + required this._fileSystem, + required this._tempStorage, + required this._httpClient, + required this._platform, + required this._allowedBaseUrls, + this._stdio, + }); /// The number of times the artifact updater will repeat the artifact download loop. static const _kRetryCount = 2; @@ -1556,7 +1558,7 @@ abstract class _DownloadDisplay { /// Displays an ANSI progress bar with speed, ETA, and percentage. class _ProgressBarDisplay extends _DownloadDisplay { - _ProgressBarDisplay({required Stdio stdio, required this.statusMessage}) : _stdio = stdio; + _ProgressBarDisplay({required this._stdio, required this.statusMessage}); static const int _maxTerminalWidth = 80; static const int _progressUpdateIntervalMs = 100; @@ -1625,9 +1627,7 @@ class _ProgressBarDisplay extends _DownloadDisplay { /// Displays a spinner via [Logger.startProgress]. class _SpinnerDisplay extends _DownloadDisplay { - _SpinnerDisplay({required Logger logger, required String statusMessage}) - : _logger = logger, - _statusMessage = statusMessage; + _SpinnerDisplay({required this._logger, required this._statusMessage}); final Logger _logger; final String _statusMessage; diff --git a/packages/flutter_tools/lib/src/commands/analyze.dart b/packages/flutter_tools/lib/src/commands/analyze.dart index 27c9b528..8454b257 100644 --- a/packages/flutter_tools/lib/src/commands/analyze.dart +++ b/packages/flutter_tools/lib/src/commands/analyze.dart @@ -11,6 +11,7 @@ import '../base/file_system.dart'; import '../base/logger.dart'; import '../base/platform.dart'; import '../base/terminal.dart'; +import '../context/tool_context.dart'; import '../project.dart'; import '../project_validator.dart'; import '../runner/flutter_command.dart'; @@ -23,24 +24,12 @@ import 'validate_project.dart'; class AnalyzeCommand extends FlutterCommand { AnalyzeCommand({ + required this._allProjectValidators, + required this._suppressAnalytics, + required super.toolContext, bool verboseHelp = false, this.workingDirectory, - required FileSystem fileSystem, - required Platform platform, - required Terminal terminal, - required Logger logger, - required ProcessManager processManager, - required Artifacts artifacts, - required List allProjectValidators, - required bool suppressAnalytics, - }) : _artifacts = artifacts, - _fileSystem = fileSystem, - _processManager = processManager, - _logger = logger, - _terminal = terminal, - _allProjectValidators = allProjectValidators, - _platform = platform, - _suppressAnalytics = suppressAnalytics { + }) { argParser.addFlag( 'flutter-repo', negatable: false, @@ -96,6 +85,7 @@ class AnalyzeCommand extends FlutterCommand { hide: !verboseHelp, help: 'Also output the analysis time.', ); + argParser.addFlag('plugins', defaultsTo: true, help: 'Whether to enable analyzer plugins.'); usesPubOption(); @@ -204,15 +194,12 @@ class AnalyzeCommand extends FlutterCommand { /// The working directory for testing analysis using dartanalyzer. final Directory? workingDirectory; - final Artifacts _artifacts; - final FileSystem _fileSystem; - final Logger _logger; - final Terminal _terminal; - final ProcessManager _processManager; - final Platform _platform; final List _allProjectValidators; final bool _suppressAnalytics; + @override + ToolContext get toolContext => super.toolContext!; + @override String get name => 'analyze'; @@ -233,7 +220,7 @@ class AnalyzeCommand extends FlutterCommand { } // Or we're not in a project directory. - if (!_fileSystem.file('pubspec.yaml').existsSync()) { + if (!toolContext.fs.file('pubspec.yaml').existsSync()) { return false; } @@ -253,6 +240,14 @@ class AnalyzeCommand extends FlutterCommand { @override Future runCommand() async { + final Artifacts artifacts = toolContext.artifacts; + final FileSystem fileSystem = toolContext.fs; + final Logger logger = toolContext.logger; + final Platform platform = toolContext.platform; + final ProcessManager processManager = toolContext.processManager; + final FlutterProjectFactory projectFactory = toolContext.projectFactory; + final Terminal terminal = toolContext.terminal; + if (boolArg('android')) { final AndroidAnalyzeOption option; final String? buildVariant; @@ -274,11 +269,11 @@ class AnalyzeCommand extends FlutterCommand { } else { throwToolExit('No argument is provided to analyze. Use -h to see available commands.'); } - final Set items = findDirectories(argResults!, _fileSystem); + final Set items = findDirectories(argResults!, fileSystem); final String directoryPath; if (items.isEmpty) { // user did not specify any path - directoryPath = _fileSystem.currentDirectory.path; + directoryPath = fileSystem.currentDirectory.path; } else if (items.length > 1) { // if the user sends more than one path throwToolExit('The Android analyze can process only one directory path'); @@ -286,11 +281,11 @@ class AnalyzeCommand extends FlutterCommand { directoryPath = items.first; } await AndroidAnalyze( - fileSystem: _fileSystem, + fileSystem: fileSystem, option: option, userPath: directoryPath, buildVariant: buildVariant, - logger: _logger, + logger: logger, ).analyze(); } else if (boolArg('ios')) { final IOSAnalyzeOption option; @@ -319,11 +314,11 @@ class AnalyzeCommand extends FlutterCommand { } else { throwToolExit('No argument is provided to analyze. Use -h to see available commands.'); } - final Set items = findDirectories(argResults!, _fileSystem); + final Set items = findDirectories(argResults!, fileSystem); final String directoryPath; if (items.isEmpty) { // user did not specify any path - directoryPath = _fileSystem.currentDirectory.path; + directoryPath = fileSystem.currentDirectory.path; } else if (items.length > 1) { // if the user sends more than one path throwToolExit('The iOS analyze can process only one directory path'); @@ -331,11 +326,11 @@ class AnalyzeCommand extends FlutterCommand { directoryPath = items.first; } await IOSAnalyze( - project: FlutterProject.fromDirectory(_fileSystem.directory(directoryPath)), + project: projectFactory.fromDirectory(fileSystem.directory(directoryPath)), option: option, configuration: configuration, target: target, - logger: _logger, + logger: logger, ).analyze(); } else if (boolArg('suggestions')) { final String directoryPath; @@ -343,11 +338,11 @@ class AnalyzeCommand extends FlutterCommand { throwToolExit('flag --watch is not compatible with --suggestions'); } if (workingDirectory == null) { - final Set items = findDirectories(argResults!, _fileSystem); + final Set items = findDirectories(argResults!, fileSystem); if (items.isEmpty) { // user did not specify any path - directoryPath = _fileSystem.currentDirectory.path; - _logger.printTrace('Showing suggestions for current directory: $directoryPath'); + directoryPath = fileSystem.currentDirectory.path; + logger.printTrace('Showing suggestions for current directory: $directoryPath'); } else if (items.length > 1) { // if the user sends more than one path throwToolExit('The suggestions flag can process only one directory path'); @@ -358,37 +353,39 @@ class AnalyzeCommand extends FlutterCommand { directoryPath = workingDirectory!.path; } return ValidateProject( - fileSystem: _fileSystem, - logger: _logger, allProjectValidators: _allProjectValidators, + fileSystem: fileSystem, + logger: logger, + processManager: processManager, + projectFactory: projectFactory, userPath: directoryPath, - processManager: _processManager, machine: outputMachineFormat, ).run(); } else if (boolArg('watch')) { await AnalyzeContinuously( argResults!, runner!.getRepoPackages(), - fileSystem: _fileSystem, - logger: _logger, - platform: _platform, - processManager: _processManager, - terminal: _terminal, - artifacts: _artifacts, + artifacts: artifacts, + fileSystem: fileSystem, + logger: logger, + platform: platform, + processManager: processManager, + shutdownHooks: toolContext.shutdownHooks, suppressAnalytics: _suppressAnalytics, + terminal: terminal, ).analyze(); } else { await AnalyzeOnce( argResults!, runner!.getRepoPackages(), - workingDirectory: workingDirectory, - fileSystem: _fileSystem, - logger: _logger, - platform: _platform, - processManager: _processManager, - terminal: _terminal, - artifacts: _artifacts, + artifacts: artifacts, + fileSystem: fileSystem, + logger: logger, + platform: platform, + processManager: processManager, suppressAnalytics: _suppressAnalytics, + terminal: terminal, + workingDirectory: workingDirectory, ).analyze(); } return FlutterCommandResult.success(); diff --git a/packages/flutter_tools/lib/src/commands/analyze_base.dart b/packages/flutter_tools/lib/src/commands/analyze_base.dart index 1395ef0c..e2a552f3 100644 --- a/packages/flutter_tools/lib/src/commands/analyze_base.dart +++ b/packages/flutter_tools/lib/src/commands/analyze_base.dart @@ -20,20 +20,19 @@ import '../base/platform.dart'; import '../base/terminal.dart'; import '../base/utils.dart'; import '../cache.dart'; -import '../globals.dart' as globals; /// Common behavior for `flutter analyze` and `flutter analyze --watch` abstract class AnalyzeBase { AnalyzeBase( this.argResults, { - required this.repoPackages, + required this.artifacts, required this.fileSystem, required this.logger, required this.platform, required this.processManager, - required this.terminal, - required this.artifacts, + required this.repoPackages, required this.suppressAnalytics, + required this.terminal, }); /// The parsed argument results for execution. @@ -56,7 +55,7 @@ abstract class AnalyzeBase { final bool suppressAnalytics; @protected - String get flutterRoot => globals.fs.path.absolute(Cache.flutterRoot!); + String get flutterRoot => fileSystem.path.absolute(Cache.flutterRoot!); /// Called by [AnalyzeCommand] to start the analysis process. Future analyze(); @@ -98,6 +97,14 @@ abstract class AnalyzeBase { bool get isBenchmarking => argResults['benchmark'] as bool; String? get protocolTrafficLog => argResults['protocol-traffic-log'] as String?; + @protected + bool get usePlugins { + if (argResults.options.contains('plugins') && argResults.wasParsed('plugins')) { + return argResults['plugins'] as bool; + } + return !isBenchmarking; + } + /// Generate an analysis summary for both [AnalyzeOnce], [AnalyzeContinuously]. static String generateErrorsMessage({ required int issueCount, @@ -143,13 +150,13 @@ class PackageDependency { } bool get hasConflict => values.length > 1; - bool get hasConflictAffectingFlutterRepo { + bool hasConflictAffectingFlutterRepo(FileSystem fileSystem) { final String? flutterRoot = Cache.flutterRoot; - assert(flutterRoot != null && globals.fs.path.isAbsolute(flutterRoot)); + assert(flutterRoot != null && fileSystem.path.isAbsolute(flutterRoot)); for (final List targetSources in values.values) { for (final source in targetSources) { - assert(globals.fs.path.isAbsolute(source)); - if (globals.fs.path.isWithin(flutterRoot!, source)) { + assert(fileSystem.path.isAbsolute(source)); + if (fileSystem.path.isWithin(flutterRoot!, source)) { return true; } } @@ -201,26 +208,24 @@ class PackageDependencyTracker { } void checkForConflictingDependencies( - Iterable pubSpecDirectories, - PackageDependencyTracker dependencies, - ) { + Iterable pubSpecDirectories, { + required FileSystem fileSystem, + }) { for (final directory in pubSpecDirectories) { - final String pubSpecYamlPath = globals.fs.path.join(directory.path, 'pubspec.yaml'); - final File pubSpecYamlFile = globals.fs.file(pubSpecYamlPath); + final String pubSpecYamlPath = fileSystem.path.join(directory.path, 'pubspec.yaml'); + final File pubSpecYamlFile = fileSystem.file(pubSpecYamlPath); if (pubSpecYamlFile.existsSync()) { // we are analyzing the actual canonical source for this package; // make sure we remember that, in case all the packages are actually // pointing elsewhere somehow. - final dynamic pubSpecYaml = yaml.loadYaml( - globals.fs.file(pubSpecYamlPath).readAsStringSync(), - ); + final dynamic pubSpecYaml = yaml.loadYaml(pubSpecYamlFile.readAsStringSync()); if (pubSpecYaml is yaml.YamlMap) { final dynamic packageName = pubSpecYaml['name']; if (packageName is String) { - final String packagePath = globals.fs.path.normalize( - globals.fs.path.absolute(globals.fs.path.join(directory.path, 'lib')), + final String packagePath = fileSystem.path.normalize( + fileSystem.path.absolute(fileSystem.path.join(directory.path, 'lib')), ); - dependencies.addCanonicalCase(packageName, packagePath, pubSpecYamlPath); + addCanonicalCase(packageName, packagePath, pubSpecYamlPath); } else { throwToolExit('pubspec.yaml is malformed. The name should be a String.'); } @@ -230,13 +235,13 @@ class PackageDependencyTracker { } } - if (dependencies.hasConflicts) { + if (hasConflicts) { final message = StringBuffer(); - message.writeln(dependencies.generateConflictReport()); + message.writeln(generateConflictReport()); message.writeln( 'Make sure you have run "pub upgrade" in all the directories mentioned above.', ); - if (dependencies.hasConflictsAffectingFlutterRepo) { + if (hasConflictsAffectingFlutterRepo(fileSystem)) { message.writeln( 'For packages in the flutter repository, try using "flutter update-packages" to do all of them at once.\n' 'If you need to actually upgrade them, consider "flutter update-packages --force-upgrade". ' @@ -255,9 +260,9 @@ class PackageDependencyTracker { return packages.values.any((PackageDependency dependency) => dependency.hasConflict); } - bool get hasConflictsAffectingFlutterRepo { + bool hasConflictsAffectingFlutterRepo(FileSystem fileSystem) { return packages.values.any( - (PackageDependency dependency) => dependency.hasConflictAffectingFlutterRepo, + (PackageDependency dependency) => dependency.hasConflictAffectingFlutterRepo(fileSystem), ); } diff --git a/packages/flutter_tools/lib/src/commands/analyze_continuously.dart b/packages/flutter_tools/lib/src/commands/analyze_continuously.dart index 5b535c89..7a845c8f 100644 --- a/packages/flutter_tools/lib/src/commands/analyze_continuously.dart +++ b/packages/flutter_tools/lib/src/commands/analyze_continuously.dart @@ -7,22 +7,24 @@ import '../base/file_system.dart'; import '../base/logger.dart'; import '../base/process.dart'; import '../dart/analysis.dart'; -import '../globals.dart' as globals; import 'analyze_base.dart'; class AnalyzeContinuously extends AnalyzeBase { AnalyzeContinuously( super.argResults, List repoPackages, { + required super.artifacts, required super.fileSystem, required super.logger, - required super.terminal, required super.platform, required super.processManager, - required super.artifacts, + required this.shutdownHooks, required super.suppressAnalytics, + required super.terminal, }) : super(repoPackages: repoPackages); + final ShutdownHooks shutdownHooks; + String? analysisTarget; bool firstAnalysis = true; Set analyzedPaths = {}; @@ -37,7 +39,7 @@ class AnalyzeContinuously extends AnalyzeBase { if (isFlutterRepo) { final dependencies = PackageDependencyTracker(); - dependencies.checkForConflictingDependencies(repoPackages, dependencies); + dependencies.checkForConflictingDependencies(repoPackages, fileSystem: fileSystem); directories = [flutterRoot]; analysisTarget = 'Flutter repository'; @@ -58,6 +60,7 @@ class AnalyzeContinuously extends AnalyzeBase { terminal: terminal, protocolTrafficLog: protocolTrafficLog, suppressAnalytics: suppressAnalytics, + usePlugins: usePlugins, ); server.onAnalyzing.listen((bool isAnalyzing) => _handleAnalysisStatus(server, isAnalyzing)); server.onErrors.listen(_handleAnalysisErrors); @@ -129,7 +132,7 @@ class AnalyzeContinuously extends AnalyzeBase { if (firstAnalysis && isBenchmarking) { writeBenchmark(analysisTimer, issueCount); server.dispose().whenComplete(() { - exitWithHooks(issueCount > 0 ? 1 : 0, shutdownHooks: globals.shutdownHooks); + exitWithHooks(issueCount > 0 ? 1 : 0, shutdownHooks: shutdownHooks); }); } diff --git a/packages/flutter_tools/lib/src/commands/analyze_once.dart b/packages/flutter_tools/lib/src/commands/analyze_once.dart index 940804d6..1e0eb7b7 100644 --- a/packages/flutter_tools/lib/src/commands/analyze_once.dart +++ b/packages/flutter_tools/lib/src/commands/analyze_once.dart @@ -14,13 +14,13 @@ class AnalyzeOnce extends AnalyzeBase { AnalyzeOnce( super.argResults, List repoPackages, { + required super.artifacts, required super.fileSystem, required super.logger, required super.platform, required super.processManager, - required super.terminal, - required super.artifacts, required super.suppressAnalytics, + required super.terminal, this.workingDirectory, }) : super(repoPackages: repoPackages); @@ -35,7 +35,7 @@ class AnalyzeOnce extends AnalyzeBase { if (isFlutterRepo) { // check for conflicting dependencies final dependencies = PackageDependencyTracker(); - dependencies.checkForConflictingDependencies(repoPackages, dependencies); + dependencies.checkForConflictingDependencies(repoPackages, fileSystem: fileSystem); items.add(flutterRoot); if (argResults.wasParsed('current-package') && (argResults['current-package'] as bool)) { items.add(currentDirectory); @@ -50,8 +50,7 @@ class AnalyzeOnce extends AnalyzeBase { throwToolExit('Nothing to analyze.', exitCode: 0); } - final analysisCompleter = Completer(); - final errors = []; + final errorsByFile = >{}; final server = AnalysisServer( sdkPath, @@ -63,44 +62,32 @@ class AnalyzeOnce extends AnalyzeBase { terminal: terminal, protocolTrafficLog: protocolTrafficLog, suppressAnalytics: suppressAnalytics, + withFineDependencies: false, + usePlugins: usePlugins, ); Stopwatch? timer; Status? progress; try { - StreamSubscription? subscription; - - void handleAnalysisStatus(bool isAnalyzing) { - if (!isAnalyzing) { - analysisCompleter.complete(); - subscription?.cancel(); - subscription = null; - } - } - - subscription = server.onAnalyzing.listen( - (bool isAnalyzing) => handleAnalysisStatus(isAnalyzing), - ); - void handleAnalysisErrors(FileAnalysisErrors fileErrors) { - errors.addAll(fileErrors.errors); + errorsByFile[fileErrors.file] = fileErrors.errors; } server.onErrors.listen(handleAnalysisErrors); await server.start(); - // Completing the future in the callback can't fail. + + // Capture if the server exits unexpectedly. + final exitErrorCompleter = Completer(); unawaited( server.onExit.then((int? exitCode) { - if (!analysisCompleter.isCompleted) { - analysisCompleter.completeError( - // Include the last 20 lines of server output in exception message - _AnalysisServerExitException( - 'analysis server exited with code $exitCode and output:\n${server.getLogs(20)}', - exitCode, - ), - ); - } + exitErrorCompleter.completeError( + // Include the last 20 lines of server output in exception message + _AnalysisServerExitException( + 'analysis server exited with code $exitCode and output:\n${server.getLogs(20)}', + exitCode, + ), + ); }), ); @@ -113,8 +100,10 @@ class AnalyzeOnce extends AnalyzeBase { ? logger.startProgress('Analyzing $message...') : null; + // Wait for analysis to complete, or the server to exit and produce + // an error. try { - await analysisCompleter.future; + await Future.any([server.waitForAnalysis(), exitErrorCompleter.future]); } on _AnalysisServerExitException catch (error) { throwToolExit(error.message, exitCode: error.exitCode); } @@ -124,6 +113,10 @@ class AnalyzeOnce extends AnalyzeBase { timer?.stop(); } + final List errors = errorsByFile.values + .expand((List fileErrors) => fileErrors) + .toList(); + // emit benchmarks if (isBenchmarking) { writeBenchmark(timer, errors.length); diff --git a/packages/flutter_tools/lib/src/commands/assemble.dart b/packages/flutter_tools/lib/src/commands/assemble.dart index 61203a9f..8371dded 100644 --- a/packages/flutter_tools/lib/src/commands/assemble.dart +++ b/packages/flutter_tools/lib/src/commands/assemble.dart @@ -9,6 +9,7 @@ import 'package:unified_analytics/unified_analytics.dart'; import '../artifacts.dart'; import '../base/common.dart'; import '../base/file_system.dart'; +import '../base/logger.dart'; import '../build_info.dart'; import '../build_system/build_system.dart'; import '../build_system/depfile.dart'; @@ -21,8 +22,9 @@ import '../build_system/targets/linux.dart'; import '../build_system/targets/macos.dart'; import '../build_system/targets/windows.dart'; import '../cache.dart'; +import '../context/tool_context.dart'; import '../convert.dart'; -import '../globals.dart' as globals; +import '../features.dart'; import '../project.dart'; import '../runner/flutter_command.dart'; @@ -31,8 +33,8 @@ var _kDefaultTargets = [ // Shared targets const CopyAssets(), const KernelSnapshot(), - const AotElfProfile(TargetPlatform.android_arm), - const AotElfRelease(TargetPlatform.android_arm), + const AotSnapshotProfile(TargetPlatform.android_arm), + const AotSnapshotRelease(TargetPlatform.android_arm), const AotAssemblyProfile(), const AotAssemblyRelease(), // macOS targets @@ -94,81 +96,97 @@ var _kDefaultTargets = [ /// Assemble provides a low level API to interact with the flutter tool build /// system. class AssembleCommand extends FlutterCommand { - AssembleCommand({bool verboseHelp = false, required BuildSystem buildSystem}) - : _verboseHelp = verboseHelp, - _buildSystem = buildSystem { + AssembleCommand({ + required this._buildSystem, + required this._featureFlags, + required ToolContext super.toolContext, + super.verboseHelp = false, + }) : _toolContext = toolContext { requiresPubspecYaml(); - argParser.addMultiOption( - 'define', - abbr: 'd', - valueHelp: 'target=key=value', - hide: !verboseHelp, - help: - 'DEPRECATED. Use "--dart-define" or "-D" instead for consistency.\n' - '\n' - 'Allows passing configuration to a target, as in "--define=target=key=value".', - ); + argParser.addDescriptors(const >[ + _define, + CommonOptions.dartDefines, + CommonOptions.dartDefineFromFile, + BuildInfoOptions.performanceMeasurementFile, + _input, + _depfile, + _buildInputs, + _buildOutputs, + _output, + BuildInfoOptions.extraFrontEndOptions, + BuildInfoOptions.extraGenSnapshotOptions, + _resourcePoolSize, + ], verboseHelp: verboseHelp); + } - // New -D/--dart-define (consistent across app) - usesDartDefineOption(); + static const _define = MultiOptionDescriptor( + name: 'define', + abbr: 'd', + valueHelp: 'target=key=value', + verboseOnly: true, + help: + 'DEPRECATED. Use "--dart-define" or "-D" instead for consistency.\n' + '\n' + 'Allows passing configuration to a target, as in "--define=target=key=value".', + ); - argParser.addOption( - 'performance-measurement-file', - help: 'Output individual target performance to a JSON file.', - ); - argParser.addMultiOption( - 'input', - abbr: 'i', - help: - 'Allows passing additional inputs with "--input=key=value". Unlike ' - 'defines, additional inputs do not generate a new configuration; instead ' - 'they are treated as dependencies of the targets that use them.', - ); - argParser.addOption( - 'depfile', - help: - 'A file path where a depfile will be written. ' - 'This contains all build inputs and outputs in a Make-style syntax.', - ); - argParser.addOption( - 'build-inputs', - help: - 'A file path where a newline-separated ' - 'file containing all inputs used will be written after a build. ' - 'This file is not included as a build input or output. This file is not ' - 'written if the build fails for any reason.', - ); - argParser.addOption( - 'build-outputs', - help: - 'A file path where a newline-separated ' - 'file containing all outputs created will be written after a build. ' - 'This file is not included as a build input or output. This file is not ' - 'written if the build fails for any reason.', - ); - argParser.addOption( - 'output', - abbr: 'o', - help: - 'A directory where output ' - 'files will be written. Must be either absolute or relative from the ' - 'root of the current Flutter project.', - ); - usesExtraDartFlagOptions(verboseHelp: verboseHelp); - argParser.addOption( - 'resource-pool-size', - help: 'The maximum number of concurrent tasks the build system will run.', - ); - } + static const _input = MultiOptionDescriptor( + name: 'input', + abbr: 'i', + help: + 'Allows passing additional inputs with "--input=key=value". Unlike ' + 'defines, additional inputs do not generate a new configuration; instead ' + 'they are treated as dependencies of the targets that use them.', + ); + + static const _depfile = StringOptionDescriptor( + name: 'depfile', + help: + 'A file path where a depfile will be written. ' + 'This contains all build inputs and outputs in a Make-style syntax.', + ); - final bool _verboseHelp; + static const _buildInputs = StringOptionDescriptor( + name: 'build-inputs', + help: + 'A file path where a newline-separated ' + 'file containing all inputs used will be written after a build. ' + 'This file is not included as a build input or output. This file is not ' + 'written if the build fails for any reason.', + ); - @override - bool get hidden => !_verboseHelp; + static const _buildOutputs = StringOptionDescriptor( + name: 'build-outputs', + help: + 'A file path where a newline-separated ' + 'file containing all outputs created will be written after a build. ' + 'This file is not included as a build input or output. This file is not ' + 'written if the build fails for any reason.', + ); + + static const _output = StringOptionDescriptor( + name: 'output', + abbr: 'o', + help: + 'A directory where output ' + 'files will be written. Must be either absolute or relative from the ' + 'root of the current Flutter project.', + ); + + static const _resourcePoolSize = StringOptionDescriptor( + name: 'resource-pool-size', + help: 'The maximum number of concurrent tasks the build system will run.', + ); final BuildSystem _buildSystem; + final FeatureFlags _featureFlags; + final ToolContext _toolContext; + + @override + ToolContext get toolContext => _toolContext; - late final FlutterProject _flutterProject = FlutterProject.current(); + @override + bool get hidden => !verboseHelp; @override String get description => 'Assemble and build Flutter resources.'; @@ -184,7 +202,7 @@ class AssembleCommand extends FlutterCommand { workflow: commandPath, commandHasTerminal: hasTerminal, buildBundleTargetPlatform: _environment.defines[kTargetPlatform], - buildBundleIsModule: _flutterProject.isModule, + buildBundleIsModule: project.isModule, ); @override @@ -195,7 +213,7 @@ class AssembleCommand extends FlutterCommand { } final targetPlatform = TargetPlatform.fromName(platform); - final DevelopmentArtifact? artifact = artifactFromTargetPlatform(targetPlatform); + final DevelopmentArtifact? artifact = artifactFromTargetPlatform(targetPlatform, _featureFlags); if (artifact != null) { return {artifact}; } @@ -244,20 +262,22 @@ class AssembleCommand extends FlutterCommand { /// The environmental configuration for a build invocation. Environment _createEnvironment() { - String? output = stringArg('output'); + final FileSystem fs = _toolContext.fs; + final FlutterProject project = this.project; + String? output = getValue(_output); if (output == null) { throwToolExit('--output directory is required for assemble.'); } // If path is relative, make it absolute from flutter project. - if (globals.fs.path.isRelative(output)) { - output = globals.fs.path.join(_flutterProject.directory.path, output); + if (fs.path.isRelative(output)) { + output = fs.path.join(project.directory.path, output); } - final Artifacts artifacts = globals.artifacts!; + final Artifacts artifacts = _toolContext.artifacts; List decodedDefines; try { decodedDefines = decodeDartDefines({ - 'dart-define': stringsArg('dart-define').join(','), + 'dart-define': getValue(CommonOptions.dartDefines).join(','), }, 'dart-define'); } on FormatException { throwToolExit( @@ -267,23 +287,23 @@ class AssembleCommand extends FlutterCommand { } return Environment( - outputDir: globals.fs.directory(output), - buildDir: _flutterProject.directory - .childDirectory('.dart_tool') - .childDirectory('flutter_build'), - projectDir: _flutterProject.directory, + outputDir: fs.directory(output), + buildDir: project.directory.childDirectory('.dart_tool').childDirectory('flutter_build'), + projectDir: project.directory, packageConfigPath: packageConfigPath(), - defines: _parseDefines([...stringsArg('define'), ...decodedDefines]), - inputs: _parseDefines(stringsArg('input')), - cacheDir: globals.cache.getRoot(), - flutterRootDir: globals.fs.directory(Cache.flutterRoot), + defines: _parseDefines([...getValue(_define), ...decodedDefines]), + inputs: _parseDefines(getValue(_input)), + cacheDir: _toolContext.cache.getRoot(), + flutterRootDir: fs.directory(Cache.flutterRoot), artifacts: artifacts, - fileSystem: globals.fs, - logger: globals.logger, - processManager: globals.processManager, - analytics: globals.analytics, - platform: globals.platform, - engineVersion: artifacts.usesLocalArtifacts ? null : globals.flutterVersion.engineRevision, + fileSystem: fs, + logger: _toolContext.logger, + processManager: _toolContext.processManager, + analytics: analytics, + platform: _toolContext.platform, + engineVersion: artifacts.usesLocalArtifacts + ? null + : _toolContext.flutterVersion.engineRevision, generateDartPluginRegistry: true, ); } @@ -302,10 +322,9 @@ class AssembleCommand extends FlutterCommand { final String value = chunk.substring(indexEquals + 1); results[key] = value; } - final ArgResults argumentResults = argResults!; - if (argumentResults.wasParsed(FlutterOptions.kExtraGenSnapshotOptions)) { - results[kExtraGenSnapshotOptions] = - (argumentResults[FlutterOptions.kExtraGenSnapshotOptions] as List).join(','); + if (wasParsed(BuildInfoOptions.extraGenSnapshotOptions)) { + results[kExtraGenSnapshotOptions] = getValue(BuildInfoOptions.extraGenSnapshotOptions) + .join(','); } final Map defineConfigJsonMap = extractDartDefineConfigJsonMap(); @@ -315,20 +334,21 @@ class AssembleCommand extends FlutterCommand { } results[kDeferredComponents] = 'false'; - if (_flutterProject.manifest.deferredComponents != null && + if (project.manifest.deferredComponents != null && isDeferredComponentsTargets() && !isDebug()) { results[kDeferredComponents] = 'true'; } - if (argumentResults.wasParsed(FlutterOptions.kExtraFrontEndOptions)) { - results[kExtraFrontEndOptions] = - (argumentResults[FlutterOptions.kExtraFrontEndOptions] as List).join(','); + if (wasParsed(BuildInfoOptions.extraFrontEndOptions)) { + results[kExtraFrontEndOptions] = getValue(BuildInfoOptions.extraFrontEndOptions).join(','); } return results; } @override Future runCommand() async { + final FileSystem fs = _toolContext.fs; + final Logger logger = _toolContext.logger; final List targets = createTargets(); final nonDeferredTargets = []; final List deferredTargets = []; @@ -342,7 +362,7 @@ class AssembleCommand extends FlutterCommand { Target? target; if (deferredTargets.isNotEmpty) { // Record to analytics that DeferredComponents is being used. - globals.analytics.send( + analytics.send( Event.flutterBuildInfo( label: 'assemble-deferred-components', buildType: 'android', @@ -350,7 +370,7 @@ class AssembleCommand extends FlutterCommand { ), ); } - if (_flutterProject.manifest.deferredComponents != null && + if (project.manifest.deferredComponents != null && _environment.defines['validate-deferred-components'] == 'true' && deferredTargets.isNotEmpty && !isDebug()) { @@ -365,51 +385,50 @@ class AssembleCommand extends FlutterCommand { } else if (targets.isNotEmpty) { target = targets.single; } - final ArgResults argumentResults = argResults!; final BuildResult result = await _buildSystem.build( target!, _environment, buildSystemConfig: BuildSystemConfig( - resourcePoolSize: argumentResults.wasParsed('resource-pool-size') - ? int.tryParse(stringArg('resource-pool-size')!) + resourcePoolSize: wasParsed(_resourcePoolSize) + ? int.tryParse(getValue(_resourcePoolSize)!) : null, ), ); if (!result.success) { for (final ExceptionMeasurement measurement in result.exceptions.values) { - if (measurement.fatal || globals.logger.isVerbose) { - globals.printError( + if (measurement.fatal || logger.isVerbose) { + logger.printError( 'Target ${measurement.target} failed: ${measurement.exception}', - stackTrace: globals.logger.isVerbose ? measurement.stackTrace : null, + stackTrace: logger.isVerbose ? measurement.stackTrace : null, ); } } throwToolExit(''); } - globals.printTrace('build succeeded.'); + logger.printTrace('build succeeded.'); - if (argumentResults.wasParsed('build-inputs')) { - writeListIfChanged(result.inputFiles, stringArg('build-inputs')!); + if (wasParsed(_buildInputs)) { + writeListIfChanged(result.inputFiles, getValue(_buildInputs)!, fs: fs); } - if (argumentResults.wasParsed('build-outputs')) { - writeListIfChanged(result.outputFiles, stringArg('build-outputs')!); + if (wasParsed(_buildOutputs)) { + writeListIfChanged(result.outputFiles, getValue(_buildOutputs)!, fs: fs); } - if (argumentResults.wasParsed('performance-measurement-file')) { - final File outFile = globals.fs.file(argumentResults['performance-measurement-file']); + if (wasParsed(BuildInfoOptions.performanceMeasurementFile)) { + final File outFile = fs.file(getValue(BuildInfoOptions.performanceMeasurementFile)); writePerformanceData(result.performance.values, outFile); } - if (argumentResults.wasParsed('depfile')) { - final File depfileFile = globals.fs.file(stringArg('depfile')); + if (wasParsed(_depfile)) { + final File depfileFile = fs.file(getValue(_depfile)); final depfile = Depfile(result.inputFiles, result.outputFiles); - _environment.depFileService.writeToFile(depfile, globals.fs.file(depfileFile)); + _environment.depFileService.writeToFile(depfile, depfileFile); } return FlutterCommandResult.success(); } } @visibleForTesting -void writeListIfChanged(List files, String path) { - final File file = globals.fs.file(path); +void writeListIfChanged(List files, String path, {required FileSystem fs}) { + final File file = fs.file(path); final buffer = StringBuffer(); // These files are already sorted. for (final file in files) { diff --git a/packages/flutter_tools/lib/src/commands/attach.dart b/packages/flutter_tools/lib/src/commands/attach.dart index 8c0c11c1..5ec00560 100644 --- a/packages/flutter_tools/lib/src/commands/attach.dart +++ b/packages/flutter_tools/lib/src/commands/attach.dart @@ -13,25 +13,27 @@ import '../base/common.dart'; import '../base/file_system.dart'; import '../base/io.dart'; import '../base/logger.dart'; -import '../base/platform.dart'; import '../base/signals.dart'; import '../base/terminal.dart'; import '../build_info.dart'; +import '../build_system/build_system.dart'; +import '../build_system/build_targets.dart'; import '../compile.dart'; +import '../context/tool_context.dart'; import '../daemon.dart'; import '../device.dart'; import '../device_vm_service_discovery_for_attach.dart'; +import '../features.dart'; import '../hook_runner.dart' show hookRunner; import '../ios/devices.dart'; import '../ios/simulators.dart'; import '../macos/macos_ipad_device.dart'; +import '../macos/xcode.dart'; import '../mdns_discovery.dart'; -import '../project.dart'; import '../resident_runner.dart'; import '../run_cold.dart'; import '../run_hot.dart'; import '../runner/flutter_command.dart'; -import '../runner/flutter_command_runner.dart'; import '../vmservice.dart'; import 'daemon.dart'; @@ -62,93 +64,77 @@ import 'daemon.dart'; /// also be provided. class AttachCommand extends FlutterCommand { AttachCommand({ - bool verboseHelp = false, + required this._buildSystem, + required this._buildTargets, + required ToolContext super.toolContext, + required this._xcode, HotRunnerFactory? hotRunnerFactory, - required Stdio stdio, - required Logger logger, - required Terminal terminal, - required Signals signals, - required Platform platform, - required ProcessInfo processInfo, - required FileSystem fileSystem, + super.verboseHelp = false, }) : _hotRunnerFactory = hotRunnerFactory ?? HotRunnerFactory(), - _stdio = stdio, - _logger = logger, - _terminal = terminal, - _signals = signals, - _platform = platform, - _processInfo = processInfo, - _fileSystem = fileSystem { - addBuildModeFlags(verboseHelp: verboseHelp, defaultToRelease: false, excludeRelease: true); - usesTargetOption(); - usesPortOptions(verboseHelp: verboseHelp); - usesIpv6Flag(verboseHelp: verboseHelp); - usesFilesystemOptions(hide: !verboseHelp); - usesFuchsiaOptions(hide: !verboseHelp); - usesDartDefineOption(); - usesDeviceUserOption(); - addEnableExperimentation(hide: !verboseHelp); - usesInitializeFromDillOption(hide: !verboseHelp); - usesNativeAssetsOption(hide: !verboseHelp); - argParser - ..addOption( - 'debug-port', - hide: !verboseHelp, - help: - '(deprecated) Device port where the Dart VM Service is listening. Requires ' - '"--disable-service-auth-codes" to also be provided to the Flutter ' - 'application at launch, otherwise this command will fail to connect to ' - 'the application. In general, "--debug-url" should be used instead.', - ) - ..addOption( - 'debug-url', - aliases: ['debug-uri'], // supported for historical reasons - help: 'The URL at which the Dart VM Service is listening.', - ) - ..addOption( - 'app-id', - help: - 'The package name (Android) or bundle identifier (iOS) for the app. ' - 'This can be specified to avoid being prompted if multiple Dart VM Service ports ' - 'are advertised.\n' - 'If you have multiple devices or emulators running, you should include the ' - 'device hostname as well, e.g. "com.example.myApp@my-iphone".\n' - 'This parameter is case-insensitive.', - ) - ..addOption( - 'pid-file', - help: - 'Specify a file to write the process ID to. ' - 'You can send SIGUSR1 to trigger a hot reload ' - 'and SIGUSR2 to trigger a hot restart. ' - 'The file is created when the signal handlers ' - 'are hooked and deleted when they are removed.', - ) - ..addFlag( - 'report-ready', - help: - 'Print "ready" to the console after handling a keyboard command.\n' - 'This is primarily useful for tests and other automation, but consider ' - 'using "--machine" instead.', - hide: !verboseHelp, - ) - ..addOption('project-root', hide: !verboseHelp, help: 'Normally used only in run target.'); - addMachineOutputFlag(verboseHelp: verboseHelp); - usesTrackWidgetCreation(verboseHelp: verboseHelp); - addDdsOptions(verboseHelp: verboseHelp); - addDevToolsOptions(verboseHelp: verboseHelp); - usesDeviceTimeoutOption(); - usesDeviceConnectionOption(); + _toolContext = toolContext { + enableUsesTargetOption(); + registerOptionBundles(const [ + BuildModeOptionsBundle(defaultToRelease: false, excludeRelease: true), + VmServicePortOptionsBundle(), + FilesystemOptionsBundle(), + DeviceDiscoveryOptionsBundle(includeDeviceUser: true), + ResidentCompilerOptionsBundle(), + DdsAndDevToolsOptionsBundle(), + ]); + argParser.addDescriptors(const >[ + CommonOptions.target, + DebuggingOptionDescriptors.fuchsiaTargetModel, + DebuggingOptionDescriptors.fuchsiaModule, + CommonOptions.dartDefines, + CommonOptions.dartDefineFromFile, + CommonOptions.enableExperiment, + _debugPort, + _debugUrl, + _appId, + DebuggingOptionDescriptors.pidFile, + DebuggingOptionDescriptors.reportReady, + DebuggingOptionDescriptors.projectRoot, + CommonOptions.machine, + BuildInfoOptions.trackWidgetCreation, + DebuggingOptionDescriptors.adbLogFiltering, + ], verboseHelp: verboseHelp); } + static const _debugPort = StringOptionDescriptor( + name: 'debug-port', + verboseOnly: true, + help: + '(deprecated) Device port where the Dart VM Service is listening. Requires ' + '"--disable-service-auth-codes" to also be provided to the Flutter ' + 'application at launch, otherwise this command will fail to connect to ' + 'the application. In general, "--debug-url" should be used instead.', + ); + + static const _debugUrl = StringOptionDescriptor( + name: 'debug-url', + aliases: ['debug-uri'], // supported for historical reasons + help: 'The URL at which the Dart VM Service is listening.', + ); + + static const _appId = StringOptionDescriptor( + name: 'app-id', + help: + 'The package name (Android) or bundle identifier (iOS) for the app. ' + 'This can be specified to avoid being prompted if multiple Dart VM Service ports ' + 'are advertised.\n' + 'If you have multiple devices or emulators running, you should include the ' + 'device hostname as well, e.g. "com.example.myApp@my-iphone".\n' + 'This parameter is case-insensitive.', + ); + final HotRunnerFactory _hotRunnerFactory; - final Stdio _stdio; - final Logger _logger; - final Terminal _terminal; - final Signals _signals; - final Platform _platform; - final ProcessInfo _processInfo; - final FileSystem _fileSystem; + final ToolContext _toolContext; + final BuildSystem _buildSystem; + final BuildTargets _buildTargets; + final Xcode? _xcode; + + @override + ToolContext get toolContext => _toolContext; @override final name = 'attach'; @@ -177,18 +163,19 @@ known, it can be explicitly provided to attach via the command-line, e.g. bool get refreshWirelessDevices => true; int? get debugPort { - if (argResults!['debug-port'] == null) { + final String? port = getValue(_debugPort); + if (port == null) { return null; } try { - return int.parse(stringArg('debug-port')!); + return int.parse(port); } on Exception catch (error) { throwToolExit('Invalid port for `--debug-port`: $error'); } } Uri? get debugUri { - final String? debugUrl = stringArg('debug-url'); + final String? debugUrl = getValue(_debugUrl); if (debugUrl == null) { return null; } @@ -202,11 +189,9 @@ known, it can be explicitly provided to attach via the command-line, e.g. return uri; } - String? get appId { - return stringArg('app-id'); - } + String? get appId => getValue(_appId); - String? get userIdentifier => stringArg(FlutterOptions.kDeviceUser); + String? get userIdentifier => getValue(CommonOptions.deviceUser); @override Future validateCommand() async { @@ -226,7 +211,7 @@ known, it can be explicitly provided to attach via the command-line, e.g. if (!_isIOSDevice(targetDevice) && debugPort == null && debugUri == null && - argResults!.wasParsed(FlutterCommand.ipv6Flag)) { + wasParsed(DebuggingOptionDescriptors.ipv6)) { throwToolExit( 'When the --debug-port or --debug-url is unknown, this command determines ' 'the value of --ipv6 on its own.', @@ -234,7 +219,7 @@ known, it can be explicitly provided to attach via the command-line, e.g. } if (debugPort == null && debugUri == null && - argResults!.wasParsed(FlutterCommand.vmServicePortOption)) { + wasParsed(DebuggingOptionDescriptors.vmServicePort)) { throwToolExit( 'When the --debug-port or --debug-url is unknown, this command does not use ' 'the value of --vm-service-port.', @@ -247,7 +232,7 @@ known, it can be explicitly provided to attach via the command-line, e.g. if (userIdentifier != null) { final Device? device = await findTargetDevice(); if (device is! AndroidDevice) { - throwToolExit('--${FlutterOptions.kDeviceUser} is only supported for Android'); + throwToolExit('--${CommonOptions.deviceUser.name} is only supported for Android'); } } } @@ -260,7 +245,7 @@ known, it can be explicitly provided to attach via the command-line, e.g. throwToolExit('Did not find any valid target devices.'); } - final bool machineMode = boolArg(FlutterGlobalOptions.kMachineFlag); + final bool machineMode = getValue(CommonOptions.machine); try { await (machineMode ? _attachDaemon(device: device) : _attach(device: device)); @@ -273,7 +258,7 @@ known, it can be explicitly provided to attach via the command-line, e.g. // However we exited from the runner, ensure the terminal has line mode // and echo mode enabled before we return the user to the shell. try { - _terminal.singleCharMode = false; + toolContext.terminal.singleCharMode = false; } on StdinException { // Do nothing, if the STDIN handle is no longer available, there is nothing actionable for us to do at this point } @@ -283,7 +268,13 @@ known, it can be explicitly provided to attach via the command-line, e.g. } Future _attach({required Device device}) async { - _terminal.usesTerminalUi = true; + final ToolContext( + :Logger logger, + :ProcessInfo processInfo, + :Signals signals, + :Terminal terminal, + ) = toolContext; + terminal.usesTerminalUi = true; final ResidentRunner runner = await _discoverVmServiceAndCreateResidentRunner(device: device); final onAppStart = Completer.sync(); TerminalHandler? terminalHandler; @@ -292,12 +283,12 @@ known, it can be explicitly provided to attach via the command-line, e.g. terminalHandler = TerminalHandler( runner, - logger: _logger, - terminal: _terminal, - signals: _signals, - processInfo: _processInfo, - reportReady: boolArg('report-ready'), - pidFile: stringArg('pid-file'), + logger: logger, + terminal: terminal, + signals: signals, + processInfo: processInfo, + reportReady: getValue(DebuggingOptionDescriptors.reportReady), + pidFile: getValue(DebuggingOptionDescriptors.pidFile), ) ..registerSignalHandlers() ..setupTerminal(); @@ -311,15 +302,22 @@ known, it can be explicitly provided to attach via the command-line, e.g. } Future _attachDaemon({required Device device}) async { + final ToolContext(:FileSystem fs, :Logger logger, :Stdio stdio) = toolContext; final daemon = Daemon( DaemonConnection( - daemonStreams: DaemonStreams.fromStdio(_stdio, logger: _logger), - logger: _logger, + daemonStreams: DaemonStreams.fromStdio(stdio, logger: logger), + logger: logger, ), - notifyingLogger: (_logger is NotifyingLogger) - ? _logger - : NotifyingLogger(verbose: _logger.isVerbose, parent: _logger), + analytics: analytics, + buildSystem: _buildSystem, + buildTargets: _buildTargets, + toolContext: toolContext, + xcode: _xcode, + notifyingLogger: (logger is NotifyingLogger) + ? logger + : NotifyingLogger(verbose: logger.isVerbose, parent: logger), logToStdout: true, + featureFlags: featureFlags, ); final ResidentRunner runner = await _discoverVmServiceAndCreateResidentRunner(device: device); @@ -339,9 +337,9 @@ known, it can be explicitly provided to attach via the command-line, e.g. device, null, true, - _fileSystem.currentDirectory, + fs.currentDirectory, LaunchMode.attach, - _logger as MachineOutputLogger, + logger as MachineOutputLogger, ); } on Exception catch (error) { throwToolExit(error.toString()); @@ -350,19 +348,20 @@ known, it can be explicitly provided to attach via the command-line, e.g. } Future _discoverVmServiceAndCreateResidentRunner({required Device device}) async { - final Stream vmServiceUri = _discoverVmService(device: device); + final Future vmServiceUri = _discoverVmService(device: device); + vmServiceUri.ignore(); final BuildInfo buildInfo = await getBuildInfo(); final FlutterDevice flutterDevice = await FlutterDevice.create( device, - target: targetFile, - targetModelOverride: TargetModel(stringArg('target-model')!), + toolContext: toolContext, buildInfo: buildInfo, + target: targetFile, + targetModelOverride: TargetModel(getValue(DebuggingOptionDescriptors.fuchsiaTargetModel)), userIdentifier: userIdentifier, - platform: _platform, ); - flutterDevice.vmServiceUris = vmServiceUri; + flutterDevice.vmServiceUri = vmServiceUri; final flutterDevices = [flutterDevice]; final debuggingOptions = DebuggingOptions.enabled( buildInfo, @@ -371,9 +370,12 @@ known, it can be explicitly provided to attach via the command-line, e.g. devToolsServerAddress: devToolsServerAddress, usingCISystem: usingCISystem, debugLogsDirectoryPath: debugLogsDirectoryPath, - enableDevTools: boolArg(FlutterCommand.kEnableDevTools), + enableDevTools: getValue(DebuggingOptionDescriptors.enableDevTools), ipv6: ipv6!, - printDtd: boolArg(FlutterGlobalOptions.kPrintDtd, global: true), + printDtd: getValue(CommonOptions.printDtd), + adbLogFiltering: + hasOption(DebuggingOptionDescriptors.adbLogFiltering) && + getValue(DebuggingOptionDescriptors.adbLogFiltering), ); return buildInfo.isDebug @@ -381,23 +383,30 @@ known, it can be explicitly provided to attach via the command-line, e.g. flutterDevices, target: targetFile, debuggingOptions: debuggingOptions, - packagesFilePath: globalResults![FlutterGlobalOptions.kPackagesOption] as String?, - projectRootPath: stringArg('project-root'), - dillOutputPath: stringArg('output-dill'), - flutterProject: FlutterProject.current(), - nativeAssetsYamlFile: stringArg(FlutterOptions.kNativeAssetsYamlFile), + projectRootPath: getValue(DebuggingOptionDescriptors.projectRoot), + dillOutputPath: getValue(BuildInfoOptions.outputDill), + nativeAssetsYamlFile: getValue(BuildInfoOptions.nativeAssetsYamlFile), analytics: analytics, - logger: _logger, + buildSystem: _buildSystem, + buildTargets: _buildTargets, + toolContext: toolContext, + xcode: _xcode, ) : ColdRunner( flutterDevices, target: targetFile, debuggingOptions: debuggingOptions, dartBuilder: hookRunner, + analytics: analytics, + buildSystem: _buildSystem, + buildTargets: _buildTargets, + toolContext: toolContext, + xcode: _xcode, ); } - Stream _discoverVmService({required Device device}) { + Future _discoverVmService({required Device device}) async { + final Logger logger = toolContext.logger; final bool usesIpv6 = ipv6!; final String ipv6Loopback = InternetAddress.loopbackIPv6.address; final String ipv4Loopback = InternetAddress.loopbackIPv4.address; @@ -405,14 +414,12 @@ known, it can be explicitly provided to attach via the command-line, e.g. final bool isWirelessIOSDevice = (device is IOSDevice) && device.isWirelesslyConnected; if (!isWirelessIOSDevice && (debugPort != null || debugUri != null)) { - return Stream.fromFuture( - buildVMServiceUri( - device, - debugUri?.host ?? hostname, - debugPort ?? debugUri!.port, - hostVmservicePort, - debugUri?.path, - ), + return buildVMServiceUri( + device, + debugUri?.host ?? hostname, + debugPort ?? debugUri!.port, + hostVmservicePort, + debugUri?.path, ); } @@ -421,15 +428,15 @@ known, it can be explicitly provided to attach via the command-line, e.g. final VMServiceDiscoveryForAttach vmServiceDiscovery = device.getVMServiceDiscoveryForAttach( appId: appId, - fuchsiaModule: stringArg('module'), + fuchsiaModule: getValue(DebuggingOptionDescriptors.fuchsiaModule), filterDevicePort: devicePort, expectedHostPort: hostVmservicePort, ipv6: usesIpv6, - logger: _logger, + logger: logger, ); - _logger.printStatus('Waiting for a connection from Flutter on ${device.displayName}...'); - final Status discoveryStatus = _logger.startSpinner( + logger.printStatus('Waiting for a connection from Flutter on ${device.displayName}...'); + final Status discoveryStatus = logger.startSpinner( timeout: const Duration(seconds: 30), slowWarningCallback: () { // On iOS we rely on mDNS to find Dart VM Service. @@ -457,8 +464,11 @@ known, it can be explicitly provided to attach via the command-line, e.g. warningColor: TerminalColor.cyan, ); - // Stop the timer once we receive the first uri. - return streamWithCallbackOnFirstItem(vmServiceDiscovery.uris, discoveryStatus.stop); + try { + return await vmServiceDiscovery.firstValidUri(); + } finally { + discoveryStatus.stop(); + } } bool _isIOSDevice(Device device) { @@ -469,23 +479,29 @@ known, it can be explicitly provided to attach via the command-line, e.g. class HotRunnerFactory { HotRunner build( List devices, { - required String target, + required Analytics analytics, + required BuildSystem buildSystem, + required BuildTargets buildTargets, required DebuggingOptions debuggingOptions, - bool benchmarkMode = false, + required String target, + required ToolContext toolContext, + required Xcode? xcode, File? applicationBinary, + bool benchmarkMode = false, + String? dillOutputPath, bool hostIsIde = false, + String? nativeAssetsYamlFile, String? projectRootPath, - String? packagesFilePath, - String? dillOutputPath, bool stayResident = true, - FlutterProject? flutterProject, - String? nativeAssetsYamlFile, - required Analytics analytics, - Logger? logger, }) => HotRunner( devices, - target: target, + analytics: analytics, + buildSystem: buildSystem, + buildTargets: buildTargets, debuggingOptions: debuggingOptions, + target: target, + toolContext: toolContext, + xcode: xcode, benchmarkMode: benchmarkMode, applicationBinary: applicationBinary, hostIsIde: hostIsIde, @@ -493,9 +509,7 @@ class HotRunnerFactory { dillOutputPath: dillOutputPath, stayResident: stayResident, nativeAssetsYamlFile: nativeAssetsYamlFile, - analytics: analytics, dartBuilder: hookRunner, - logger: logger, ); } diff --git a/packages/flutter_tools/lib/src/commands/build.dart b/packages/flutter_tools/lib/src/commands/build.dart index 08977c9a..f31ab468 100644 --- a/packages/flutter_tools/lib/src/commands/build.dart +++ b/packages/flutter_tools/lib/src/commands/build.dart @@ -2,27 +2,31 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter_tools_core/flutter_tools_core.dart'; import 'package:meta/meta.dart'; import 'package:process/process.dart'; -import '../android/android_sdk.dart'; +import '../android/android_builder.dart'; +import '../android/gradle.dart'; import '../artifacts.dart'; -import '../base/config.dart'; +import '../base/common.dart' show throwToolExit; import '../base/file_system.dart'; import '../base/logger.dart'; import '../base/os.dart'; import '../base/platform.dart'; -import '../base/process.dart'; import '../base/template.dart'; -import '../base/terminal.dart'; +import '../build_info.dart'; import '../build_system/build_system.dart'; import '../cache.dart'; +import '../context/android_context.dart'; +import '../context/apple_context.dart'; +import '../context/tool_context.dart'; +import '../experimental/extension_build_manager.dart'; import '../features.dart'; -import '../ios/code_signing.dart'; -import '../ios/plist_parser.dart'; import '../macos/xcode.dart'; import '../runner/flutter_command.dart'; import '../version.dart'; +import '../windows/visual_studio.dart'; import 'build_aar.dart'; import 'build_apk.dart'; import 'build_appbundle.dart'; @@ -39,119 +43,193 @@ import 'darwin_add_to_app.dart'; class BuildCommand extends FlutterCommand { BuildCommand({ - required Artifacts artifacts, - required Cache cache, - required FileSystem fileSystem, - required FlutterVersion flutterVersion, + required AndroidContext androidContext, + required AppleContext appleContext, required BuildSystem buildSystem, - required OperatingSystemUtils osUtils, - required Logger logger, - required AndroidSdk? androidSdk, - required Config config, - required Platform platform, - required ProcessUtils processUtils, - required ProcessManager processManager, - required FileSystemUtils fileSystemUtils, + required FeatureFlags featureFlags, required TemplateRenderer templateRenderer, - required Terminal terminal, - required PlistParser plistParser, - required Xcode? xcode, + required ToolContext toolContext, + AndroidBuilder? androidBuilder, + this._extensionBuildManager, bool verboseHelp = false, - }) { + }) : _appleContext = appleContext, + super(toolContext: toolContext, verboseHelp: verboseHelp) { + final ToolContext( + :Artifacts artifacts, + :Cache cache, + :FlutterVersion flutterVersion, + fs: FileSystem fileSystem, + :Logger logger, + os: OperatingSystemUtils osUtils, + :Platform platform, + :ProcessManager processManager, + ) = toolContext; + final Xcode xcode = appleContext.xcode; + + final codesign = DarwinAddToAppCodesigning.fromContexts( + appleContext: appleContext, + toolContext: toolContext, + ); + final AndroidBuilder effectiveAndroidBuilder = + androidBuilder ?? + AndroidGradleBuilder.fromContexts( + analytics: analytics, + androidContext: androidContext, + toolContext: toolContext, + ); _addSubcommand( BuildAarCommand( - fileSystem: fileSystem, - androidSdk: androidSdk, - logger: logger, + androidBuilder: effectiveAndroidBuilder, + androidContext: androidContext, + buildSystem: buildSystem, + toolContext: toolContext, + verboseHelp: verboseHelp, + ), + ); + _addSubcommand( + BuildApkCommand( + androidBuilder: effectiveAndroidBuilder, + androidContext: androidContext, + buildSystem: buildSystem, + toolContext: toolContext, + verboseHelp: verboseHelp, + ), + ); + _addSubcommand( + BuildAppBundleCommand( + androidBuilder: effectiveAndroidBuilder, + androidContext: androidContext, + buildSystem: buildSystem, + templateRenderer: templateRenderer, + toolContext: toolContext, + verboseHelp: verboseHelp, + ), + ); + _addSubcommand( + BuildIOSCommand( + appleContext: appleContext, + buildSystem: buildSystem, + toolContext: toolContext, verboseHelp: verboseHelp, ), ); - _addSubcommand(BuildApkCommand(logger: logger, verboseHelp: verboseHelp)); - _addSubcommand(BuildAppBundleCommand(logger: logger, verboseHelp: verboseHelp)); - _addSubcommand(BuildIOSCommand(logger: logger, verboseHelp: verboseHelp)); _addSubcommand( BuildIOSFrameworkCommand( - logger: logger, + appleContext: appleContext, buildSystem: buildSystem, + codesign: codesign, + toolContext: toolContext, verboseHelp: verboseHelp, - codesign: DarwinAddToAppCodesigning( - logger: logger, - xcodeCodeSigningSettings: XcodeCodeSigningSettings( - config: config, - logger: logger, - platform: platform, - processUtils: processUtils, - fileSystem: fileSystem, - fileSystemUtils: fileSystemUtils, - terminal: terminal, - plistParser: plistParser, - ), - ), ), ); _addSubcommand( BuildMacOSFrameworkCommand( - logger: logger, + appleContext: appleContext, buildSystem: buildSystem, + codesign: codesign, + toolContext: toolContext, verboseHelp: verboseHelp, - codesign: DarwinAddToAppCodesigning( - logger: logger, - xcodeCodeSigningSettings: XcodeCodeSigningSettings( - config: config, - logger: logger, - platform: platform, - processUtils: processUtils, - fileSystem: fileSystem, - fileSystemUtils: fileSystemUtils, - terminal: terminal, - plistParser: plistParser, - ), - ), ), ); _addSubcommand( BuildSwiftPackage( - logger: logger, analytics: analytics, artifacts: artifacts, buildSystem: buildSystem, cache: cache, + codesign: codesign, featureFlags: featureFlags, fileSystem: fileSystem, flutterVersion: flutterVersion, + logger: logger, platform: platform, processManager: processManager, templateRenderer: templateRenderer, - xcode: xcode, - codesign: DarwinAddToAppCodesigning( - logger: logger, - xcodeCodeSigningSettings: XcodeCodeSigningSettings( - config: config, - logger: logger, - platform: platform, - processUtils: processUtils, - fileSystem: fileSystem, - fileSystemUtils: fileSystemUtils, - terminal: terminal, - plistParser: plistParser, - ), - ), verboseHelp: verboseHelp, + xcode: xcode, ), ); - _addSubcommand(BuildIOSArchiveCommand(logger: logger, verboseHelp: verboseHelp)); - _addSubcommand(BuildBundleCommand(logger: logger, verboseHelp: verboseHelp)); _addSubcommand( - BuildWebCommand(fileSystem: fileSystem, logger: logger, verboseHelp: verboseHelp), + BuildIOSArchiveCommand( + appleContext: appleContext, + buildSystem: buildSystem, + toolContext: toolContext, + verboseHelp: verboseHelp, + ), ); - _addSubcommand(BuildMacosCommand(logger: logger, verboseHelp: verboseHelp)); _addSubcommand( - BuildLinuxCommand(logger: logger, operatingSystemUtils: osUtils, verboseHelp: verboseHelp), + BuildBundleCommand( + buildSystem: buildSystem, + featureFlags: featureFlags, + toolContext: toolContext, + verboseHelp: verboseHelp, + ), ); _addSubcommand( - BuildWindowsCommand(logger: logger, operatingSystemUtils: osUtils, verboseHelp: verboseHelp), + BuildWebCommand( + buildSystem: buildSystem, + featureFlags: featureFlags, + toolContext: toolContext, + verboseHelp: verboseHelp, + ), ); + _addSubcommand( + BuildMacosCommand( + buildSystem: buildSystem, + featureFlags: featureFlags, + toolContext: toolContext, + verboseHelp: verboseHelp, + ), + ); + _addSubcommand( + BuildLinuxCommand( + buildSystem: buildSystem, + featureFlags: featureFlags, + toolContext: toolContext, + verboseHelp: verboseHelp, + ), + ); + _addSubcommand( + BuildWindowsCommand( + buildSystem: buildSystem, + featureFlags: featureFlags, + toolContext: toolContext, + verboseHelp: verboseHelp, + visualStudio: VisualStudio( + fileSystem: fileSystem, + platform: platform, + logger: logger, + processManager: processManager, + osUtils: osUtils, + ), + ), + ); + } + + final ExtensionBuildManager? _extensionBuildManager; + + @override + Future initializeDynamicOptions() async { + if (_extensionBuildManager case final extensionBuildManager?) { + final List targets = await extensionBuildManager.getBuildTargets(); + for (final target in targets) { + if (!subcommands.containsKey(target.name)) { + _addSubcommand( + ExtensionBuildSubCommand( + buildManager: extensionBuildManager, + target: target, + toolContext: toolContext, + verboseHelp: verboseHelp, + ), + ); + } else { + toolContext.logger.printWarning( + 'Skipping custom build target "${target.name}" because a subcommand with that name already exists.', + ); + } + } + } } void _addSubcommand(BuildSubCommand command) { @@ -161,20 +239,34 @@ class BuildCommand extends FlutterCommand { } @override - final name = 'build'; + ToolContext get toolContext => super.toolContext!; @override - final description = 'Build an executable app or install bundle.'; + final String name = 'build'; + + @override + final String description = 'Build an executable app or install bundle.'; @override String get category => FlutterCommandCategory.project; @override Future runCommand() async => FlutterCommandResult.fail(); + + final AppleContext _appleContext; + + /// The Apple-specific context dependencies, exposed for hermetic testing. + @visibleForTesting + AppleContext get appleContext => _appleContext; } abstract class BuildSubCommand extends FlutterCommand { - BuildSubCommand({required this.logger, required bool verboseHelp}) { + BuildSubCommand({ + required this.logger, + required super.verboseHelp, + super.outputPreferences, + super.toolContext, + }) { requiresPubspecYaml(); usesFatalWarningsOption(verboseHelp: verboseHelp); } @@ -185,3 +277,58 @@ abstract class BuildSubCommand extends FlutterCommand { /// Whether this command is supported and should be shown. bool get supported => true; } + +/// A dynamically registered `flutter build` subcommand backed by a tool extension. +class ExtensionBuildSubCommand extends BuildSubCommand { + ExtensionBuildSubCommand({ + required this._buildManager, + required this.target, + required ToolContext toolContext, + required super.verboseHelp, + }) : super( + logger: toolContext.logger, + outputPreferences: toolContext.outputPreferences, + toolContext: toolContext, + ) { + usesTargetOption(); + usesPubOption(); + addBuildModeFlags(verboseHelp: verboseHelp); + } + + final ExtensionBuildManager _buildManager; + + /// The custom build target definition provided by the tool extension. + final ExtensionBuildTarget target; + + @override + ToolContext get toolContext => super.toolContext!; + + @override + String get name => target.name; + + @override + String get description => target.description; + + @override + Future runCommand() async { + final BuildInfo buildInfo = await getBuildInfo(); + + final ExtensionBuildResult result = await _buildManager.build( + buildMode: buildInfo.mode, + mainPath: targetFile, + projectRoot: toolContext.fs.currentDirectory.uri, + targetName: target.name, + ); + + if (result.success) { + return FlutterCommandResult.success(); + } else { + final String? errorMessage = result.errorMessage; + throwToolExit( + errorMessage != null && errorMessage.isNotEmpty + ? 'Build failed: $errorMessage' + : 'Build failed.', + ); + } + } +} diff --git a/packages/flutter_tools/lib/src/commands/build_aar.dart b/packages/flutter_tools/lib/src/commands/build_aar.dart index f90f6252..e216817c 100644 --- a/packages/flutter_tools/lib/src/commands/build_aar.dart +++ b/packages/flutter_tools/lib/src/commands/build_aar.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:meta/meta.dart'; import 'package:unified_analytics/unified_analytics.dart'; import '../android/android_builder.dart'; @@ -11,59 +12,86 @@ import '../base/common.dart'; import '../base/file_system.dart'; import '../base/os.dart'; import '../build_info.dart'; -import '../cache.dart'; -import '../globals.dart' as globals; +import '../build_system/build_system.dart'; +import '../context/android_context.dart'; +import '../context/tool_context.dart'; import '../project.dart'; -import '../runner/flutter_command.dart' show FlutterCommandResult; +import '../runner/flutter_command.dart'; import 'build.dart'; class BuildAarCommand extends BuildSubCommand { BuildAarCommand({ - required super.logger, - required AndroidSdk? androidSdk, - required FileSystem fileSystem, - required bool verboseHelp, - }) : _androidSdk = androidSdk, - _fileSystem = fileSystem, - super(verboseHelp: verboseHelp) { - argParser - ..addFlag('debug', defaultsTo: true, help: 'Build a debug version of the current project.') - ..addFlag( - 'profile', - defaultsTo: true, - help: 'Build a version of the current project specialized for performance profiling.', - ) - ..addFlag( - 'release', - defaultsTo: true, - help: 'Build a release version of the current project.', - ); - addTreeShakeIconsFlag(); - usesFlavorOption(); - usesBuildNumberOption(); - usesOutputDir(); - usesPubOption(); - addSplitDebugInfoOption(); - addDartObfuscationOption(); - usesDartDefineOption(); - usesExtraDartFlagOptions(verboseHelp: verboseHelp); - usesTrackWidgetCreation(verboseHelp: false); - addEnableExperimentation(hide: !verboseHelp); - addAndroidSpecificBuildOptions(hide: !verboseHelp); - // No --[no-]enable-hcpp flag here: the Flutter Gradle Plugin intentionally - // does not inject the EnableHcpp metadata into module (aar) manifests, - // because a library-provided value that conflicts with an explicit value in - // the host app's manifest fails the host build in the manifest merger. The - // host app's manifest is the source of truth for HCPP in add-to-app. - argParser.addMultiOption( - 'target-platform', - defaultsTo: ['android-arm', 'android-arm64', 'android-x64'], - allowed: ['android-arm', 'android-arm64', 'android-x64'], - help: 'The target platform for which the project is compiled.', - ); + required this._androidBuilder, + required this._androidContext, + required this._buildSystem, + required ToolContext toolContext, + super.verboseHelp = false, + }) : super(logger: toolContext.logger, toolContext: toolContext) { + enableUsesPubOption(); + registerOptionBundles(const [ + DartCompileOptionsBundle(), + AndroidGradleOptionsBundle(), + ]); + argParser.addDescriptors(const >[ + _debugMode, + _profileMode, + _releaseMode, + CommonOptions.treeShakeIcons, + BuildInfoOptions.flavor, + CommonOptions.buildNumber, + CommonOptions.outputDir, + CommonOptions.pub, + BuildInfoOptions.splitDebugInfo, + BuildInfoOptions.obfuscate, + BuildInfoOptions.extraFrontEndOptions, + BuildInfoOptions.extraGenSnapshotOptions, + AndroidBuildOptionsBundle.targetPlatformWithDefaults, + BuildInfoOptions.trackWidgetCreation, + ], verboseHelp: verboseHelp); } - final AndroidSdk? _androidSdk; - final FileSystem _fileSystem; + + static const _debugMode = FlagOptionDescriptor( + name: 'debug', + defaultsTo: true, + help: 'Build a debug version of the current project.', + ); + + static const _profileMode = FlagOptionDescriptor( + name: 'profile', + defaultsTo: true, + help: 'Build a version of the current project specialized for performance profiling.', + ); + + static const _releaseMode = FlagOptionDescriptor( + name: 'release', + defaultsTo: true, + help: 'Build a release version of the current project.', + ); + + final AndroidBuilder _androidBuilder; + final AndroidContext _androidContext; + final BuildSystem _buildSystem; + + /// The [AndroidBuilder] used to build the AAR. + @visibleForTesting + AndroidBuilder get androidBuilder => _androidBuilder; + + /// The [AndroidContext] containing Android-specific toolchain dependencies. + @visibleForTesting + AndroidContext get androidContext => _androidContext; + + /// The [AndroidSdk] instance, if available. + @visibleForTesting + AndroidSdk? get androidSdk => _androidContext.androidSdk; + + /// The [BuildSystem] used for building the project. + @visibleForTesting + BuildSystem get buildSystem => _buildSystem; + + @override + ToolContext get toolContext => super.toolContext!; + + FileSystem get _fileSystem => toolContext.fs; @override final name = 'aar'; @@ -91,7 +119,8 @@ class BuildAarCommand extends BuildSubCommand { workflow: commandPath, commandHasTerminal: hasTerminal, buildAarProjectType: projectType, - buildAarTargetPlatform: stringsArg('target-platform').join(','), + buildAarTargetPlatform: getValue(AndroidBuildOptionsBundle.targetPlatformWithDefaults) + .join(','), // TODO(gmackall): Consider collecting hcpp analytics, see https://github.com/flutter/flutter/issues/184541. ); } @@ -118,30 +147,27 @@ class BuildAarCommand extends BuildSubCommand { @override Future runCommand() async { - if (_androidSdk == null) { - exitWithNoSdkMessage(); + if (_androidContext.androidSdk == null) { + exitWithNoSdkMessage(analytics: analytics, logger: logger); } final androidBuildInfo = {}; - final Iterable targetArchitectures = stringsArg( - 'target-platform', + final Iterable targetArchitectures = getValue( + AndroidBuildOptionsBundle.targetPlatformWithDefaults, ).map(getCpuArchForName); - final String? buildNumberArg = stringArg('build-number'); - final String buildNumber = - argParser.options.containsKey('build-number') && - buildNumberArg != null && - buildNumberArg.isNotEmpty + final String? buildNumberArg = getValue(CommonOptions.buildNumber); + final String buildNumber = (buildNumberArg != null && buildNumberArg.isNotEmpty) ? buildNumberArg : '1.0'; final File targetFile = _fileSystem.file(_fileSystem.path.join('lib', 'main.dart')); - for (final buildMode in const ['debug', 'profile', 'release']) { - if (boolArg(buildMode)) { + for (final FlagOptionDescriptor descriptor in const [_debugMode, _profileMode, _releaseMode]) { + if (getValue(descriptor)) { androidBuildInfo.add( AndroidBuildInfo( await getBuildInfo( - forcedBuildMode: BuildMode.fromCliName(buildMode), + forcedBuildMode: BuildMode.fromCliName(descriptor.name), forcedTargetFile: targetFile, ), targetArchs: targetArchitectures, @@ -153,12 +179,12 @@ class BuildAarCommand extends BuildSubCommand { throwToolExit('Please specify a build mode and try again.'); } - await androidBuilder?.buildAar( + await _androidBuilder.buildAar( project: project, target: targetFile.path, androidBuildInfo: androidBuildInfo, generateTooling: regeneratePlatformSpecificToolingIfApplicable, - outputDirectoryPath: stringArg('output'), + outputDirectoryPath: getValue(CommonOptions.outputDir), buildNumber: buildNumber, ); @@ -166,7 +192,7 @@ class BuildAarCommand extends BuildSubCommand { final buildLabel = impellerEnabled ? 'manifest-aar-impeller-enabled' : 'manifest-aar-impeller-disabled'; - globals.analytics.send(Event.flutterBuildInfo(label: buildLabel, buildType: 'android')); + analytics.send(Event.flutterBuildInfo(label: buildLabel, buildType: 'android')); return FlutterCommandResult.success(); } @@ -193,6 +219,6 @@ class BuildAarCommand extends BuildSubCommand { if (projectRoot == null) { throwToolExit('${mainFile.parent.path} is not a valid flutter project'); } - return FlutterProject.fromDirectory(_fileSystem.directory(projectRoot)); + return toolContext.projectFactory.fromDirectory(_fileSystem.directory(projectRoot)); } } diff --git a/packages/flutter_tools/lib/src/commands/build_apk.dart b/packages/flutter_tools/lib/src/commands/build_apk.dart index 7da02d51..f2669d93 100644 --- a/packages/flutter_tools/lib/src/commands/build_apk.dart +++ b/packages/flutter_tools/lib/src/commands/build_apk.dart @@ -2,69 +2,76 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:meta/meta.dart'; import 'package:unified_analytics/unified_analytics.dart'; import '../android/android_builder.dart'; +import '../android/android_sdk.dart'; import '../android/build_validation.dart'; import '../android/gradle_utils.dart'; +import '../base/logger.dart'; +import '../base/terminal.dart'; import '../build_info.dart'; -import '../cache.dart'; -import '../globals.dart' as globals; +import '../build_system/build_system.dart'; +import '../context/android_context.dart'; +import '../context/tool_context.dart'; import '../project.dart'; -import '../runner/flutter_command.dart' show FlutterCommandResult; +import '../runner/flutter_command.dart'; import 'build.dart'; class BuildApkCommand extends BuildSubCommand { - BuildApkCommand({required super.logger, bool verboseHelp = false}) - : super(verboseHelp: verboseHelp) { - addTreeShakeIconsFlag(); - usesTargetOption(); - addBuildModeFlags(verboseHelp: verboseHelp); - usesFlavorOption(); - usesPubOption(); - usesBuildNumberOption(); - usesBuildNameOption(); - addShrinkingFlag(verboseHelp: verboseHelp); - addSplitDebugInfoOption(); - addDartObfuscationOption(); - usesDartDefineOption(); - usesExtraDartFlagOptions(verboseHelp: verboseHelp); - addEnableExperimentation(hide: !verboseHelp); - addBuildPerformanceFile(hide: !verboseHelp); - usesAnalyzeSizeFlag(); - addAndroidSpecificBuildOptions(hide: !verboseHelp); - addIgnoreDeprecationOption(); - addEnableHcppFlag(verboseHelp: verboseHelp); - argParser - ..addFlag( - 'split-per-abi', - negatable: false, - help: - 'Whether to split the APKs per ABIs. ' - 'To learn more, see: https://developer.android.com/studio/build/configure-apk-splits#configure-abi-split', - ) - ..addFlag( - 'config-only', - help: - 'Generate build files used by flutter but ' - 'do not build any artifacts.', - ) - ..addMultiOption( - 'target-platform', - allowed: ['android-arm', 'android-arm64', 'android-x64'], - help: 'The target platform for which the app is compiled.', - ); - usesTrackWidgetCreation(verboseHelp: verboseHelp); + BuildApkCommand({ + required this._androidBuilder, + required this._androidContext, + required this._buildSystem, + required ToolContext toolContext, + super.verboseHelp = false, + }) : super(logger: toolContext.logger, toolContext: toolContext) { + registerOptionBundles(const [ + CommonBuildOptionsBundle(), + BuildModeOptionsBundle(), + DartCompileOptionsBundle(), + AndroidBuildOptionsBundle(), + ]); + argParser.addDescriptors(const >[ + _splitPerAbi, + BuildInfoOptions.configOnly, + AndroidBuildOptionsBundle.targetPlatform, + ]); } + static const _splitPerAbi = FlagOptionDescriptor( + name: 'split-per-abi', + negatable: false, + help: + 'Whether to split the APKs per ABIs. ' + 'To learn more, see: https://developer.android.com/studio/build/configure-apk-splits#configure-abi-split', + ); + + final AndroidBuilder _androidBuilder; + final AndroidContext _androidContext; + final BuildSystem _buildSystem; + + @visibleForTesting + AndroidBuilder get androidBuilder => _androidBuilder; + + @visibleForTesting + AndroidContext get androidContext => _androidContext; + + @visibleForTesting + AndroidSdk? get androidSdk => _androidContext.androidSdk; + + @visibleForTesting + BuildSystem get buildSystem => _buildSystem; + BuildMode get _buildMode { - if (boolArg('release')) { + if (getValue(CommonOptions.releaseMode)) { return BuildMode.release; - } else if (boolArg('profile')) { + } else if (getValue(CommonOptions.profileMode)) { return BuildMode.profile; - } else if (boolArg('debug')) { + } else if (getValue(CommonOptions.debugMode)) { return BuildMode.debug; - } else if (boolArg('jit-release')) { + } else if (getValue(CommonOptions.jitReleaseMode)) { return BuildMode.jitRelease; } return BuildMode.release; @@ -72,21 +79,44 @@ class BuildApkCommand extends BuildSubCommand { static const _kDefaultJitArchs = ['android-arm', 'android-arm64', 'android-x64']; static const _kDefaultAotArchs = ['android-arm', 'android-arm64', 'android-x64']; - List get _targetArchs => stringsArg('target-platform').isEmpty - ? switch (_buildMode) { - BuildMode.release || BuildMode.profile => _kDefaultAotArchs, - BuildMode.debug || BuildMode.jitRelease => _kDefaultJitArchs, - } - : stringsArg('target-platform'); + List get _targetArchs { + final List targetPlatform = getValue(AndroidBuildOptionsBundle.targetPlatform); + return targetPlatform.isEmpty + ? switch (_buildMode) { + BuildMode.release || BuildMode.profile => _kDefaultAotArchs, + BuildMode.debug || BuildMode.jitRelease => _kDefaultJitArchs, + } + : targetPlatform; + } + + @override + ToolContext get toolContext => super.toolContext!; + + @override + FlutterProject get project => + toolContext.projectFactory.fromDirectory(toolContext.fs.currentDirectory); + + @override + String get targetFile { + if (argResults?.wasParsed('target') ?? false) { + return stringArg('target')!; + } + final List? rest = argResults?.rest; + if (rest != null && rest.isNotEmpty) { + return rest.first; + } + return toolContext.fs.path.join('lib', 'main.dart'); + } @override final name = 'apk'; @override - DeprecationBehavior get deprecationBehavior => - boolArg('ignore-deprecation') ? DeprecationBehavior.ignore : DeprecationBehavior.exit; + DeprecationBehavior get deprecationBehavior => getValue(BuildInfoOptions.ignoreDeprecation) + ? DeprecationBehavior.ignore + : DeprecationBehavior.exit; - bool get configOnly => boolArg('config-only'); + bool get configOnly => getValue(BuildInfoOptions.configOnly); @override Future> get requiredArtifacts async => { @@ -110,29 +140,28 @@ class BuildApkCommand extends BuildSubCommand { commandHasTerminal: hasTerminal, buildApkTargetPlatform: _targetArchs.join(','), buildApkBuildMode: _buildMode.cliName, - buildApkSplitPerAbi: boolArg('split-per-abi'), + buildApkSplitPerAbi: getValue(_splitPerAbi), buildApkEnableHcpp: - explicitEnableHcpp ?? - FlutterProject.current().android.computeHcppEnabled(ifAbsent: enableHcpp), + explicitEnableHcpp ?? project.android.computeHcppEnabled(ifAbsent: enableHcpp), ); } @override Future runCommand() async { - if (globals.androidSdk == null) { - exitWithNoSdkMessage(); + final ToolContext(:Logger logger, :Terminal terminal) = toolContext; + if (androidSdk == null) { + exitWithNoSdkMessage(analytics: analytics, logger: logger); } final BuildInfo buildInfo = await getBuildInfo(); final androidBuildInfo = AndroidBuildInfo( buildInfo, - splitPerAbi: boolArg('split-per-abi'), + splitPerAbi: getValue(_splitPerAbi), targetArchs: _targetArchs.map(getCpuArchForName), ); validateBuild(androidBuildInfo); - globals.terminal.usesTerminalUi = true; - final FlutterProject project = FlutterProject.current(); - await androidBuilder?.buildApk( + terminal.usesTerminalUi = true; + await _androidBuilder.buildApk( project: project, target: targetFile, androidBuildInfo: androidBuildInfo, @@ -141,7 +170,7 @@ class BuildApkCommand extends BuildSubCommand { final bool impellerEnabled = project.android.computeImpellerEnabled(); final buildLabel = impellerEnabled ? 'manifest-impeller-enabled' : 'manifest-impeller-disabled'; - globals.analytics.send(Event.flutterBuildInfo(label: buildLabel, buildType: 'android')); + analytics.send(Event.flutterBuildInfo(label: buildLabel, buildType: 'android')); return FlutterCommandResult.success(); } diff --git a/packages/flutter_tools/lib/src/commands/build_appbundle.dart b/packages/flutter_tools/lib/src/commands/build_appbundle.dart index 9a8c320f..4a9a496a 100644 --- a/packages/flutter_tools/lib/src/commands/build_appbundle.dart +++ b/packages/flutter_tools/lib/src/commands/build_appbundle.dart @@ -2,72 +2,97 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:meta/meta.dart'; import 'package:unified_analytics/unified_analytics.dart'; import '../android/android_builder.dart'; +import '../android/android_sdk.dart'; import '../android/build_validation.dart'; import '../android/deferred_components_prebuild_validator.dart'; +import '../android/deferred_components_validator.dart'; import '../android/gradle_utils.dart'; import '../base/deferred_component.dart'; import '../base/file_system.dart'; +import '../base/logger.dart'; +import '../base/template.dart'; +import '../base/terminal.dart'; import '../build_info.dart'; -import '../cache.dart'; -import '../globals.dart' as globals; +import '../build_system/build_system.dart'; +import '../context/android_context.dart'; +import '../context/tool_context.dart'; import '../project.dart'; -import '../runner/flutter_command.dart' show FlutterCommandResult; +import '../runner/flutter_command.dart'; import 'build.dart'; class BuildAppBundleCommand extends BuildSubCommand { - BuildAppBundleCommand({required super.logger, bool verboseHelp = false}) - : super(verboseHelp: verboseHelp) { - addTreeShakeIconsFlag(); - usesTargetOption(); - addBuildModeFlags(verboseHelp: verboseHelp); - usesFlavorOption(); - usesPubOption(); - usesBuildNumberOption(); - usesBuildNameOption(); - addShrinkingFlag(verboseHelp: verboseHelp); - addSplitDebugInfoOption(); - addDartObfuscationOption(); - usesDartDefineOption(); - usesExtraDartFlagOptions(verboseHelp: verboseHelp); - addBuildPerformanceFile(hide: !verboseHelp); - usesTrackWidgetCreation(verboseHelp: verboseHelp); - addEnableExperimentation(hide: !verboseHelp); - usesAnalyzeSizeFlag(); - addAndroidSpecificBuildOptions(hide: !verboseHelp); - addIgnoreDeprecationOption(); - addEnableHcppFlag(verboseHelp: verboseHelp); - argParser.addMultiOption( - 'target-platform', - defaultsTo: ['android-arm', 'android-arm64', 'android-x64'], - allowed: ['android-arm', 'android-arm64', 'android-x64'], - help: 'The target platform for which the app is compiled.', - ); - argParser.addFlag( - 'deferred-components', - defaultsTo: true, - help: - 'Setting to false disables building with deferred components. All deferred code ' - 'will be compiled into the base app, and assets act as if they were defined under' - ' the regular assets section in pubspec.yaml. This flag has no effect on ' - 'non-deferred components apps.', - ); - argParser.addFlag( - 'validate-deferred-components', - defaultsTo: true, - help: - 'When enabled, deferred component apps will fail to build if setup problems are ' - 'detected that would prevent deferred components from functioning properly. The ' - 'tooling also provides guidance on how to set up the project files to pass this ' - 'verification. Disabling setup verification will always attempt to fully build ' - 'the app regardless of any problems detected. Builds that are part of CI testing ' - 'and advanced users with custom deferred components implementations should disable ' - 'setup verification. This flag has no effect on non-deferred components apps.', - ); + BuildAppBundleCommand({ + required this._androidBuilder, + required this._androidContext, + required this._buildSystem, + required this._templateRenderer, + required ToolContext toolContext, + super.verboseHelp = false, + }) : super(logger: toolContext.logger, toolContext: toolContext) { + registerOptionBundles(const [ + CommonBuildOptionsBundle(), + BuildModeOptionsBundle(), + DartCompileOptionsBundle(), + AndroidBuildOptionsBundle(), + ]); + argParser.addDescriptors(const >[ + AndroidBuildOptionsBundle.targetPlatformWithDefaults, + _deferredComponents, + _validateDeferredComponents, + ]); } + static const _deferredComponents = FlagOptionDescriptor( + name: 'deferred-components', + defaultsTo: true, + help: + 'Setting to false disables building with deferred components. All deferred code ' + 'will be compiled into the base app, and assets act as if they were defined under' + ' the regular assets section in pubspec.yaml. This flag has no effect on ' + 'non-deferred components apps.', + ); + + static const _validateDeferredComponents = FlagOptionDescriptor( + name: 'validate-deferred-components', + defaultsTo: true, + help: + 'When enabled, deferred component apps will fail to build if setup problems are ' + 'detected that would prevent deferred components from functioning properly. The ' + 'tooling also provides guidance on how to set up the project files to pass this ' + 'verification. Disabling setup verification will always attempt to fully build ' + 'the app regardless of any problems detected. Builds that are part of CI testing ' + 'and advanced users with custom deferred components implementations should disable ' + 'setup verification. This flag has no effect on non-deferred components apps.', + ); + + final AndroidBuilder _androidBuilder; + final AndroidContext _androidContext; + final BuildSystem _buildSystem; + final TemplateRenderer _templateRenderer; + + /// The [AndroidBuilder] used to build the app bundle. + @visibleForTesting + AndroidBuilder get androidBuilder => _androidBuilder; + + /// The [AndroidContext] containing Android-specific toolchain dependencies. + @visibleForTesting + AndroidContext get androidContext => _androidContext; + + /// The [AndroidSdk] instance, if available. + @visibleForTesting + AndroidSdk? get androidSdk => _androidContext.androidSdk; + + /// The [BuildSystem] used for building the project. + @visibleForTesting + BuildSystem get buildSystem => _buildSystem; + + @override + ToolContext get toolContext => super.toolContext!; + @override final name = 'appbundle'; @@ -75,8 +100,9 @@ class BuildAppBundleCommand extends BuildSubCommand { List get aliases => const ['aab']; @override - DeprecationBehavior get deprecationBehavior => - boolArg('ignore-deprecation') ? DeprecationBehavior.ignore : DeprecationBehavior.exit; + DeprecationBehavior get deprecationBehavior => getValue(BuildInfoOptions.ignoreDeprecation) + ? DeprecationBehavior.ignore + : DeprecationBehavior.exit; @override Future> get requiredArtifacts async => { @@ -94,11 +120,11 @@ class BuildAppBundleCommand extends BuildSubCommand { Future unifiedAnalyticsUsageValues(String commandPath) async { final String buildMode; - if (boolArg('release')) { + if (getValue(CommonOptions.releaseMode)) { buildMode = 'release'; - } else if (boolArg('debug')) { + } else if (getValue(CommonOptions.debugMode)) { buildMode = 'debug'; - } else if (boolArg('profile')) { + } else if (getValue(CommonOptions.profileMode)) { buildMode = 'profile'; } else { // The build defaults to release. @@ -108,42 +134,46 @@ class BuildAppBundleCommand extends BuildSubCommand { return Event.commandUsageValues( workflow: commandPath, commandHasTerminal: hasTerminal, - buildAppBundleTargetPlatform: stringsArg('target-platform').join(','), + buildAppBundleTargetPlatform: getValue(AndroidBuildOptionsBundle.targetPlatformWithDefaults) + .join(','), buildAppBundleBuildMode: buildMode, buildBundleEnableHcpp: - explicitEnableHcpp ?? - FlutterProject.current().android.computeHcppEnabled(ifAbsent: enableHcpp), + explicitEnableHcpp ?? project.android.computeHcppEnabled(ifAbsent: enableHcpp), ); } @override Future runCommand() async { - if (globals.androidSdk == null) { - exitWithNoSdkMessage(); + final ToolContext(:Logger logger, :Terminal terminal) = toolContext; + if (_androidContext.androidSdk == null) { + exitWithNoSdkMessage(analytics: analytics, logger: logger); } final androidBuildInfo = AndroidBuildInfo( await getBuildInfo(), - targetArchs: stringsArg('target-platform').map(getCpuArchForName), + targetArchs: getValue(AndroidBuildOptionsBundle.targetPlatformWithDefaults) + .map(getCpuArchForName), ); // Do all setup verification that doesn't involve loading units. Checks that // require generated loading units are done after gen_snapshot in assemble. - final List? deferredComponents = - FlutterProject.current().manifest.deferredComponents; - if (deferredComponents != null && boolArg('deferred-components')) { + final List? deferredComponents = project.manifest.deferredComponents; + if (deferredComponents != null && getValue(_deferredComponents)) { // Record to analytics that DeferredComponents is being used. - globals.analytics.send( + analytics.send( Event.flutterBuildInfo(label: 'build-appbundle-deferred-components', buildType: 'android'), ); } if (deferredComponents != null && - boolArg('deferred-components') && - boolArg('validate-deferred-components') && - !boolArg('debug')) { + getValue(_deferredComponents) && + getValue(_validateDeferredComponents) && + !getValue(CommonOptions.debugMode)) { final validator = DeferredComponentsPrebuildValidator( project.directory, - globals.logger, - globals.platform, + toolContext: toolContext, + templateRenderer: _templateRenderer, title: 'Deferred components prebuild validation', + outputDir: project.buildDirectory.childDirectory( + DeferredComponentsValidator.kDeferredComponentsTempDirectory, + ), ); validator.clearOutputDir(); await validator.checkAndroidDynamicFeature(deferredComponents); @@ -154,8 +184,7 @@ class BuildAppBundleCommand extends BuildSubCommand { // Delete intermediates libs dir for components to resolve mismatching // abis supported by base and dynamic feature modules. for (final DeferredComponent component in deferredComponents) { - final Directory deferredLibsIntermediate = project.directory - .childDirectory('build') + final Directory deferredLibsIntermediate = project.buildDirectory .childDirectory(component.name) .childDirectory('intermediates') .childDirectory('flutter') @@ -168,18 +197,19 @@ class BuildAppBundleCommand extends BuildSubCommand { } validateBuild(androidBuildInfo); - globals.terminal.usesTerminalUi = true; - await androidBuilder?.buildAab( + terminal.usesTerminalUi = true; + await _androidBuilder.buildAab( project: project, target: targetFile, androidBuildInfo: androidBuildInfo, - validateDeferredComponents: boolArg('validate-deferred-components'), - deferredComponentsEnabled: boolArg('deferred-components') && !boolArg('debug'), + validateDeferredComponents: getValue(_validateDeferredComponents), + deferredComponentsEnabled: + getValue(_deferredComponents) && !getValue(CommonOptions.debugMode), ); final bool impellerEnabled = project.android.computeImpellerEnabled(); final buildLabel = impellerEnabled ? 'manifest-impeller-enabled' : 'manifest-impeller-disabled'; - globals.analytics.send(Event.flutterBuildInfo(label: buildLabel, buildType: 'android')); + analytics.send(Event.flutterBuildInfo(label: buildLabel, buildType: 'android')); return FlutterCommandResult.success(); } } diff --git a/packages/flutter_tools/lib/src/commands/build_bundle.dart b/packages/flutter_tools/lib/src/commands/build_bundle.dart index 32ef91fc..9d085022 100644 --- a/packages/flutter_tools/lib/src/commands/build_bundle.dart +++ b/packages/flutter_tools/lib/src/commands/build_bundle.dart @@ -2,25 +2,33 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:meta/meta.dart'; import 'package:unified_analytics/unified_analytics.dart'; import '../base/common.dart'; +import '../base/file_system.dart'; import '../build_info.dart'; -import '../bundle.dart'; +import '../build_system/build_system.dart'; import '../bundle_builder.dart'; +import '../context/tool_context.dart'; import '../features.dart'; -import '../globals.dart' as globals; import '../project.dart'; import '../runner/flutter_command.dart'; import 'build.dart'; class BuildBundleCommand extends BuildSubCommand { BuildBundleCommand({ - required super.logger, - bool verboseHelp = false, + required this.buildSystem, + required this.featureFlags, + required ToolContext toolContext, + required super.verboseHelp, BundleBuilder? bundleBuilder, }) : _bundleBuilder = bundleBuilder ?? BundleBuilder(), - super(verboseHelp: verboseHelp) { + super( + logger: toolContext.logger, + outputPreferences: toolContext.outputPreferences, + toolContext: toolContext, + ) { usesTargetOption(); usesFilesystemOptions(hide: !verboseHelp); usesBuildNumberOption(); @@ -30,7 +38,10 @@ class BuildBundleCommand extends BuildSubCommand { argParser ..addOption( 'depfile', - defaultsTo: defaultDepfilePath, + defaultsTo: toolContext.fs.path.join( + getBuildDirectory(toolContext.config, toolContext.fs), + 'snapshot_blob.bin.d', + ), help: 'A file path where a depfile will be written. ' 'This contains all build inputs and outputs in a Make-style syntax.', @@ -54,7 +65,7 @@ class BuildBundleCommand extends BuildSubCommand { ) ..addOption( 'asset-dir', - defaultsTo: getAssetBuildDirectory(), + defaultsTo: getAssetBuildDirectory(toolContext.config, toolContext.fs), help: 'The output directory for the kernel_blob.bin file, the native snapshot, the assets, etc. ' 'Can be used to redirect the output when driving the Flutter toolchain from another build system.', @@ -68,7 +79,15 @@ class BuildBundleCommand extends BuildSubCommand { usesTrackWidgetCreation(verboseHelp: verboseHelp); } + final BuildSystem buildSystem; final BundleBuilder _bundleBuilder; + final FeatureFlags featureFlags; + + @visibleForTesting + BundleBuilder get bundleBuilder => _bundleBuilder; + + @override + ToolContext get toolContext => super.toolContext!; @override final name = 'bundle'; @@ -84,10 +103,9 @@ class BuildBundleCommand extends BuildSubCommand { @override Future unifiedAnalyticsUsageValues(String commandPath) async { - final String projectDir = globals.fs.file(targetFile).parent.parent.path; - final FlutterProject flutterProject = FlutterProject.fromDirectory( - globals.fs.directory(projectDir), - ); + final ToolContext(:FileSystem fs, :FlutterProjectFactory projectFactory) = toolContext; + final String projectDir = fs.file(targetFile).parent.parent.path; + final FlutterProject flutterProject = projectFactory.fromDirectory(fs.directory(projectDir)); return Event.commandUsageValues( workflow: commandPath, commandHasTerminal: hasTerminal, @@ -111,44 +129,34 @@ class BuildBundleCommand extends BuildSubCommand { final String targetPlatform = stringArg('target-platform')!; final platform = TargetPlatform.fromName(targetPlatform); // Check for target platforms that are only allowed via feature flags. - switch (platform) { - case TargetPlatform.darwin: + switch (platform.os) { + case .macos: if (!featureFlags.isMacOSEnabled) { throwToolExit('macOS is not a supported target platform.'); } - case TargetPlatform.windows_x64: - case TargetPlatform.windows_arm64: + case .windows: if (!featureFlags.isWindowsEnabled) { throwToolExit('Windows is not a supported target platform.'); } - case TargetPlatform.linux_x64: - case TargetPlatform.linux_arm64: - case TargetPlatform.linux_riscv64: + case .linux: if (!featureFlags.isLinuxEnabled) { throwToolExit('Linux is not a supported target platform.'); } - case TargetPlatform.android: - case TargetPlatform.android_arm: - case TargetPlatform.android_arm64: - case TargetPlatform.android_x64: - case TargetPlatform.fuchsia_arm64: - case TargetPlatform.fuchsia_x64: - case TargetPlatform.ios: - case TargetPlatform.tester: - case TargetPlatform.web_javascript: + case .android || .fuchsia || .ios || .tester || .web: break; - case TargetPlatform.unsupported: + case .unsupported: TargetPlatform.throwUnsupportedTarget(); } final BuildInfo buildInfo = await getBuildInfo(); await _bundleBuilder.build( - platform: platform, buildInfo: buildInfo, - mainPath: targetFile, - depfilePath: stringArg('depfile'), + platform: platform, assetDirPath: stringArg('asset-dir'), + buildSystem: buildSystem, + depfilePath: stringArg('depfile'), + mainPath: targetFile, ); return FlutterCommandResult.success(); } diff --git a/packages/flutter_tools/lib/src/commands/build_ios.dart b/packages/flutter_tools/lib/src/commands/build_ios.dart index 5131df96..6e1e4fb5 100644 --- a/packages/flutter_tools/lib/src/commands/build_ios.dart +++ b/packages/flutter_tools/lib/src/commands/build_ios.dart @@ -5,52 +5,61 @@ import 'dart:typed_data'; import 'package:crypto/crypto.dart'; +import 'package:flutter_tools_core/flutter_tools_core.dart' hide BuildMode; import 'package:meta/meta.dart'; import 'package:unified_analytics/unified_analytics.dart'; +import '../artifacts.dart'; import '../base/analyze_size.dart'; import '../base/common.dart'; import '../base/error_handling_io.dart'; import '../base/file_system.dart'; import '../base/logger.dart'; +import '../base/os.dart'; +import '../base/platform.dart'; import '../base/process.dart'; import '../base/terminal.dart'; import '../base/utils.dart'; import '../base/version.dart'; import '../build_info.dart'; +import '../build_system/build_system.dart'; +import '../context/apple_context.dart'; +import '../context/tool_context.dart'; import '../convert.dart'; import '../darwin/darwin.dart'; import '../doctor_validator.dart'; -import '../globals.dart' as globals; import '../ios/application_package.dart'; import '../ios/code_signing.dart'; import '../ios/mac.dart'; import '../ios/plist_parser.dart'; +import '../macos/xcode.dart'; import '../runner/flutter_command.dart'; +import '../runner/flutter_command_runner.dart'; import 'build.dart'; /// Builds an .app for an iOS app to be used for local testing on an iOS device /// or simulator. Can only be run on a macOS host. class BuildIOSCommand extends _BuildIOSSubCommand { - BuildIOSCommand({required super.logger, required bool verboseHelp}) - : super(verboseHelp: verboseHelp) { - addPublishPort(verboseHelp: verboseHelp); - argParser - ..addFlag( - 'config-only', - help: - 'Update the project configuration without performing a build. ' - 'This can be used in CI/CD process that create an archive to avoid ' - 'performing duplicate work.', - ) - ..addFlag( - 'simulator', - help: - 'Build for the iOS simulator instead of the device. This changes ' - 'the default build mode to debug if otherwise unspecified.', - ); + BuildIOSCommand({ + required super.appleContext, + required super.buildSystem, + required super.toolContext, + required super.verboseHelp, + }) { + argParser.addDescriptors(const >[ + DebuggingOptionDescriptors.publishPort, + AppleBuildOptionsBundle.configOnly, + _simulator, + ], verboseHelp: verboseHelp); } + static const _simulator = FlagOptionDescriptor( + name: 'simulator', + help: + 'Build for the iOS simulator instead of the device. This changes ' + 'the default build mode to debug if otherwise unspecified.', + ); + @override final name = 'ios'; @@ -62,14 +71,14 @@ class BuildIOSCommand extends _BuildIOSSubCommand { @override EnvironmentType get environmentType => - boolArg('simulator') ? EnvironmentType.simulator : EnvironmentType.physical; + getValue(_simulator) ? EnvironmentType.simulator : EnvironmentType.physical; @override - bool get configOnly => boolArg('config-only'); + bool get configOnly => getValue(AppleBuildOptionsBundle.configOnly); @override Directory _outputAppDirectory(String xcodeResultOutput) => - globals.fs.directory(xcodeResultOutput).parent; + _toolContext.fs.directory(xcodeResultOutput).parent; } /// The key that uniquely identifies an image file in an image asset. @@ -108,30 +117,37 @@ class _ImageAssetFileKey { /// /// Can only be run on a macOS host. class BuildIOSArchiveCommand extends _BuildIOSSubCommand { - BuildIOSArchiveCommand({required super.logger, required super.verboseHelp}) { - argParser.addOption( - 'export-method', - defaultsTo: 'app-store', - allowed: ['app-store', 'ad-hoc', 'development', 'enterprise'], - help: 'Specify how the IPA will be distributed.', - allowedHelp: { - 'app-store': 'Upload to the App Store.', - 'ad-hoc': - 'Test on designated devices that do not need to be registered with the Apple developer account. ' - 'Requires a distribution certificate.', - 'development': - 'Test only on development devices registered with the Apple developer account.', - 'enterprise': 'Distribute an app registered with the Apple Developer Enterprise Program.', - }, - ); - argParser.addOption( - 'export-options-plist', - valueHelp: 'ExportOptions.plist', - help: - 'Export an IPA with these options. See "xcodebuild -h" for available exportOptionsPlist keys.', - ); + BuildIOSArchiveCommand({ + required super.appleContext, + required super.buildSystem, + required super.toolContext, + required super.verboseHelp, + }) { + argParser.addDescriptors(const >[_exportMethod, _exportOptionsPlist]); } + static const _exportMethod = DefaultedStringOptionDescriptor( + name: 'export-method', + defaultsTo: 'app-store', + allowed: ['app-store', 'ad-hoc', 'development', 'enterprise'], + help: 'Specify how the IPA will be distributed.', + allowedHelp: { + 'app-store': 'Upload to the App Store.', + 'ad-hoc': + 'Test on designated devices that do not need to be registered with the Apple developer account. ' + 'Requires a distribution certificate.', + 'development': + 'Test only on development devices registered with the Apple developer account.', + 'enterprise': 'Distribute an app registered with the Apple Developer Enterprise Program.', + }, + ); + + static const _exportOptionsPlist = StringOptionDescriptor( + name: 'export-options-plist', + valueHelp: 'ExportOptions.plist', + help: 'Export an IPA with these options. See "xcodebuild -h" for available exportOptionsPlist keys.', + ); + @override final name = 'ipa'; @@ -150,26 +166,27 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand { @override final configOnly = false; - String? get exportOptionsPlist => stringArg('export-options-plist'); + String? get exportOptionsPlist => getValue(_exportOptionsPlist); @override - Directory _outputAppDirectory(String xcodeResultOutput) => globals.fs + Directory _outputAppDirectory(String xcodeResultOutput) => _toolContext.fs .directory(xcodeResultOutput) .childDirectory('Products') .childDirectory('Applications'); @override Future validateCommand() async { + final FileSystem fs = _toolContext.fs; final String? exportOptions = exportOptionsPlist; if (exportOptions != null) { - if (argResults?.wasParsed('export-method') ?? false) { + if (wasParsed(_exportMethod)) { throwToolExit( '"--export-options-plist" is not compatible with "--export-method". Either use "--export-options-plist" and ' 'a plist describing how the IPA should be exported by Xcode, or use "--export-method" to create a new plist.\n' 'See "xcodebuild -h" for available exportOptionsPlist keys.', ); } - final FileSystemEntityType type = globals.fs.typeSync(exportOptions); + final FileSystemEntityType type = fs.typeSync(exportOptions); if (type == FileSystemEntityType.notFound) { throwToolExit('"$exportOptions" property list does not exist.'); } else if (type != FileSystemEntityType.file) { @@ -186,7 +203,7 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand { String contentsJsonDirName, { required bool requiresSize, }) { - final Directory contentsJsonDirectory = globals.fs.directory(contentsJsonDirName); + final Directory contentsJsonDirectory = _toolContext.fs.directory(contentsJsonDirName); if (!contentsJsonDirectory.existsSync()) { return <_ImageAssetFileKey, String>{}; } @@ -251,18 +268,15 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand { required String templateImageDirName, required String projectImageDirName, }) { + final FileSystem fs = _toolContext.fs; return projectImageInfoMap.entries.any((MapEntry<_ImageAssetFileKey, String> entry) { final String projectFileName = entry.value; final String? templateFileName = templateImageInfoMap[entry.key]; if (templateFileName == null) { return false; } - final File projectFile = globals.fs.file( - globals.fs.path.join(projectImageDirName, projectFileName), - ); - final File templateFile = globals.fs.file( - globals.fs.path.join(templateImageDirName, templateFileName), - ); + final File projectFile = fs.file(fs.path.join(projectImageDirName, projectFileName)); + final File templateFile = fs.file(fs.path.join(templateImageDirName, templateFileName)); return projectFile.existsSync() && templateFile.existsSync() && @@ -276,10 +290,11 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand { required Map<_ImageAssetFileKey, String> imageInfoMap, required String imageDirName, }) { + final FileSystem fs = _toolContext.fs; return imageInfoMap.entries .where((MapEntry<_ImageAssetFileKey, String> entry) { final String fileName = entry.value; - final File imageFile = globals.fs.file(globals.fs.path.join(imageDirName, fileName)); + final File imageFile = fs.file(fs.path.join(imageDirName, fileName)); if (!imageFile.existsSync()) { return false; } @@ -390,8 +405,7 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand { validationMessages.add( _createValidationMessage( isValid: false, - message: - 'Launch image is set to the default placeholder icon. Replace with unique launch image.', + message: 'Launch image is set to the default placeholder icon. Replace with unique launch image.', ), ); } @@ -403,33 +417,32 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand { final BuildableIOSApp app = await buildableIOSApp; final String plistPath = app.builtInfoPlistPathAfterArchive; + final ToolContext(:FileSystem fs, :Logger logger) = _toolContext; + final AppleContext(:PlistParser plistParser) = _appleContext; - if (!globals.fs.file(plistPath).existsSync()) { - globals.printError('Invalid iOS archive. Does not contain Info.plist.'); + if (!fs.file(plistPath).existsSync()) { + logger.printError('Invalid iOS archive. Does not contain Info.plist.'); return []; } final xcodeProjectSettingsMap = {}; - xcodeProjectSettingsMap['Version Number'] = globals.plistParser.getValueFromFile( + xcodeProjectSettingsMap['Version Number'] = plistParser.getValueFromFile( plistPath, PlistParser.kCFBundleShortVersionStringKey, ); - xcodeProjectSettingsMap['Build Number'] = globals.plistParser.getValueFromFile( + xcodeProjectSettingsMap['Build Number'] = plistParser.getValueFromFile( plistPath, PlistParser.kCFBundleVersionKey, ); xcodeProjectSettingsMap['Display Name'] = - globals.plistParser.getValueFromFile( - plistPath, - PlistParser.kCFBundleDisplayNameKey, - ) ?? - globals.plistParser.getValueFromFile(plistPath, PlistParser.kCFBundleNameKey); - xcodeProjectSettingsMap['Deployment Target'] = globals.plistParser.getValueFromFile( + plistParser.getValueFromFile(plistPath, PlistParser.kCFBundleDisplayNameKey) ?? + plistParser.getValueFromFile(plistPath, PlistParser.kCFBundleNameKey); + xcodeProjectSettingsMap['Deployment Target'] = plistParser.getValueFromFile( plistPath, PlistParser.kMinimumOSVersionKey, ); - xcodeProjectSettingsMap['Bundle Identifier'] = globals.plistParser.getValueFromFile( + xcodeProjectSettingsMap['Bundle Identifier'] = plistParser.getValueFromFile( plistPath, PlistParser.kCFBundleIdentifierKey, ); @@ -473,6 +486,14 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand { @override Future runCommand() async { + final ToolContext( + :FileSystem fs, + :OperatingSystemUtils os, + :ProcessUtils processUtils, + :Terminal terminal, + ) = _toolContext; + final AppleContext(:PlistParser plistParser, :Xcode xcode) = _appleContext; + final BuildInfo buildInfo = await cachedBuildInfo; final FlutterCommandResult xcarchiveResult = await super.runCommand(); @@ -491,26 +512,26 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand { ); for (final ValidationResult result in validationResults.whereType()) { - globals.printStatus('\n${result.coloredLeadingBox} ${result.statusInfo}'); + logger.printStatus('\n${result.coloredLeadingBox(terminal)} ${result.statusInfo}'); for (final ValidationMessage message in result.messages) { - globals.printStatus( - '${message.coloredIndicator} ${message.message}', + logger.printStatus( + '${message.coloredIndicator(terminal)} ${message.message}', indent: result.leadingBox.length + 1, ); } } - globals.printStatus( + logger.printStatus( '\nTo update the settings, please refer to https://flutter.dev/to/ios-deploy\n', ); // xcarchive failed or not at expected location. if (xcarchiveResult.exitStatus != ExitStatus.success) { - globals.printStatus('Skipping IPA.'); + logger.printStatus('Skipping IPA.'); return xcarchiveResult; } if (!shouldCodesign) { - globals.printStatus('Codesigning disabled with --no-codesign, skipping IPA.'); + logger.printStatus('Codesigning disabled with --no-codesign, skipping IPA.'); return xcarchiveResult; } @@ -519,47 +540,41 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand { Status? status; RunResult? result; final String relativeOutputPath = app.ipaOutputPath; - final String absoluteOutputPath = globals.fs.path.absolute(relativeOutputPath); - final String absoluteArchivePath = globals.fs.path.absolute(app.archiveBundleOutputPath); + final String absoluteOutputPath = fs.path.absolute(relativeOutputPath); + final String absoluteArchivePath = fs.path.absolute(app.archiveBundleOutputPath); String? exportOptions = exportOptionsPlist; String? exportMethod = exportOptions != null - ? globals.plistParser.getValueFromFile(exportOptions, 'method') + ? plistParser.getValueFromFile(exportOptions, 'method') : null; - exportMethod ??= _getVersionAppropriateExportMethod(stringArg('export-method')!); + exportMethod ??= _getVersionAppropriateExportMethod(getValue(_exportMethod)); final bool isAppStoreUpload = exportMethod == 'app-store' || exportMethod == 'app-store-connect'; File? generatedExportPlist; try { final String exportMethodDisplayName = isAppStoreUpload ? 'App Store' : exportMethod; - status = globals.logger.startProgress('Building $exportMethodDisplayName IPA...'); + status = logger.startProgress('Building $exportMethodDisplayName IPA...'); if (exportOptions == null) { final Map? buildSettings = await app.project.buildSettingsForBuildInfo( buildInfo, ); // Create XcodeCodeSigningSettings for dependency injection into createExportPlist - final codeSigningSettings = XcodeCodeSigningSettings( - config: globals.config, - logger: logger, - platform: globals.platform, - processUtils: globals.processUtils, - fileSystem: globals.fs, - fileSystemUtils: globals.fsUtils, - terminal: globals.terminal, - plistParser: globals.plistParser, + final codeSigningSettings = XcodeCodeSigningSettings.fromContexts( + appleContext: _appleContext, + toolContext: _toolContext, ); generatedExportPlist = await createExportPlist( exportMethod: exportMethod, app: app, buildInfo: buildInfo, buildSettings: buildSettings, - fileSystem: globals.fs, + fileSystem: fs, codeSigningSettings: codeSigningSettings, ); exportOptions = generatedExportPlist.path; } - result = await globals.processUtils.run([ - ...globals.xcode!.xcrunCommand(), + result = await processUtils.run([ + ...xcode.xcrunCommand(), 'xcodebuild', '-exportArchive', if (shouldCodesign) ...[ @@ -571,7 +586,7 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand { '-exportPath', absoluteOutputPath, '-exportOptionsPlist', - globals.fs.path.absolute(exportOptions), + fs.path.absolute(exportOptions), ]); } finally { if (generatedExportPlist != null) { @@ -588,19 +603,19 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand { // Example: // error: exportArchive: exportOptionsPlist error for key 'method': expected one of {app-store, ad-hoc, enterprise, development, validation}, but found developmentasdasd // Error Domain=IDEFoundationErrorDomain Code=1 "exportOptionsPlist error for key 'method': expected one of {app-store, ad-hoc, enterprise, development, validation}, but found developmentasdasd" ... - LineSplitter.split( - result.stderr, - ).where((String line) => line.contains('error: ')).forEach(errorMessage.writeln); + LineSplitter.split(result.stderr) + .where((String line) => line.contains('error: ')) + .forEach(errorMessage.writeln); - globals.printError('Encountered error while creating the IPA:'); - globals.printError(errorMessage.toString()); + logger.printError('Encountered error while creating the IPA:'); + logger.printError(errorMessage.toString()); - final FileSystemEntityType type = globals.fs.typeSync(absoluteArchivePath); - globals.printError('Try distributing the app in Xcode:'); + final FileSystemEntityType type = fs.typeSync(absoluteArchivePath); + logger.printError('Try distributing the app in Xcode:'); if (type == FileSystemEntityType.notFound) { - globals.printError('open ios/Runner.xcworkspace', indent: 2); + logger.printError('open ios/Runner.xcworkspace', indent: 2); } else { - globals.printError('open $absoluteArchivePath', indent: 2); + logger.printError('open $absoluteArchivePath', indent: 2); } // Even though the IPA step didn't succeed, the xcarchive did. @@ -609,29 +624,29 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand { return FlutterCommandResult.success(); } - final Directory outputDirectory = globals.fs.directory(absoluteOutputPath); - final int? directorySize = globals.os.getDirectorySize(outputDirectory); + final Directory outputDirectory = fs.directory(absoluteOutputPath); + final int? directorySize = os.getDirectorySize(outputDirectory); final appSize = (buildInfo.mode == BuildMode.debug || directorySize == null) ? '' // Don't display the size when building a debug variant. : ' (${getSizeAsPlatformMB(directorySize)})'; - globals.printStatus( - '${globals.terminal.successMark} ' - 'Built IPA to ${globals.fs.path.relative(outputDirectory.path)}$appSize', + logger.printStatus( + '${terminal.successMark} ' + 'Built IPA to ${fs.path.relative(outputDirectory.path)}$appSize', color: TerminalColor.green, ); if (isAppStoreUpload) { - globals.printStatus('To upload to the App Store either:'); - globals.printStatus( + logger.printStatus('To upload to the App Store either:'); + logger.printStatus( '1. Drag and drop the "$relativeOutputPath/*.ipa" bundle into the Apple Transporter macOS app https://apps.apple.com/us/app/transporter/id1450874784', indent: 4, ); - globals.printStatus( + logger.printStatus( '2. Run "xcrun altool --upload-app --type ios -f $relativeOutputPath/*.ipa --apiKey your_api_key --apiIssuer your_issuer_id".', indent: 4, ); - globals.printStatus( + logger.printStatus( 'See "man altool" for details about how to authenticate with the App Store Connect API key.', indent: 7, ); @@ -689,7 +704,7 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand { profileSpecifier, codeSigningSettings: codeSigningSettings, fileSystem: fileSystem, - fileSystemUtils: fileSystemUtils ?? globals.fsUtils, + fileSystemUtils: fileSystemUtils ?? _toolContext.fileSystemUtils, ); if (profileUuid != null) { @@ -835,15 +850,9 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand { // Use provided or create new XcodeCodeSigningSettings instance final XcodeCodeSigningSettings settings = codeSigningSettings ?? - XcodeCodeSigningSettings( - config: globals.config, - logger: logger, - platform: globals.platform, - processUtils: globals.processUtils, - fileSystem: globals.fs, - fileSystemUtils: globals.fsUtils, - terminal: globals.terminal, - plistParser: globals.plistParser, + XcodeCodeSigningSettings.fromContexts( + appleContext: _appleContext, + toolContext: _toolContext, ); // Search for profiles matching the specifier (could be name or UUID) @@ -873,7 +882,7 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand { // are now deprecated. The new equivalents are 'app-store-connect', 'release-testing', // and 'debugging'. String _getVersionAppropriateExportMethod(String method) { - final Version? currVersion = globals.xcode!.currentVersion; + final Version? currVersion = _appleContext.xcode.currentVersion; if (currVersion != null) { if (currVersion >= Version(15, 4, 0)) { switch (method) { @@ -892,29 +901,42 @@ class BuildIOSArchiveCommand extends _BuildIOSSubCommand { } abstract class _BuildIOSSubCommand extends BuildSubCommand { - _BuildIOSSubCommand({required super.logger, required bool verboseHelp}) - : super(verboseHelp: verboseHelp) { - addTreeShakeIconsFlag(); - addSplitDebugInfoOption(); - addBuildModeFlags(verboseHelp: verboseHelp); - usesTargetOption(); - usesFlavorOption(); - usesPubOption(); - usesBuildNumberOption(); - usesBuildNameOption(); - addDartObfuscationOption(); - usesDartDefineOption(); - usesExtraDartFlagOptions(verboseHelp: verboseHelp); - addEnableExperimentation(hide: !verboseHelp); - addBuildPerformanceFile(hide: !verboseHelp); - usesAnalyzeSizeFlag(); - argParser.addFlag( - 'codesign', - defaultsTo: true, - help: 'Codesign the application bundle (only available on device builds).', - ); + _BuildIOSSubCommand({ + required this._appleContext, + required this._buildSystem, + required ToolContext super.toolContext, + required super.verboseHelp, + }) : _toolContext = toolContext, + super(logger: toolContext.logger) { + registerOptionBundles(const [ + CommonBuildOptionsBundle(), + BuildModeOptionsBundle(), + DartCompileOptionsBundle(), + AppleBuildOptionsBundle(), + ]); + argParser.addDescriptor(_codesign); } + static const _codesign = FlagOptionDescriptor( + name: 'codesign', + defaultsTo: true, + help: 'Codesign the application bundle (only available on device builds).', + ); + + final AppleContext _appleContext; + final BuildSystem _buildSystem; + final ToolContext _toolContext; + + @visibleForTesting + AppleContext get appleContext => _appleContext; + + @visibleForTesting + BuildSystem get buildSystem => _buildSystem; + + @visibleForTesting + @override + ToolContext get toolContext => _toolContext; + @override Future> get requiredArtifacts async => const { DevelopmentArtifact.iOS, @@ -929,17 +951,15 @@ abstract class _BuildIOSSubCommand extends BuildSubCommand { EnvironmentType get environmentType; bool get configOnly; - bool get shouldCodesign => boolArg('codesign'); + bool get shouldCodesign => getValue(_codesign); late final Future cachedBuildInfo = getBuildInfo(); late final Future buildableIOSApp = () async { - final app = - await applicationPackages?.getPackageForPlatform( - TargetPlatform.ios, - buildInfo: await cachedBuildInfo, - ) - as BuildableIOSApp?; + final app = await applicationPackages?.getPackageForPlatform( + TargetPlatform.ios, + buildInfo: await cachedBuildInfo, + ) as BuildableIOSApp?; if (app == null) { throwToolExit('Application not configured for iOS'); @@ -950,10 +970,21 @@ abstract class _BuildIOSSubCommand extends BuildSubCommand { Directory _outputAppDirectory(String xcodeResultOutput); @override - bool get supported => globals.platform.isMacOS; + bool get supported => _toolContext.platform.isMacOS; @override Future runCommand() async { + final ToolContext( + :Artifacts artifacts, + fileSystemUtils: FileSystemUtils fsUtils, + :FileSystem fs, + :Logger logger, + :OperatingSystemUtils os, + :Platform platform, + :Terminal terminal, + ) = _toolContext; + final AppleContext(:PlistParser plistParser) = _appleContext; + defaultBuildMode = environmentType == EnvironmentType.simulator ? BuildMode.debug : BuildMode.release; @@ -969,7 +1000,7 @@ abstract class _BuildIOSSubCommand extends BuildSubCommand { throwToolExit('Cannot analyze code size without performing a full build.'); } if (environmentType == EnvironmentType.physical && !shouldCodesign) { - globals.printStatus( + logger.printStatus( 'Warning: Building for device with codesigning disabled. You will ' 'have to manually codesign before deploying to device.', ); @@ -978,11 +1009,14 @@ abstract class _BuildIOSSubCommand extends BuildSubCommand { final BuildableIOSApp app = await buildableIOSApp; final logTarget = environmentType == EnvironmentType.simulator ? 'simulator' : 'device'; - final String typeName = globals.artifacts!.getEngineType(TargetPlatform.ios, buildInfo.mode); - globals.printStatus(switch (xcodeBuildAction) { + final String typeName = artifacts.getEngineType(TargetPlatform.ios, buildInfo.mode); + logger.printStatus(switch (xcodeBuildAction) { XcodeBuildAction.build => 'Building $app for $logTarget ($typeName)...', XcodeBuildAction.archive => 'Archiving $app...', }); + final String? specifiedDeviceId = + globalResults?[FlutterGlobalOptions.kDeviceIdOption] as String? ?? + _toolContext.platform.environment['FLUTTER_DEVICE_ID']; final XcodeBuildResult result = await buildXcodeProject( app: app, buildInfo: buildInfo, @@ -991,7 +1025,7 @@ abstract class _BuildIOSSubCommand extends BuildSubCommand { codesign: shouldCodesign, configOnly: configOnly, buildAction: xcodeBuildAction, - deviceID: globals.deviceManager?.specifiedDeviceId, + deviceID: specifiedDeviceId, disablePortPublication: usingCISystem && xcodeBuildAction == XcodeBuildAction.build && @@ -1002,9 +1036,9 @@ abstract class _BuildIOSSubCommand extends BuildSubCommand { if (!result.success) { await diagnoseXcodeBuildFailure( result, - analytics: globals.analytics, - fileSystem: globals.fs, - logger: globals.logger, + analytics: analytics, + fileSystem: fs, + logger: logger, platform: FlutterDarwinPlatform.ios, project: app.project.parent, ); @@ -1016,17 +1050,17 @@ abstract class _BuildIOSSubCommand extends BuildSubCommand { if (buildInfo.codeSizeDirectory != null) { final sizeAnalyzer = SizeAnalyzer( - fileSystem: globals.fs, - logger: globals.logger, + fileSystem: fs, + logger: logger, analytics: analytics, appFilenamePattern: 'App', ); // Only support 64bit iOS code size analysis. final String arch = CpuArch.arm64.darwinArchName; - final File aotSnapshot = globals.fs + final File aotSnapshot = fs .directory(buildInfo.codeSizeDirectory) .childFile('snapshot.$arch.json'); - final File precompilerTrace = globals.fs + final File precompilerTrace = fs .directory(buildInfo.codeSizeDirectory) .childFile('trace.$arch.json'); @@ -1041,8 +1075,8 @@ abstract class _BuildIOSSubCommand extends BuildSubCommand { appDirectory = outputAppDirectoryCandidate.listSync().whereType().where(( Directory directory, ) { - return globals.fs.path.extension(directory.path) == '.app'; - }).first; + return fs.path.extension(directory.path) == '.app'; + }).firstOrNull; } if (appDirectory == null) { throwToolExit( @@ -1055,32 +1089,32 @@ abstract class _BuildIOSSubCommand extends BuildSubCommand { outputDirectory: appDirectory, type: 'ios', ); - final File outputFile = globals.fsUtils.getUniqueFile( - globals.fs.directory(globals.fsUtils.homeDirPath).childDirectory('.flutter-devtools'), + final File outputFile = fsUtils.getUniqueFile( + fs.directory(fsUtils.homeDirPath).childDirectory('.flutter-devtools'), 'ios-code-size-analysis', 'json', )..writeAsStringSync(jsonEncode(output)); // This message is used as a sentinel in analyze_apk_size_test.dart - globals.printStatus( + logger.printStatus( 'A summary of your iOS bundle analysis can be found at: ${outputFile.path}', ); - globals.printStatus( + logger.printStatus( '\nTo analyze your app size in Dart DevTools, run the following command:\n' 'dart devtools --appSizeBase=${outputFile.path}', ); } if (result.output != null) { - final Directory outputDirectory = globals.fs.directory(result.output); - final int? directorySize = globals.os.getDirectorySize(outputDirectory); + final Directory outputDirectory = fs.directory(result.output); + final int? directorySize = os.getDirectorySize(outputDirectory); final appSize = (buildInfo.mode == BuildMode.debug || directorySize == null) ? '' // Don't display the size when building a debug variant. : ' (${getSizeAsPlatformMB(directorySize)})'; - globals.printStatus( - '${globals.terminal.successMark} ' - 'Built ${globals.fs.path.relative(outputDirectory.path)}$appSize', + logger.printStatus( + '${terminal.successMark} ' + 'Built ${fs.path.relative(outputDirectory.path)}$appSize', color: TerminalColor.green, ); @@ -1089,7 +1123,7 @@ abstract class _BuildIOSSubCommand extends BuildSubCommand { // flag set as "enabled" because the default is to enable Impeller on iOS. final BuildableIOSApp app = await buildableIOSApp; final String plistPath = app.project.infoPlist.path; - final bool? impellerEnabled = globals.plistParser.getValueFromFile( + final bool? impellerEnabled = plistParser.getValueFromFile( plistPath, PlistParser.kFLTEnableImpellerKey, ); @@ -1097,7 +1131,7 @@ abstract class _BuildIOSSubCommand extends BuildSubCommand { final buildLabel = impellerEnabled == false ? 'plist-impeller-disabled' : 'plist-impeller-enabled'; - globals.analytics.send(Event.flutterBuildInfo(label: buildLabel, buildType: 'ios')); + analytics.send(Event.flutterBuildInfo(label: buildLabel, buildType: 'ios')); return FlutterCommandResult.success(); } diff --git a/packages/flutter_tools/lib/src/commands/build_ios_framework.dart b/packages/flutter_tools/lib/src/commands/build_ios_framework.dart index 432e9519..4bea9833 100644 --- a/packages/flutter_tools/lib/src/commands/build_ios_framework.dart +++ b/packages/flutter_tools/lib/src/commands/build_ios_framework.dart @@ -7,6 +7,7 @@ import 'package:process/process.dart'; import '../artifacts.dart'; import '../base/common.dart'; +import '../base/config.dart'; import '../base/file_system.dart'; import '../base/io.dart'; import '../base/logger.dart'; @@ -16,124 +17,140 @@ import '../build_info.dart'; import '../build_system/build_system.dart'; import '../build_system/targets/ios.dart'; import '../cache.dart'; +import '../context/apple_context.dart'; +import '../context/tool_context.dart'; import '../convert.dart'; import '../darwin/darwin.dart'; import '../flutter_plugins.dart'; -import '../globals.dart' as globals; import '../ios/plist_parser.dart'; import '../ios/xcodeproj.dart'; import '../macos/cocoapod_utils.dart'; -import '../runner/flutter_command.dart' - show DevelopmentArtifact, FlutterCommandResult, FlutterOptions; +import '../macos/xcode.dart'; +import '../project.dart'; +import '../runner/flutter_command.dart'; import '../version.dart'; import 'build.dart'; import 'darwin_add_to_app.dart'; abstract class BuildFrameworkCommand extends BuildSubCommand { BuildFrameworkCommand({ - // Instantiating FlutterVersion kicks off networking, so delay until it's needed, but allow test injection. - @visibleForTesting FlutterVersion? flutterVersion, - required BuildSystem buildSystem, - required bool verboseHelp, - Cache? cache, - Platform? platform, + required this._appleContext, + required this._buildSystem, required this.codesign, - required super.logger, - }) : _injectedFlutterVersion = flutterVersion, - _buildSystem = buildSystem, - _injectedCache = cache, - _injectedPlatform = platform, - super(verboseHelp: verboseHelp) { - addTreeShakeIconsFlag(); - usesTargetOption(); - usesPubOption(); - usesDartDefineOption(); - addSplitDebugInfoOption(); - addDartObfuscationOption(); - usesExtraDartFlagOptions(verboseHelp: verboseHelp); - addEnableExperimentation(hide: !verboseHelp); - usesDarwinCodeSignXCFrameworksOption(); - - argParser - ..addFlag( - 'debug', - defaultsTo: true, - help: - 'Whether to produce a framework for the debug build configuration. ' - 'By default, all build configurations are built.', - ) - ..addFlag( - 'profile', - defaultsTo: true, - help: - 'Whether to produce a framework for the profile build configuration. ' - 'By default, all build configurations are built.', - ) - ..addFlag( - 'release', - defaultsTo: true, - help: - 'Whether to produce a framework for the release build configuration. ' - 'By default, all build configurations are built.', - ) - ..addFlag( - 'cocoapods', - help: - 'Produce a Flutter.podspec instead of an engine Flutter.xcframework (recommended if host app uses CocoaPods).', - ) - ..addFlag( - 'plugins', - defaultsTo: true, - help: - 'Whether to produce frameworks for the plugins. ' - 'This is intended for cases where plugins are already being built separately.', - ) - ..addFlag( - 'static', - help: - 'Build plugins as static frameworks. Link on, but do not embed these frameworks in the existing Xcode project.', - ) - ..addOption( - 'output', - abbr: 'o', - valueHelp: 'path/to/directory/', - help: 'Location to write the frameworks.', - ) - ..addFlag( - 'force', - abbr: 'f', - help: - 'Force Flutter.podspec creation on the master channel. This is only intended for testing the tool itself.', - hide: !verboseHelp, - ); + required ToolContext super.toolContext, + required super.verboseHelp, + }) : _toolContext = toolContext, + super(logger: toolContext.logger) { + registerOptionBundle(const DarwinAddToAppOptionsBundle()); + argParser.addDescriptors(const >[ + debugMode, + profileMode, + releaseMode, + cocoapods, + plugins, + output, + force, + ], verboseHelp: verboseHelp); } + + static const debugMode = FlagOptionDescriptor( + name: 'debug', + defaultsTo: true, + help: + 'Whether to produce a framework for the debug build configuration. ' + 'By default, all build configurations are built.', + ); + + static const profileMode = FlagOptionDescriptor( + name: 'profile', + defaultsTo: true, + help: + 'Whether to produce a framework for the profile build configuration. ' + 'By default, all build configurations are built.', + ); + + static const releaseMode = FlagOptionDescriptor( + name: 'release', + defaultsTo: true, + help: + 'Whether to produce a framework for the release build configuration. ' + 'By default, all build configurations are built.', + ); + + static const cocoapods = FlagOptionDescriptor( + name: 'cocoapods', + help: 'Produce a Flutter.podspec instead of an engine Flutter.xcframework (recommended if host app uses CocoaPods).', + ); + + static const plugins = FlagOptionDescriptor( + name: 'plugins', + defaultsTo: true, + help: + 'Whether to produce frameworks for the plugins. ' + 'This is intended for cases where plugins are already being built separately.', + ); + + static const FlagOptionDescriptor staticFrameworks = DarwinAddToAppOptionsBundle.staticFrameworks; + + static const output = StringOptionDescriptor( + name: 'output', + abbr: 'o', + valueHelp: 'path/to/directory/', + help: 'Location to write the frameworks.', + ); + + static const force = FlagOptionDescriptor( + name: 'force', + abbr: 'f', + verboseOnly: true, + help: 'Force Flutter.podspec creation on the master channel. This is only intended for testing the tool itself.', + ); + final DarwinAddToAppCodesigning codesign; - final BuildSystem? _buildSystem; + final AppleContext _appleContext; + final BuildSystem _buildSystem; + final ToolContext _toolContext; + + AppleContext get appleContext => _appleContext; + + @protected + BuildSystem get buildSystem => _buildSystem; + @protected - BuildSystem get buildSystem => _buildSystem ?? globals.buildSystem; + @override + ToolContext get toolContext => _toolContext; @protected - Cache get cache => _injectedCache ?? globals.cache; - final Cache? _injectedCache; + Cache get cache => _toolContext.cache; @protected - Platform get platform => _injectedPlatform ?? globals.platform; - final Platform? _injectedPlatform; + Platform get platform => _toolContext.platform; - // FlutterVersion.instance kicks off git processing which can sometimes fail, so don't try it until needed. @protected - FlutterVersion get flutterVersion => _injectedFlutterVersion ?? globals.flutterVersion; - final FlutterVersion? _injectedFlutterVersion; + FlutterVersion get flutterVersion => _toolContext.flutterVersion; Future> getBuildInfos() async { return [ - if (boolArg('debug')) await getBuildInfo(forcedBuildMode: BuildMode.debug), - if (boolArg('profile')) await getBuildInfo(forcedBuildMode: BuildMode.profile), - if (boolArg('release')) await getBuildInfo(forcedBuildMode: BuildMode.release), + if (getValue(debugMode)) await getBuildInfo(forcedBuildMode: BuildMode.debug), + if (getValue(profileMode)) await getBuildInfo(forcedBuildMode: BuildMode.profile), + if (getValue(releaseMode)) await getBuildInfo(forcedBuildMode: BuildMode.release), ]; } + @protected + Future getCodesignIdentity({ + required BuildInfo buildInfo, + required Directory outputDirectory, + required XcodeBasedProject xcodeProject, + }) => codesign.getCodesignIdentity( + buildInfo: buildInfo, + codesignEnabled: getValue(BuildInfoOptions.codesign), + codesignIdentityOption: getValue(BuildInfoOptions.codesignIdentity), + identityFile: outputDirectory.childFile('.codesign_identity'), + xcodeProject: xcodeProject, + ); + @override bool get supported => platform.isMacOS; @@ -144,11 +161,11 @@ abstract class BuildFrameworkCommand extends BuildSubCommand { throwToolExit('Building frameworks for iOS is only supported on the Mac.'); } - if ((await getBuildInfos()).isEmpty) { + if (!getValue(debugMode) && !getValue(profileMode) && !getValue(releaseMode)) { throwToolExit('At least one of "--debug" or "--profile", or "--release" is required.'); } - if (!boolArg('plugins') && boolArg('static')) { + if (!getValue(plugins) && getValue(staticFrameworks)) { throwToolExit('--static cannot be used with the --no-plugins flag'); } } @@ -277,20 +294,22 @@ abstract class BuildFrameworkCommand extends BuildSubCommand { Directory hostAppRoot, PlistParser plistParser, ) async { + final ToolContext(:FileSystem fs, :Logger logger) = _toolContext; + final File projectFile = hostAppRoot .childDirectory('Pods') .childDirectory('Pods.xcodeproj') .childFile('project.pbxproj'); if (!projectFile.existsSync()) { - globals.logger.printTrace('Pods.xcodeproj not found, skipping vendored frameworks'); + logger.printTrace('Pods.xcodeproj not found, skipping vendored frameworks'); return; } final List frameworkPaths = parseVendoredFrameworksFromPbxproj( projectFile, plistParser, - globals.logger, + logger, ); if (frameworkPaths.isEmpty) { @@ -301,7 +320,7 @@ abstract class BuildFrameworkCommand extends BuildSubCommand { final Directory podsRoot = hostAppRoot.childDirectory('Pods'); for (final frameworkPath in frameworkPaths) { - final String frameworkName = globals.fs.path.basename(frameworkPath); + final String frameworkName = fs.path.basename(frameworkPath); // Skip Flutter's own frameworks. if (frameworkName == 'Flutter.framework' || @@ -313,17 +332,17 @@ abstract class BuildFrameworkCommand extends BuildSubCommand { // Framework paths from CocoaPods virtual groups (e.g. "Development Pods/[plugin]/Frameworks") // may have a "../../../" prefix. Strip it so the path resolves correctly relative to Pods. - final String absolutePath = globals.fs.path.normalize( - globals.fs.path.join(podsRoot.path, frameworkPath.replaceFirst('../../../', '')), + final String absolutePath = fs.path.normalize( + fs.path.join(podsRoot.path, frameworkPath.replaceFirst('../../../', '')), ); - final Directory frameworkEntity = globals.fs.directory(absolutePath); + final Directory frameworkEntity = fs.directory(absolutePath); if (!frameworkEntity.existsSync()) { - globals.logger.printTrace('Vendored framework not found: $absolutePath'); + logger.printTrace('Vendored framework not found: $absolutePath'); continue; } - final String binaryName = globals.fs.path.basenameWithoutExtension(frameworkName); + final String binaryName = fs.path.basenameWithoutExtension(frameworkName); // Skip if we've already processed this framework name if (processedFrameworks.contains(binaryName)) { @@ -343,7 +362,7 @@ abstract class BuildFrameworkCommand extends BuildSubCommand { } final kind = isXcframework ? 'xcframework' : 'framework'; - globals.logger.printTrace('Copying vendored $kind: $frameworkName'); + logger.printTrace('Copying vendored $kind: $frameworkName'); copyDirectory(frameworkEntity, destination); } } @@ -444,31 +463,33 @@ List parseVendoredFrameworksFromPbxproj( /// managers. class BuildIOSFrameworkCommand extends BuildFrameworkCommand { BuildIOSFrameworkCommand({ - required super.logger, - super.flutterVersion, + required super.appleContext, required super.buildSystem, - required bool verboseHelp, - super.cache, - super.platform, required super.codesign, - }) : super(verboseHelp: verboseHelp) { - usesFlavorOption(); - - argParser - ..addFlag( - 'universal', - help: '(deprecated) Produce universal frameworks that include all valid architectures.', - hide: !verboseHelp, - ) - ..addFlag( - 'xcframework', - help: 'Produce xcframeworks that include all valid architectures.', - negatable: false, - defaultsTo: true, - hide: !verboseHelp, - ); + required super.toolContext, + required super.verboseHelp, + }) { + argParser.addDescriptors(const >[ + BuildInfoOptions.flavor, + _universal, + _xcframework, + ], verboseHelp: verboseHelp); } + static const _universal = FlagOptionDescriptor( + name: 'universal', + verboseOnly: true, + help: '(deprecated) Produce universal frameworks that include all valid architectures.', + ); + + static const _xcframework = FlagOptionDescriptor( + name: 'xcframework', + defaultsTo: true, + negatable: false, + verboseOnly: true, + help: 'Produce xcframeworks that include all valid architectures.', + ); + @override final name = 'ios-framework'; @@ -487,7 +508,7 @@ class BuildIOSFrameworkCommand extends BuildFrameworkCommand { Future validateCommand() async { await super.validateCommand(); - if (boolArg('universal')) { + if (getValue(_universal)) { throwToolExit('--universal has been deprecated, only XCFrameworks are supported.'); } } @@ -497,9 +518,16 @@ class BuildIOSFrameworkCommand extends BuildFrameworkCommand { @override Future runCommand() async { + final ToolContext( + :Config config, + :FileSystem fs, + :Logger logger, + :ProcessManager processManager, + ) = toolContext; + final String outputArgument = - stringArg('output') ?? - globals.fs.path.join(globals.fs.currentDirectory.path, 'build', 'ios', 'framework'); + getValue(BuildFrameworkCommand.output) ?? + fs.path.join(fs.currentDirectory.path, getBuildDirectory(config, fs), 'ios', 'framework'); if (outputArgument.isEmpty) { throwToolExit('--output is required.'); @@ -509,16 +537,14 @@ class BuildIOSFrameworkCommand extends BuildFrameworkCommand { throwToolExit('Project does not support iOS'); } - final Directory outputDirectory = globals.fs.directory( - globals.fs.path.absolute(globals.fs.path.normalize(outputArgument)), + final Directory outputDirectory = fs.directory( + fs.path.absolute(fs.path.normalize(outputArgument)), ); final List buildInfos = await getBuildInfos(); - final String? codesignIdentity = await codesign.getCodesignIdentity( + final String? codesignIdentity = await getCodesignIdentity( buildInfo: buildInfos.first, - codesignEnabled: boolArg(FlutterOptions.kCodesign), - codesignIdentityOption: stringArg(FlutterOptions.kCodesignIdentity), - identityFile: outputDirectory.childFile('.codesign_identity'), + outputDirectory: outputDirectory, xcodeProject: project.ios, ); @@ -535,10 +561,7 @@ class BuildIOSFrameworkCommand extends BuildFrameworkCommand { releaseMode: buildInfo.mode.isRelease, ); - final String? productBundleIdentifier = await project.ios.productBundleIdentifier(buildInfo); - globals.printStatus( - 'Building frameworks for $productBundleIdentifier in ${buildInfo.mode.cliName} mode...', - ); + logger.printStatus('Building frameworks in ${buildInfo.mode.cliName} mode...'); final String xcodeBuildConfiguration = buildInfo.mode.uppercaseName; final Directory modeDirectory = outputDirectory.childDirectory(xcodeBuildConfiguration); @@ -547,8 +570,12 @@ class BuildIOSFrameworkCommand extends BuildFrameworkCommand { modeDirectory.deleteSync(recursive: true); } - if (boolArg('cocoapods')) { - produceFlutterPodspec(buildInfo.mode, modeDirectory, force: boolArg('force')); + if (getValue(BuildFrameworkCommand.cocoapods)) { + produceFlutterPodspec( + buildInfo.mode, + modeDirectory, + force: getValue(BuildFrameworkCommand.force), + ); } else { // Copy Flutter.xcframework. await _produceFlutterFramework(buildInfo, modeDirectory, codesignIdentity); @@ -570,25 +597,27 @@ class BuildIOSFrameworkCommand extends BuildFrameworkCommand { ); // Build and copy plugins. - await processPodsIfNeeded( - project.ios, - getIosBuildDirectory(), - buildInfo.mode, - forceCocoaPodsOnly: true, - ); - if (boolArg('plugins') && hasPlugins(project)) { - await _producePlugins( + if (getValue(BuildFrameworkCommand.plugins)) { + await processPodsIfNeeded( + project.ios, + getIosBuildDirectory(config: config, fileSystem: fs), buildInfo.mode, - xcodeBuildConfiguration, - iPhoneBuildOutput, - simulatorBuildOutput, - modeDirectory, - codesignIdentity, + forceCocoaPodsOnly: true, ); + if (hasPlugins(project)) { + await _producePlugins( + buildInfo.mode, + xcodeBuildConfiguration, + iPhoneBuildOutput, + simulatorBuildOutput, + modeDirectory, + codesignIdentity, + ); + } } - final Status status = globals.logger.startProgress( - ' └─Moving to ${globals.fs.path.relative(modeDirectory.path)}', + final Status status = logger.startProgress( + ' └─Moving to ${fs.path.relative(modeDirectory.path)}', ); // Package native assets. @@ -613,7 +642,7 @@ class BuildIOSFrameworkCommand extends BuildFrameworkCommand { frameworks, frameworkName.replaceAll('.framework', ''), modeDirectory, - globals.processManager, + processManager, codesignIdentity, buildInfo.mode, ); @@ -633,7 +662,7 @@ class BuildIOSFrameworkCommand extends BuildFrameworkCommand { } } - globals.printStatus('Frameworks written to ${outputDirectory.path}.'); + logger.printStatus('Frameworks written to ${outputDirectory.path}.'); if (!project.isModule && hasPlugins(project)) { // Apps do not generate a FlutterPluginRegistrant.framework. Users will need @@ -646,8 +675,8 @@ class BuildIOSFrameworkCommand extends BuildFrameworkCommand { pluginRegistrantImplementation.copySync( outputDirectory.childFile(pluginRegistrantImplementation.basename).path, ); - globals.printStatus( - '\nCopy the ${globals.fs.path.basenameWithoutExtension(pluginRegistrantHeader.path)} class into your project.\n' + logger.printStatus( + '\nCopy the ${fs.path.basenameWithoutExtension(pluginRegistrantHeader.path)} class into your project.\n' 'See https://flutter.dev/to/ios-create-flutter-engine for more information.', ); } @@ -661,7 +690,7 @@ class BuildIOSFrameworkCommand extends BuildFrameworkCommand { if (!lldbInitTargetFile.existsSync()) { // If LLDB is being added to the output, print a warning with instructions on how to add. - globals.printWarning( + logger.printWarning( 'Debugging Flutter on new iOS versions requires an LLDB Init File. To ' 'ensure debug mode works, please complete instructions found in ' '"Embed a Flutter module in your iOS app > Use frameworks > Set LLDB Init File" ' @@ -679,7 +708,7 @@ class BuildIOSFrameworkCommand extends BuildFrameworkCommand { /// vendored framework caching. @visibleForTesting void produceFlutterPodspec(BuildMode mode, Directory modeDirectory, {bool force = false}) { - final Status status = globals.logger.startProgress(' ├─Creating Flutter.podspec...'); + final Status status = logger.startProgress(' ├─Creating Flutter.podspec...'); try { final GitTagVersion gitTagVersion = flutterVersion.gitTagVersion; if (!force && @@ -741,28 +770,30 @@ end Directory modeDirectory, String? codesignIdentity, ) async { - final Status status = globals.logger.startProgress(' ├─Copying Flutter.xcframework...'); - final String engineCacheFlutterFrameworkDirectory = globals.artifacts!.getArtifactPath( + final ToolContext( + :Artifacts artifacts, + :FileSystem fs, + :Logger logger, + :ProcessManager processManager, + ) = toolContext; + + final Status status = logger.startProgress(' ├─Copying Flutter.xcframework...'); + final String engineCacheFlutterFrameworkDirectory = artifacts.getArtifactPath( Artifact.flutterXcframework, platform: TargetPlatform.ios, mode: buildInfo.mode, ); - final String flutterFrameworkFileName = globals.fs.path.basename( - engineCacheFlutterFrameworkDirectory, - ); + final String flutterFrameworkFileName = fs.path.basename(engineCacheFlutterFrameworkDirectory); final Directory flutterFrameworkCopy = modeDirectory.childDirectory(flutterFrameworkFileName); try { // Copy xcframework engine cache framework to mode directory. - copyDirectory( - globals.fs.directory(engineCacheFlutterFrameworkDirectory), - flutterFrameworkCopy, - ); + copyDirectory(fs.directory(engineCacheFlutterFrameworkDirectory), flutterFrameworkCopy); if (codesignIdentity != null) { await DarwinAddToAppCodesigning.codesignFlutterXCFramework( codesignIdentity: codesignIdentity, xcframework: flutterFrameworkCopy, - processManager: globals.processManager, + processManager: processManager, buildMode: buildInfo.mode, ); } @@ -778,8 +809,18 @@ end Directory simulatorBuildOutput, String? codesignIdentity, ) async { + final ToolContext( + :Artifacts artifacts, + :Cache cache, + :FileSystem fs, + :Logger logger, + :Platform platform, + :ProcessManager processManager, + ) = toolContext; + final AppleContext(:Xcode xcode) = appleContext; + const appFrameworkName = 'App.framework'; - final Status status = globals.logger.startProgress(' ├─Building App.xcframework...'); + final Status status = logger.startProgress(' ├─Building App.xcframework...'); final frameworks = []; try { @@ -790,31 +831,29 @@ end }; frameworks.add(outputBuildDirectory.childDirectory(appFrameworkName)); final environment = Environment( - projectDir: globals.fs.currentDirectory, + projectDir: fs.currentDirectory, packageConfigPath: packageConfigPath(), outputDir: outputBuildDirectory, buildDir: project.dartTool.childDirectory('flutter_build'), - cacheDir: globals.cache.getRoot(), - flutterRootDir: globals.fs.directory(Cache.flutterRoot), + cacheDir: cache.getRoot(), + flutterRootDir: fs.directory(Cache.flutterRoot), defines: { kTargetFile: targetFile, kTargetPlatform: TargetPlatform.ios.getName(), kIosArchs: defaultIOSArchsForEnvironment( sdkType, - globals.artifacts!, + artifacts, ).map((CpuArch e) => e.darwinArchName).join(' '), - kSdkRoot: await globals.xcode!.sdkLocation(sdkType), + kSdkRoot: await xcode.sdkLocation(sdkType), ...buildInfo.toBuildSystemEnvironment(), }, - artifacts: globals.artifacts!, - fileSystem: globals.fs, - logger: globals.logger, - processManager: globals.processManager, - platform: globals.platform, - analytics: globals.analytics, - engineVersion: globals.artifacts!.usesLocalArtifacts - ? null - : globals.flutterVersion.engineRevision, + artifacts: artifacts, + fileSystem: fs, + logger: logger, + processManager: processManager, + platform: platform, + analytics: analytics, + engineVersion: artifacts.usesLocalArtifacts ? null : flutterVersion.engineRevision, generateDartPluginRegistry: true, ); Target target; @@ -829,7 +868,7 @@ end final BuildResult result = await buildSystem.build(target, environment); if (!result.success) { for (final ExceptionMeasurement measurement in result.exceptions.values) { - globals.printError(measurement.exception.toString()); + logger.printError(measurement.exception.toString()); } throwToolExit('The App.xcframework build failed.'); } @@ -842,7 +881,7 @@ end frameworks, 'App', outputDirectory, - globals.processManager, + processManager, codesignIdentity, buildInfo.mode, ); @@ -856,10 +895,18 @@ end Directory modeDirectory, String? codesignIdentity, ) async { - final Status status = globals.logger.startProgress(' ├─Building plugins...'); + final ToolContext( + :FileSystem fs, + :Logger logger, + :ProcessManager processManager, + :ProcessUtils processUtils, + ) = toolContext; + final AppleContext(:PlistParser plistParser, :Xcode xcode) = appleContext; + + final Status status = logger.startProgress(' ├─Building plugins...'); try { var pluginsBuildCommand = [ - ...globals.xcode!.xcrunCommand(), + ...xcode.xcrunCommand(), 'xcodebuild', '-alltargets', '-sdk', @@ -869,10 +916,10 @@ end 'SYMROOT=${iPhoneBuildOutput.path}', 'ONLY_ACTIVE_ARCH=NO', // No device targeted, so build all valid architectures. 'BUILD_LIBRARY_FOR_DISTRIBUTION=YES', - if (boolArg('static')) 'MACH_O_TYPE=staticlib', + if (getValue(BuildFrameworkCommand.staticFrameworks)) 'MACH_O_TYPE=staticlib', ]; - RunResult buildPluginsResult = await globals.processUtils.run( + RunResult buildPluginsResult = await processUtils.run( pluginsBuildCommand, workingDirectory: project.ios.hostAppRoot.childDirectory('Pods').path, ); @@ -884,7 +931,7 @@ end // Always build debug for simulator. final String simulatorConfiguration = BuildMode.debug.uppercaseName; pluginsBuildCommand = [ - ...globals.xcode!.xcrunCommand(), + ...xcode.xcrunCommand(), 'xcodebuild', '-alltargets', '-sdk', @@ -894,10 +941,10 @@ end 'SYMROOT=${simulatorBuildOutput.path}', 'ONLY_ACTIVE_ARCH=NO', // No device targeted, so build all valid architectures. 'BUILD_LIBRARY_FOR_DISTRIBUTION=YES', - if (boolArg('static')) 'MACH_O_TYPE=staticlib', + if (getValue(BuildFrameworkCommand.staticFrameworks)) 'MACH_O_TYPE=staticlib', ]; - buildPluginsResult = await globals.processUtils.run( + buildPluginsResult = await processUtils.run( pluginsBuildCommand, workingDirectory: project.ios.hostAppRoot.childDirectory('Pods').path, ); @@ -921,10 +968,10 @@ end for (final builtProduct in products) { for (final FileSystemEntity podProduct in builtProduct.listSync(followLinks: false)) { final String podFrameworkName = podProduct.basename; - if (globals.fs.path.extension(podFrameworkName) != '.framework') { + if (fs.path.extension(podFrameworkName) != '.framework') { continue; } - final String binaryName = globals.fs.path.basenameWithoutExtension(podFrameworkName); + final String binaryName = toolContext.fs.path.basenameWithoutExtension(podFrameworkName); final frameworks = [ podProduct as Directory, @@ -937,7 +984,7 @@ end frameworks, binaryName, modeDirectory, - globals.processManager, + processManager, codesignIdentity, mode, ); @@ -945,7 +992,7 @@ end } // Copy vendored frameworks from CocoaPods plugins. - await copyVendoredFrameworks(modeDirectory, project.ios.hostAppRoot, globals.plistParser); + await copyVendoredFrameworks(modeDirectory, project.ios.hostAppRoot, plistParser); } finally { status.stop(); } diff --git a/packages/flutter_tools/lib/src/commands/build_linux.dart b/packages/flutter_tools/lib/src/commands/build_linux.dart index fc8f602e..b9d82a9d 100644 --- a/packages/flutter_tools/lib/src/commands/build_linux.dart +++ b/packages/flutter_tools/lib/src/commands/build_linux.dart @@ -2,60 +2,80 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:meta/meta.dart'; + import '../base/analyze_size.dart'; import '../base/common.dart'; +import '../base/file_system.dart'; import '../base/logger.dart'; import '../base/os.dart'; +import '../base/platform.dart'; import '../build_info.dart'; -import '../cache.dart'; +import '../build_system/build_system.dart'; +import '../context/tool_context.dart'; import '../features.dart'; -import '../globals.dart' as globals; import '../linux/build_linux.dart'; -import '../runner/flutter_command.dart' show FlutterCommandResult; +import '../runner/flutter_command.dart'; import 'build.dart'; /// A command to build a linux desktop target through a build shell script. class BuildLinuxCommand extends BuildSubCommand { BuildLinuxCommand({ - required super.logger, - required OperatingSystemUtils operatingSystemUtils, - bool verboseHelp = false, - }) : _operatingSystemUtils = operatingSystemUtils, - super(verboseHelp: verboseHelp) { - addCommonDesktopBuildOptions(verboseHelp: verboseHelp); - usesFlavorOption(); - final String defaultTargetPlatform = switch (_operatingSystemUtils.hostPlatform) { - HostPlatform.linux_arm64 => 'linux-arm64', - HostPlatform.linux_riscv64 => 'linux-riscv64', - _ => 'linux-x64', + required this.buildSystem, + required ToolContext super.toolContext, + required super.verboseHelp, + required this._featureFlags, + }) : super(logger: toolContext.logger, outputPreferences: toolContext.outputPreferences) { + final OperatingSystemUtils os = toolContext.os; + final TargetPlatform defaultTargetPlatform = switch (os.hostPlatform) { + HostPlatform.linux_arm64 => TargetPlatform.linux_arm64, + HostPlatform.linux_riscv64 => TargetPlatform.linux_riscv64, + _ => TargetPlatform.linux_x64, }; - argParser.addOption( - 'target-platform', + _targetPlatformOption = DefaultedEnumOptionDescriptor( + name: 'target-platform', defaultsTo: defaultTargetPlatform, - allowed: ['linux-arm64', 'linux-x64', 'linux-riscv64'], + values: const [ + TargetPlatform.linux_arm64, + TargetPlatform.linux_x64, + TargetPlatform.linux_riscv64, + ], + nameMapper: (TargetPlatform platform) => platform.getName(), + valueParser: TargetPlatform.fromName, help: 'The target platform for which the app is compiled.', ); - argParser.addOption( - 'target-sysroot', - defaultsTo: '/', - help: - 'The root filesystem path of target platform for which ' - 'the app is compiled. This option is valid only ' - 'if the current host and target architectures are different.', - ); - argParser.addFlag( - 'config-only', - help: 'Update the project configuration without performing a build.', - ); + registerOptionBundles(const [DesktopBuildOptionsBundle()]); + argParser.addDescriptors(>[ + _targetPlatformOption, + _targetSysroot, + ], verboseHelp: verboseHelp); } - final OperatingSystemUtils _operatingSystemUtils; + late final DefaultedEnumOptionDescriptor _targetPlatformOption; + + static const _targetSysroot = DefaultedStringOptionDescriptor( + name: 'target-sysroot', + defaultsTo: '/', + help: + 'The root filesystem path of target platform for which ' + 'the app is compiled. This option is valid only ' + 'if the current host and target architectures are different.', + ); + + final BuildSystem buildSystem; + final FeatureFlags _featureFlags; + + @visibleForTesting + FeatureFlags get featureFlags => _featureFlags; + + @override + ToolContext get toolContext => super.toolContext!; @override final name = 'linux'; @override - bool get hidden => !featureFlags.isLinuxEnabled || !globals.platform.isLinux; + bool get hidden => !_featureFlags.isLinuxEnabled || !toolContext.platform.isLinux; @override Future> get requiredArtifacts async => { @@ -65,51 +85,53 @@ class BuildLinuxCommand extends BuildSubCommand { @override String get description => 'Build a Linux desktop application.'; - bool get configOnly => boolArg('config-only'); + bool get configOnly => getValue(DesktopBuildOptionsBundle.configOnly); @override Future runCommand() async { + final FileSystem fs = toolContext.fs; + final Logger logger = this.logger; + final OperatingSystemUtils os = toolContext.os; + final Platform platform = toolContext.platform; + final BuildInfo buildInfo = await getBuildInfo(); - final targetPlatform = TargetPlatform.fromName(stringArg('target-platform')!); - final needCrossBuild = - _operatingSystemUtils.hostPlatform.platformName != targetPlatform.simpleName; + final TargetPlatform targetPlatform = getValue(_targetPlatformOption); + final needCrossBuild = os.hostPlatform.platformName != targetPlatform.simpleName; - if (!featureFlags.isLinuxEnabled) { + if (!_featureFlags.isLinuxEnabled) { throwToolExit( '"build linux" is not currently supported. To enable, run "flutter config --enable-linux-desktop".', ); } - if (!globals.platform.isLinux) { + if (!platform.isLinux) { throwToolExit('"build linux" only supported on Linux hosts.'); } // Cross-building is only supported on x64 hosts - if (_operatingSystemUtils.hostPlatform != HostPlatform.linux_x64 && needCrossBuild) { + if (os.hostPlatform != HostPlatform.linux_x64 && needCrossBuild) { throwToolExit('"cross-building" only supported on Linux x64 hosts.'); } // TODO(fujino): https://github.com/flutter/flutter/issues/74929 - if (_operatingSystemUtils.hostPlatform == HostPlatform.linux_x64 && - targetPlatform == TargetPlatform.linux_arm64) { + if (os.hostPlatform == HostPlatform.linux_x64 && targetPlatform == TargetPlatform.linux_arm64) { throwToolExit( 'Cross-build from Linux x64 host to Linux arm64 target is not currently supported.', ); } // Building for riscv64 (on a non-riscv64 host) is experimental - if (_operatingSystemUtils.hostPlatform != HostPlatform.linux_riscv64 && + if (os.hostPlatform != HostPlatform.linux_riscv64 && targetPlatform == TargetPlatform.linux_riscv64 && - !featureFlags.isRiscv64SupportEnabled) { + !_featureFlags.isRiscv64SupportEnabled) { throwToolExit( 'Building for Linux riscv64 is currently an experimental feature. To enable, run "flutter config --enable-riscv64"', ); } - final Logger logger = globals.logger; await buildLinux( project.linux, buildInfo, target: targetFile, - sizeAnalyzer: SizeAnalyzer(fileSystem: globals.fs, logger: logger, analytics: analytics), + sizeAnalyzer: SizeAnalyzer(fileSystem: fs, logger: logger, analytics: analytics), needCrossBuild: needCrossBuild, targetPlatform: targetPlatform, - targetSysroot: stringArg('target-sysroot')!, + targetSysroot: getValue(_targetSysroot), logger: logger, configOnly: configOnly, ); diff --git a/packages/flutter_tools/lib/src/commands/build_macos.dart b/packages/flutter_tools/lib/src/commands/build_macos.dart index d0d6f50b..105fb6f0 100644 --- a/packages/flutter_tools/lib/src/commands/build_macos.dart +++ b/packages/flutter_tools/lib/src/commands/build_macos.dart @@ -2,36 +2,56 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:meta/meta.dart'; + import '../base/analyze_size.dart'; import '../base/common.dart'; +import '../base/file_system.dart'; +import '../base/logger.dart'; import '../build_info.dart'; -import '../cache.dart'; +import '../build_system/build_system.dart'; +import '../context/tool_context.dart'; import '../features.dart'; -import '../globals.dart' as globals; import '../macos/build_macos.dart'; -import '../runner/flutter_command.dart' show FlutterCommandResult; +import '../runner/flutter_command.dart'; +import '../runner/flutter_command_runner.dart' show FlutterGlobalOptions; import 'build.dart'; /// A command to build a macOS desktop target through a build shell script. class BuildMacosCommand extends BuildSubCommand { - BuildMacosCommand({required super.logger, required bool verboseHelp}) - : super(verboseHelp: verboseHelp) { - addCommonDesktopBuildOptions(verboseHelp: verboseHelp); - usesFlavorOption(); - argParser.addFlag( - 'config-only', - help: - 'Update the project configuration without performing a build. ' - 'This can be used in CI/CD process that create an archive to avoid ' - 'performing duplicate work.', - ); + BuildMacosCommand({ + required this.buildSystem, + required this.featureFlags, + required ToolContext super.toolContext, + required super.verboseHelp, + }) : super(logger: toolContext.logger, outputPreferences: toolContext.outputPreferences) { + registerOptionBundles(const [ + CommonBuildOptionsBundle(), + BuildModeOptionsBundle(), + DartCompileOptionsBundle(), + AppleBuildOptionsBundle(), + ]); + argParser.addDescriptors(const >[ + BuildInfoOptions.trackWidgetCreation, + AppleBuildOptionsBundle.configOnly, + ], verboseHelp: verboseHelp); } + /// The build system used to execute targets. + final BuildSystem buildSystem; + + /// Feature flags governing macOS desktop builds. + @visibleForTesting + final FeatureFlags featureFlags; + + @override + ToolContext get toolContext => super.toolContext!; + @override final name = 'macos'; @override - bool get hidden => !featureFlags.isMacOSEnabled || !globals.platform.isMacOS; + bool get hidden => !featureFlags.isMacOSEnabled || !toolContext.platform.isMacOS; @override Future> get requiredArtifacts async => { @@ -42,12 +62,15 @@ class BuildMacosCommand extends BuildSubCommand { String get description => 'Build a macOS desktop application.'; @override - bool get supported => globals.platform.isMacOS; + bool get supported => toolContext.platform.isMacOS; - bool get configOnly => boolArg('config-only'); + bool get configOnly => getValue(AppleBuildOptionsBundle.configOnly); @override Future runCommand() async { + final FileSystem fs = toolContext.fs; + final Logger logger = toolContext.logger; + final BuildInfo buildInfo = await getBuildInfo(); if (!featureFlags.isMacOSEnabled) { throwToolExit( @@ -62,11 +85,11 @@ class BuildMacosCommand extends BuildSubCommand { flutterProject: project, buildInfo: buildInfo, targetOverride: targetFile, - verboseLogging: globals.logger.isVerbose, + verboseLogging: logger.isVerbose || globalResults?[FlutterGlobalOptions.kVerboseFlag] == true, configOnly: configOnly, sizeAnalyzer: SizeAnalyzer( - fileSystem: globals.fs, - logger: globals.logger, + fileSystem: fs, + logger: logger, appFilenamePattern: 'App', analytics: analytics, ), diff --git a/packages/flutter_tools/lib/src/commands/build_macos_framework.dart b/packages/flutter_tools/lib/src/commands/build_macos_framework.dart index 52cf414c..face7b74 100644 --- a/packages/flutter_tools/lib/src/commands/build_macos_framework.dart +++ b/packages/flutter_tools/lib/src/commands/build_macos_framework.dart @@ -3,23 +3,28 @@ // found in the LICENSE file. import 'package:meta/meta.dart'; +import 'package:process/process.dart'; import '../artifacts.dart'; import '../base/common.dart'; +import '../base/config.dart'; import '../base/file_system.dart'; import '../base/logger.dart'; +import '../base/platform.dart'; import '../base/process.dart'; import '../build_info.dart'; import '../build_system/build_system.dart'; import '../build_system/targets/macos.dart'; import '../cache.dart'; +import '../context/apple_context.dart'; +import '../context/tool_context.dart'; import '../darwin/darwin.dart'; import '../flutter_plugins.dart'; -import '../globals.dart' as globals; +import '../ios/plist_parser.dart'; import '../ios/xcodeproj.dart'; import '../macos/cocoapod_utils.dart'; -import '../runner/flutter_command.dart' - show DevelopmentArtifact, FlutterCommandResult, FlutterOptions; +import '../macos/xcode.dart'; +import '../runner/flutter_command.dart'; import '../version.dart'; import 'build_ios_framework.dart'; import 'darwin_add_to_app.dart'; @@ -30,13 +35,11 @@ import 'darwin_add_to_app.dart'; /// managers. class BuildMacOSFrameworkCommand extends BuildFrameworkCommand { BuildMacOSFrameworkCommand({ - super.flutterVersion, + required super.appleContext, required super.buildSystem, - required super.verboseHelp, - required super.logger, - super.cache, - super.platform, required super.codesign, + required super.toolContext, + required super.verboseHelp, }); @override @@ -58,9 +61,16 @@ class BuildMacOSFrameworkCommand extends BuildFrameworkCommand { @override Future runCommand() async { + final ToolContext( + :Config config, + :FileSystem fs, + :Logger logger, + :ProcessManager processManager, + ) = toolContext; + final String outputArgument = - stringArg('output') ?? - globals.fs.path.join(globals.fs.currentDirectory.path, 'build', 'macos', 'framework'); + getValue(BuildFrameworkCommand.output) ?? + fs.path.join(fs.currentDirectory.path, getBuildDirectory(config, fs), 'macos', 'framework'); if (outputArgument.isEmpty) { throwToolExit('--output is required.'); @@ -70,22 +80,20 @@ class BuildMacOSFrameworkCommand extends BuildFrameworkCommand { throwToolExit('Project does not support macOS'); } - final Directory outputDirectory = globals.fs.directory( - globals.fs.path.absolute(globals.fs.path.normalize(outputArgument)), + final Directory outputDirectory = fs.directory( + fs.path.absolute(fs.path.normalize(outputArgument)), ); final List buildInfos = await getBuildInfos(); - final String? codesignIdentity = await codesign.getCodesignIdentity( + final String? codesignIdentity = await getCodesignIdentity( buildInfo: buildInfos.first, - codesignEnabled: boolArg(FlutterOptions.kCodesign), - codesignIdentityOption: stringArg(FlutterOptions.kCodesignIdentity), - identityFile: outputDirectory.childFile('.codesign_identity'), + outputDirectory: outputDirectory, xcodeProject: project.macos, ); for (final buildInfo in buildInfos) { - globals.printStatus('Building macOS frameworks in ${buildInfo.mode.cliName} mode...'); + logger.printStatus('Building macOS frameworks in ${buildInfo.mode.cliName} mode...'); // Create the build-mode specific metadata. // // This normally would be done in the verifyAndRun step of FlutterCommand, but special "meta" @@ -105,8 +113,12 @@ class BuildMacOSFrameworkCommand extends BuildFrameworkCommand { modeDirectory.deleteSync(recursive: true); } - if (boolArg('cocoapods')) { - produceFlutterPodspec(buildInfo.mode, modeDirectory, force: boolArg('force')); + if (getValue(BuildFrameworkCommand.cocoapods)) { + produceFlutterPodspec( + buildInfo.mode, + modeDirectory, + force: getValue(BuildFrameworkCommand.force), + ); } else { await _produceFlutterFramework(buildInfo, modeDirectory, codesignIdentity); } @@ -117,23 +129,25 @@ class BuildMacOSFrameworkCommand extends BuildFrameworkCommand { await _produceAppFramework(buildInfo, modeDirectory, buildOutput, codesignIdentity); // Build and copy plugins. - await processPodsIfNeeded( - project.macos, - getMacOSBuildDirectory(), - buildInfo.mode, - forceCocoaPodsOnly: true, - ); - if (boolArg('plugins') && hasPlugins(project)) { - await _producePlugins( - xcodeBuildConfiguration, - buildOutput, - modeDirectory, + if (getValue(BuildFrameworkCommand.plugins)) { + await processPodsIfNeeded( + project.macos, + getMacOSBuildDirectory(config: config, fileSystem: fs), buildInfo.mode, - codesignIdentity, + forceCocoaPodsOnly: true, ); + if (hasPlugins(project)) { + await _producePlugins( + xcodeBuildConfiguration, + buildOutput, + modeDirectory, + buildInfo.mode, + codesignIdentity, + ); + } } - globals.logger.printStatus(' └─Moving to ${globals.fs.path.relative(modeDirectory.path)}'); + logger.printStatus(' └─Moving to ${fs.path.relative(modeDirectory.path)}'); // Package native assets. final Iterable frameworkNames = BuildFrameworkCommand.findCodeAssetFrameworkNames( @@ -147,7 +161,7 @@ class BuildMacOSFrameworkCommand extends BuildFrameworkCommand { [frameworkDirectory], frameworkName.replaceAll('.framework', ''), modeDirectory, - globals.processManager, + processManager, codesignIdentity, buildInfo.mode, ); @@ -160,7 +174,7 @@ class BuildMacOSFrameworkCommand extends BuildFrameworkCommand { } } - globals.printStatus('Frameworks written to ${outputDirectory.path}.'); + logger.printStatus('Frameworks written to ${outputDirectory.path}.'); if (hasPlugins(project)) { // Apps do not generate a FlutterPluginRegistrant.framework. Users will need @@ -169,8 +183,8 @@ class BuildMacOSFrameworkCommand extends BuildFrameworkCommand { pluginRegistrantImplementation.copySync( outputDirectory.childFile(pluginRegistrantImplementation.basename).path, ); - globals.printStatus( - '\nCopy ${globals.fs.path.basename(pluginRegistrantImplementation.path)} into your project.', + logger.printStatus( + '\nCopy ${fs.path.basename(pluginRegistrantImplementation.path)} into your project.', ); } @@ -181,7 +195,7 @@ class BuildMacOSFrameworkCommand extends BuildFrameworkCommand { /// vendored framework caching. @visibleForTesting void produceFlutterPodspec(BuildMode mode, Directory modeDirectory, {bool force = false}) { - final Status status = globals.logger.startProgress(' ├─Creating FlutterMacOS.podspec...'); + final Status status = logger.startProgress(' ├─Creating FlutterMacOS.podspec...'); try { final GitTagVersion gitTagVersion = flutterVersion.gitTagVersion; if (!force && @@ -247,32 +261,39 @@ end Directory macosBuildOutput, String? codesignIdentity, ) async { - final Status status = globals.logger.startProgress(' ├─Building App.xcframework...'); + final ToolContext( + :Artifacts artifacts, + :Cache cache, + :FileSystem fs, + :Logger logger, + :Platform platform, + :ProcessManager processManager, + ) = toolContext; + + final Status status = logger.startProgress(' ├─Building App.xcframework...'); try { final environment = Environment( - projectDir: globals.fs.currentDirectory, + projectDir: fs.currentDirectory, packageConfigPath: packageConfigPath(), outputDir: macosBuildOutput, buildDir: project.dartTool.childDirectory('flutter_build'), - cacheDir: globals.cache.getRoot(), - flutterRootDir: globals.fs.directory(Cache.flutterRoot), + cacheDir: cache.getRoot(), + flutterRootDir: fs.directory(Cache.flutterRoot), defines: { kTargetFile: targetFile, kTargetPlatform: TargetPlatform.darwin.getName(), - kDarwinArchs: defaultMacOSArchsForEnvironment( - globals.artifacts!, - ).map((CpuArch e) => e.darwinArchName).join(' '), + kDarwinArchs: defaultMacOSArchsForEnvironment(artifacts) + .map((CpuArch e) => e.darwinArchName) + .join(' '), ...buildInfo.toBuildSystemEnvironment(), }, - artifacts: globals.artifacts!, - fileSystem: globals.fs, - logger: globals.logger, - processManager: globals.processManager, - platform: globals.platform, - analytics: globals.analytics, - engineVersion: globals.artifacts!.usesLocalArtifacts - ? null - : globals.flutterVersion.engineRevision, + artifacts: artifacts, + fileSystem: fs, + logger: logger, + processManager: processManager, + platform: platform, + analytics: analytics, + engineVersion: artifacts.usesLocalArtifacts ? null : flutterVersion.engineRevision, generateDartPluginRegistry: true, ); Target target; @@ -288,7 +309,7 @@ end final BuildResult result = await buildSystem.build(target, environment); if (!result.success) { for (final ExceptionMeasurement measurement in result.exceptions.values) { - globals.printError(measurement.exception.toString()); + logger.printError(measurement.exception.toString()); } throwToolExit('The App.xcframework build failed.'); } @@ -301,7 +322,7 @@ end [appFramework], 'App', outputBuildDirectory, - globals.processManager, + processManager, codesignIdentity, buildInfo.mode, ); @@ -313,21 +334,26 @@ end Directory modeDirectory, String? codesignIdentity, ) async { - final Status status = globals.logger.startProgress(' ├─Copying FlutterMacOS.xcframework...'); - final String engineCacheFlutterFrameworkDirectory = globals.artifacts!.getArtifactPath( + final ToolContext( + :Artifacts artifacts, + :FileSystem fs, + :Logger logger, + :ProcessManager processManager, + ) = toolContext; + + final Status status = logger.startProgress(' ├─Copying FlutterMacOS.xcframework...'); + final String engineCacheFlutterFrameworkDirectory = artifacts.getArtifactPath( Artifact.flutterMacOSXcframework, platform: TargetPlatform.darwin, mode: buildInfo.mode, ); - final String flutterFrameworkFileName = globals.fs.path.basename( - engineCacheFlutterFrameworkDirectory, - ); + final String flutterFrameworkFileName = fs.path.basename(engineCacheFlutterFrameworkDirectory); final Directory flutterFrameworkCopy = modeDirectory.childDirectory(flutterFrameworkFileName); try { // Copy xcframework engine cache framework to mode directory. copyDirectory( - globals.fs.directory(engineCacheFlutterFrameworkDirectory), + fs.directory(engineCacheFlutterFrameworkDirectory), flutterFrameworkCopy, followLinks: false, ); @@ -335,7 +361,7 @@ end await DarwinAddToAppCodesigning.codesignFlutterXCFramework( codesignIdentity: codesignIdentity, xcframework: flutterFrameworkCopy, - processManager: globals.processManager, + processManager: processManager, buildMode: buildInfo.mode, ); } @@ -351,10 +377,18 @@ end BuildMode mode, String? codesignIdentity, ) async { - final Status status = globals.logger.startProgress(' ├─Building plugins...'); + final ToolContext( + :FileSystem fs, + :Logger logger, + :ProcessManager processManager, + :ProcessUtils processUtils, + ) = toolContext; + final AppleContext(:PlistParser plistParser, :Xcode xcode) = appleContext; + + final Status status = logger.startProgress(' ├─Building plugins...'); try { final pluginsBuildCommand = [ - ...globals.xcode!.xcrunCommand(), + ...xcode.xcrunCommand(), 'xcodebuild', '-alltargets', '-sdk', @@ -364,10 +398,10 @@ end 'SYMROOT=${buildOutput.path}', 'ONLY_ACTIVE_ARCH=NO', // No device targeted, so build all valid architectures. 'BUILD_LIBRARY_FOR_DISTRIBUTION=YES', - if (boolArg('static')) 'MACH_O_TYPE=staticlib', + if (getValue(BuildFrameworkCommand.staticFrameworks)) 'MACH_O_TYPE=staticlib', ]; - final RunResult buildPluginsResult = await globals.processUtils.run( + final RunResult buildPluginsResult = await processUtils.run( pluginsBuildCommand, workingDirectory: project.macos.hostAppRoot.childDirectory('Pods').path, ); @@ -384,16 +418,16 @@ end for (final builtProduct in products) { for (final FileSystemEntity podProduct in builtProduct.listSync(followLinks: false)) { final String podFrameworkName = podProduct.basename; - if (globals.fs.path.extension(podFrameworkName) != '.framework') { + if (fs.path.extension(podFrameworkName) != '.framework') { continue; } - final String binaryName = globals.fs.path.basenameWithoutExtension(podFrameworkName); + final String binaryName = fs.path.basenameWithoutExtension(podFrameworkName); await BuildFrameworkCommand.produceXCFramework( [podProduct as Directory], binaryName, modeDirectory, - globals.processManager, + processManager, codesignIdentity, mode, ); @@ -401,7 +435,7 @@ end } // Copy vendored frameworks from CocoaPods plugins. - await copyVendoredFrameworks(modeDirectory, project.macos.hostAppRoot, globals.plistParser); + await copyVendoredFrameworks(modeDirectory, project.macos.hostAppRoot, plistParser); } finally { status.stop(); } diff --git a/packages/flutter_tools/lib/src/commands/build_swift_package.dart b/packages/flutter_tools/lib/src/commands/build_swift_package.dart index ba41167f..5bf6bd1d 100644 --- a/packages/flutter_tools/lib/src/commands/build_swift_package.dart +++ b/packages/flutter_tools/lib/src/commands/build_swift_package.dart @@ -33,9 +33,7 @@ import '../macos/swift_packages.dart'; import '../macos/xcode.dart'; import '../plugins.dart'; import '../project.dart'; -import '../runner/flutter_command.dart' - show DevelopmentArtifact, FlutterCommandResult, FlutterOptions; -import '../runner/flutter_command_runner.dart'; +import '../runner/flutter_command.dart'; import '../template.dart'; import '../version.dart'; import 'build.dart'; @@ -63,64 +61,50 @@ const String _kCodesignIdentityFile = '.codesign_identity'; class BuildSwiftPackage extends BuildSubCommand { BuildSwiftPackage({ required super.logger, - required Analytics analytics, - required Artifacts artifacts, - required BuildSystem buildSystem, - required Cache cache, - required FeatureFlags featureFlags, - required FileSystem fileSystem, - required FlutterVersion flutterVersion, - required Platform platform, - required ProcessManager processManager, - required TemplateRenderer templateRenderer, - required Xcode? xcode, - required DarwinAddToAppCodesigning codesign, - required bool verboseHelp, - }) : _analytics = analytics, - _artifacts = artifacts, - _cache = cache, - _platform = platform, - _processManager = processManager, - _buildSystem = buildSystem, - _featureFlags = featureFlags, - _fileSystem = fileSystem, - _flutterVersion = flutterVersion, - _templateRenderer = templateRenderer, - _xcode = xcode, - _codesign = codesign, - super(verboseHelp: verboseHelp) { - usesFlavorOption(); - addTreeShakeIconsFlag(); - usesTargetOption(); - usesPubOption(); - usesDartDefineOption(); - addSplitDebugInfoOption(); - addDartObfuscationOption(); - usesExtraDartFlagOptions(verboseHelp: verboseHelp); - addEnableExperimentation(hide: !verboseHelp); - usesDarwinCodeSignXCFrameworksOption(); - argParser - ..addOption( - 'output', - abbr: 'o', - valueHelp: 'path/to/directory/', - help: 'Directory where the Swift package will be written.', - ) - ..addOption( - 'platform', - allowed: _kSupportedPlatforms, - defaultsTo: 'ios', - help: 'Target platform for the build.', - ) - ..addMultiOption( - 'build-mode', - allowed: availableBuildModes.map((e) => e.cliName).toList(), - defaultsTo: availableBuildModes.map((e) => e.cliName).toList(), - help: 'Build modes to include.', - ) - ..addFlag('static', help: 'Build CocoaPods plugins as static frameworks.'); + required this._analytics, + required this._artifacts, + required this._buildSystem, + required this._cache, + required this._featureFlags, + required this._fileSystem, + required this._flutterVersion, + required this._platform, + required this._processManager, + required this._templateRenderer, + required this._xcode, + required this._codesign, + required super.verboseHelp, + }) { + registerOptionBundle(const DarwinAddToAppOptionsBundle()); + argParser.addDescriptors(const >[ + BuildInfoOptions.flavor, + _output, + _platformOption, + _buildMode, + ], verboseHelp: verboseHelp); } + static const _output = StringOptionDescriptor( + name: 'output', + abbr: 'o', + valueHelp: 'path/to/directory/', + help: 'Directory where the Swift package will be written.', + ); + + static const _platformOption = DefaultedStringOptionDescriptor( + name: 'platform', + defaultsTo: 'ios', + allowed: _kSupportedPlatforms, + help: 'Target platform for the build.', + ); + + static const _buildMode = MultiOptionDescriptor( + name: 'build-mode', + defaultsTo: ['debug', 'profile', 'release'], + allowed: ['debug', 'profile', 'release'], + help: 'Build modes to include.', + ); + @override final name = 'swift-package'; @@ -149,12 +133,10 @@ class BuildSwiftPackage extends BuildSubCommand { bool get supported => _platform.isMacOS; FlutterDarwinPlatform get _targetPlatform { - final String? platformString = stringArg('platform'); - if (platformString != null) { - final FlutterDarwinPlatform? darwinPlatform = FlutterDarwinPlatform.fromName(platformString); - if (darwinPlatform != null) { - return darwinPlatform; - } + final String platformString = getValue(_platformOption); + final FlutterDarwinPlatform? darwinPlatform = FlutterDarwinPlatform.fromName(platformString); + if (darwinPlatform != null) { + return darwinPlatform; } throwToolExit( 'The $platformString platform is being targeted, but is not supported for this command. ' @@ -173,7 +155,7 @@ class BuildSwiftPackage extends BuildSubCommand { } Future> _getBuildInfos() async { - final List buildModes = stringsArg('build-mode'); + final List buildModes = getValue(_buildMode); return [ for (final mode in availableBuildModes) if (buildModes.contains(mode.cliName)) await getBuildInfo(forcedBuildMode: mode), @@ -185,7 +167,13 @@ class BuildSwiftPackage extends BuildSubCommand { await super.validateCommand(); _validateTargetPlatform(); _validateFeatureFlags(); - _validateXcodeVersion(); + final Xcode? xcode = _xcode; + if (xcode == null || !xcode.isInstalled) { + throwToolExit( + 'Flutter requires Xcode when using Swift Package Manager. Please ensure ' + 'Xcode is installed.', + ); + } } /// Validates the Flutter project supports the [_targetPlatform]. @@ -223,19 +211,6 @@ class BuildSwiftPackage extends BuildSubCommand { } } - /// Validates the Xcode version is equal to or greater than 15. - /// - /// Throws a [ToolExit] if the Xcoder version is less than 15. - void _validateXcodeVersion() { - final Version? xcodeVersion = _xcode?.currentVersion; - if (xcodeVersion == null || xcodeVersion.major < 15) { - throwToolExit( - 'Flutter requires Xcode 15 or greater when using Swift Package Manager. Please ensure ' - 'Xcode is installed and meets the version requirements.', - ); - } - } - late BuildSwiftPackageUtils utils = BuildSwiftPackageUtils( analytics: _analytics, artifacts: _artifacts, @@ -282,13 +257,13 @@ class BuildSwiftPackage extends BuildSubCommand { /// Test are only generated with `--ci` is passed in. This is only expected to be used /// by the Flutter CI. Tests are not needed by regular users of the command. bool get generateTests { - return boolArg(FlutterGlobalOptions.kContinuousIntegrationFlag, global: true); + return getValue(CommonOptions.ci); } @override Future runCommand() async { final String outputArgument = - stringArg('output') ?? + getValue(_output) ?? _fileSystem.path.join( _fileSystem.currentDirectory.path, 'build', @@ -317,8 +292,7 @@ class BuildSwiftPackage extends BuildSubCommand { throwToolExit('--build-mode is required.'); } - final List plugins = await findPlugins(project); - plugins.sort((Plugin left, Plugin right) => left.name.compareTo(right.name)); + final List plugins = await findPlugins(project, logger: logger); await pluginSwiftDependencies.processPlugins( cacheDirectory: cacheDirectory, plugins: plugins, @@ -328,8 +302,8 @@ class BuildSwiftPackage extends BuildSubCommand { final File codesignIdentityFile = cacheDirectory.childFile(_kCodesignIdentityFile); final String? codesignIdentity = await _codesign.getCodesignIdentity( buildInfo: buildInfos.first, - codesignEnabled: boolArg(FlutterOptions.kCodesign), - codesignIdentityOption: stringArg(FlutterOptions.kCodesignIdentity), + codesignEnabled: getValue(BuildInfoOptions.codesign), + codesignIdentityOption: getValue(BuildInfoOptions.codesignIdentity), identityFile: codesignIdentityFile, xcodeProject: _xcodeProject, ); @@ -401,12 +375,11 @@ class BuildSwiftPackage extends BuildSubCommand { await cocoapodDependencies.generateArtifacts( buildInfo: buildInfo, - buildStatic: boolArg('static'), + buildStatic: getValue(DarwinAddToAppOptionsBundle.staticFrameworks), cacheDirectory: cacheDirectory, xcframeworkOutput: xcframeworkOutput, codesignIdentity: codesignIdentity, codesignIdentityFile: codesignIdentityFile, - pluginSwiftDependencies: pluginSwiftDependencies, ); } @@ -487,11 +460,7 @@ class BuildSwiftPackage extends BuildSubCommand { /// Class that encapsulates logic needed to create the FlutterPluginRegistrant swift package. @visibleForTesting class FlutterPluginRegistrantSwiftPackage { - FlutterPluginRegistrantSwiftPackage({ - required FlutterDarwinPlatform targetPlatform, - required BuildSwiftPackageUtils utils, - }) : _targetPlatform = targetPlatform, - _utils = utils; + FlutterPluginRegistrantSwiftPackage({required this._targetPlatform, required this._utils}); final FlutterDarwinPlatform _targetPlatform; final BuildSwiftPackageUtils _utils; @@ -611,11 +580,7 @@ class FlutterPluginRegistrantSwiftPackage { /// for the FlutterPluginRegistrant swift package. @visibleForTesting class FlutterFrameworkDependency { - FlutterFrameworkDependency({ - required FlutterDarwinPlatform targetPlatform, - required BuildSwiftPackageUtils utils, - }) : _targetPlatform = targetPlatform, - _utils = utils; + FlutterFrameworkDependency({required this._targetPlatform, required this._utils}); final FlutterDarwinPlatform _targetPlatform; final BuildSwiftPackageUtils _utils; @@ -651,6 +616,28 @@ class FlutterFrameworkDependency { '${result.stdout}\n---\n${result.stderr}', ); } + final String copiedPath = xcframeworkOutput + .childDirectory('${_targetPlatform.binaryName}.xcframework') + .path; + try { + final ProcessResult chmodResult = await _utils.processManager.run([ + 'chmod', + '-R', + 'u+w', + copiedPath, + ]); + if (chmodResult.exitCode != 0) { + _utils.logger.printWarning( + 'Failed to make the XCFramework writable. This may cause the build to ' + 'fail when using lipo.\nError: $copiedPath: ${chmodResult.stderr}', + ); + } + } on ProcessException catch (e) { + _utils.logger.printWarning( + 'Failed to make the XCFramework writable. This may cause the build to ' + 'fail when using lipo.\nError: $copiedPath: $e', + ); + } if (codesignIdentity != null) { final Directory copiedXCFramework = xcframeworkOutput.childDirectory( '${_targetPlatform.binaryName}.xcframework', @@ -727,11 +714,7 @@ class FlutterFrameworkDependency { /// dependencies for the FlutterPluginRegistrant swift package. @visibleForTesting class FlutterPluginSwiftDependencies { - FlutterPluginSwiftDependencies({ - required FlutterDarwinPlatform targetPlatform, - required BuildSwiftPackageUtils utils, - }) : _targetPlatform = targetPlatform, - _utils = utils; + FlutterPluginSwiftDependencies({required this._targetPlatform, required this._utils}); final FlutterDarwinPlatform _targetPlatform; final BuildSwiftPackageUtils _utils; @@ -1156,11 +1139,7 @@ class FlutterPluginSwiftDependencies { /// dependencies for the FlutterPluginRegistrant swift package. @visibleForTesting class AppFrameworkAndNativeAssetsDependencies { - AppFrameworkAndNativeAssetsDependencies({ - required FlutterDarwinPlatform targetPlatform, - required BuildSwiftPackageUtils utils, - }) : _targetPlatform = targetPlatform, - _utils = utils; + AppFrameworkAndNativeAssetsDependencies({required this._targetPlatform, required this._utils}); final FlutterDarwinPlatform _targetPlatform; final BuildSwiftPackageUtils _utils; @@ -1418,9 +1397,9 @@ class AppFrameworkAndNativeAssetsDependencies { }; case FlutterDarwinPlatform.macos: return { - kDarwinArchs: defaultMacOSArchsForEnvironment( - _utils.artifacts, - ).map((CpuArch e) => e.darwinArchName).join(' '), + kDarwinArchs: defaultMacOSArchsForEnvironment(_utils.artifacts) + .map((CpuArch e) => e.darwinArchName) + .join(' '), }; } } @@ -1472,11 +1451,7 @@ class AppFrameworkAndNativeAssetsDependencies { /// frameworks and then combines them into a single XCFramework for each. @visibleForTesting class CocoaPodPluginDependencies { - CocoaPodPluginDependencies({ - required FlutterDarwinPlatform targetPlatform, - required BuildSwiftPackageUtils utils, - }) : _targetPlatform = targetPlatform, - _utils = utils; + CocoaPodPluginDependencies({required this._targetPlatform, required this._utils}); final FlutterDarwinPlatform _targetPlatform; final BuildSwiftPackageUtils _utils; @@ -1495,7 +1470,6 @@ class CocoaPodPluginDependencies { required bool buildStatic, required String? codesignIdentity, required File codesignIdentityFile, - required FlutterPluginSwiftDependencies pluginSwiftDependencies, }) async { final String xcodeBuildConfiguration = buildInfo.mode.uppercaseName; if (!_xcodeProject.podfile.existsSync()) { @@ -1526,6 +1500,22 @@ class CocoaPodPluginDependencies { ErrorHandlingFileSystem.deleteIfExists(cocoapodXCFrameworkOutput, recursive: true); } + if (_utils.project.isModule) { + // Flutter modules generate a FlutterPluginRegistrant framework, but "flutter build + // swift-package" uses a Swift package for the FlutterPluginRegistrant instead. Since + // SwiftPM compatible pods are skipped, overwrite the FlutterPluginRegistrant.podspec to + // have no dependencies to avoid build failures due to missing dependencies (that are now + // SwiftPM). FlutterPluginRegistrant will be skipped in [_findFrameworks] anyway. + // + // TODO(vashworth): Find a way to prevent CocoaPods from building FlutterPluginRegistrant + // during this command when using a module in the first place. + await writeIOSPluginRegistrant( + _utils.project, + [], + templateRenderer: _utils.templateRenderer, + ); + } + await processPods(_xcodeProject, buildInfo); // Pods directory may not exist until after `processPods` is called. final Directory podsDirectory = _xcodeProject.hostAppRoot.childDirectory('Pods'); @@ -1545,7 +1535,6 @@ class CocoaPodPluginDependencies { buildStatic: buildStatic, outputBuildDirectory: outputBuildDirectory, podsDirectory: podsDirectory, - pluginSwiftDependencies: pluginSwiftDependencies, ); sdkSpecificFrameworks.forEach((String name, List frameworks) { frameworksPerPod.putIfAbsent(name, () => []).addAll(frameworks); @@ -1582,7 +1571,18 @@ class CocoaPodPluginDependencies { @visibleForTesting /// Wrap [processPodsIfNeeded] in a method to be overwritten in tests. Future processPods(XcodeBasedProject xcodeProject, BuildInfo buildInfo) async { - await processPodsIfNeeded(xcodeProject, _targetPlatform.buildDirectory(), buildInfo.mode); + await processPodsIfNeeded( + xcodeProject, + _targetPlatform.buildDirectory(), + buildInfo.mode, + // Normal module builds (like when running "flutter build ios" with a module), do not support + // SwiftPM. Since "flutter build swift-package" builds Swift packages and CocoaPods + // separately, we need to force SwiftPM to be enabled so that CocoaPods will skip processing + // plugins that support SwiftPM. Forcing SwiftPM to be enabled here causes + // `swift_package_manager_enabled` to be set to true in .flutter-plugins-dependencies, which + // podhelper.rb then uses to skip processing SwiftPM-compatible pods. + forceSwiftPM: true, + ); } /// Builds CocoaPod plugins into frameworks for the given [xcodeBuildConfiguration], [platform], @@ -1597,7 +1597,6 @@ class CocoaPodPluginDependencies { required bool buildStatic, required Directory outputBuildDirectory, required Directory podsDirectory, - required FlutterPluginSwiftDependencies pluginSwiftDependencies, }) async { final String configuration = _configurationForSdkType(sdk, xcodeBuildConfiguration); final ProcessResult buildPluginsResult = await _utils.processManager.run([ @@ -1626,7 +1625,7 @@ class CocoaPodPluginDependencies { '$configuration-${sdk.platformName}', ); } - return _findFrameworks(configurationBuildDir, pluginSwiftDependencies); + return _findFrameworks(configurationBuildDir); } /// Iterates through the build files and find .frameworks @@ -1637,10 +1636,7 @@ class CocoaPodPluginDependencies { /// > plugin_a /// > plugin_a.framework /// ``` - Future>> _findFrameworks( - Directory configurationBuildDir, - FlutterPluginSwiftDependencies pluginSwiftDependencies, - ) async { + Future>> _findFrameworks(Directory configurationBuildDir) async { final frameworks = >{}; final Iterable products = configurationBuildDir @@ -1654,18 +1650,10 @@ class CocoaPodPluginDependencies { continue; } final String binaryName = _utils.fileSystem.path.basenameWithoutExtension(podFrameworkName); - if (_utils.project.isModule && - (binaryName == 'FlutterPluginRegistrant' || - pluginSwiftDependencies.copiedPlugins.any((record) => record.name == binaryName))) { - // Flutter modules don't support SwiftPM and force all plugins to be built as CocoaPods. - // Since SwiftPM supported plugins are used as Swift packages in this command, they should - // be skipped and not included as CocoaPod framework dependencies. - // In addition, modules generate a FlutterPluginRegistrant framework. Since the - // FlutterPluginRegistrant is also being used as a Swift Package in this command, it should - // also be skipped. - // TODO(vashworth): Find a way to prevent CocoaPods from building SwiftPM plugins and - // FlutterPluginRegistrant when using a module in the first place. - // See https://github.com/flutter/flutter/issues/184590. + if (_utils.project.isModule && (binaryName == 'FlutterPluginRegistrant')) { + // Flutter modules generate a FlutterPluginRegistrant framework. Since the + // FlutterPluginRegistrant is being used as a Swift Package in this command, it should + // be skipped. continue; } frameworks.putIfAbsent(binaryName, () => []).add(podProduct); @@ -1833,12 +1821,10 @@ class CocoaPodPluginDependencies { @visibleForTesting class FlutterNativeIntegrationSwiftPackage { FlutterNativeIntegrationSwiftPackage({ - required BuildSwiftPackageUtils utils, - required bool generateTests, - required FlutterDarwinPlatform targetPlatform, - }) : _targetPlatform = targetPlatform, - _utils = utils, - _generateTests = generateTests; + required this._utils, + required this._generateTests, + required this._targetPlatform, + }); final FlutterDarwinPlatform _targetPlatform; final BuildSwiftPackageUtils _utils; @@ -1916,7 +1902,7 @@ class FlutterNativeIntegrationSwiftPackage { manifest: nativeToolsPackage.childFile('Package.swift'), name: _kFlutterNativeTools, platforms: [], - products: [_pluginTool.product, _assembleTool.product, _prebuildTool.product], + products: [..._pluginTool.products, _assembleTool.product, _prebuildTool.product], dependencies: [], targets: [ SwiftPackageTarget.defaultTarget(name: _kFlutterToolHelper), @@ -2017,13 +2003,15 @@ class FlutterNativeIntegrationSwiftPackage { } } - ({SwiftPackageProduct product, List targets}) get _pluginTool { - final product = SwiftPackageProduct.plugin( - name: 'FlutterBuildModePlugin', - targets: BuildSwiftPackage.availableBuildModes - .map((mode) => 'Switch to ${mode.uppercaseName} Mode') - .toList(), - ); + ({List products, List targets}) get _pluginTool { + final List products = BuildSwiftPackage.availableBuildModes + .map( + (mode) => SwiftPackageProduct.plugin( + name: 'FlutterBuildMode${mode.uppercaseName}Plugin', + targets: ['Switch to ${mode.uppercaseName} Mode'], + ), + ) + .toList(); final targets = [ SwiftPackageTarget.executableTarget( name: _kFlutterPluginTool, @@ -2042,7 +2030,7 @@ class FlutterNativeIntegrationSwiftPackage { ), ), ]; - return (product: product, targets: targets); + return (products: products, targets: targets); } ({SwiftPackageProduct product, SwiftPackageTarget target}) get _assembleTool { diff --git a/packages/flutter_tools/lib/src/commands/build_web.dart b/packages/flutter_tools/lib/src/commands/build_web.dart index 31231def..36d6d90c 100644 --- a/packages/flutter_tools/lib/src/commands/build_web.dart +++ b/packages/flutter_tools/lib/src/commands/build_web.dart @@ -2,157 +2,53 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:process/process.dart'; + +import '../artifacts.dart'; import '../base/common.dart'; +import '../base/config.dart'; import '../base/file_system.dart'; -import '../base/utils.dart'; +import '../base/platform.dart'; +import '../base/terminal.dart'; import '../build_info.dart'; +import '../build_system/build_system.dart'; +import '../cache.dart'; +import '../context/tool_context.dart'; import '../features.dart'; -import '../globals.dart' as globals; -import '../runner/flutter_command.dart' - show DevelopmentArtifact, FlutterCommandResult, FlutterOptions; +import '../isolated/build_targets.dart'; +import '../runner/flutter_command.dart'; +import '../version.dart'; import '../web/compile.dart'; -import '../web/file_generators/flutter_service_worker_js.dart'; +import '../web/content_hash.dart'; import '../web/web_constants.dart'; +import '../web/web_options.dart'; import '../web_template.dart'; import 'build.dart'; class BuildWebCommand extends BuildSubCommand { BuildWebCommand({ - required super.logger, - required FileSystem fileSystem, - required bool verboseHelp, - }) : _fileSystem = fileSystem, - super(verboseHelp: verboseHelp) { - addTreeShakeIconsFlag(); - usesTargetOption(); - usesOutputDir(); - usesPubOption(); - usesBuildNumberOption(); - usesBuildNameOption(); - addBuildModeFlags(verboseHelp: verboseHelp); - usesDartDefineOption(); - usesWebDefineOption(); - addEnableExperimentation(hide: !verboseHelp); - addNativeNullAssertions(); - - // - // Flutter web-specific options - // - argParser.addSeparator('Flutter web options'); - usesBaseHrefOption(); - argParser.addOption( - 'static-assets-url', - help: - 'Used when serving the static assets from a different domain the application is hosted on. ' - 'The value has to end with a slash "/". ' - 'When this is set, it will replace all $kStaticAssetsUrlPlaceholder in web/index.html for the given value.', - ); - argParser.addOption( - 'pwa-strategy', - hide: true, - help: - 'This option is deprecated and will be removed in a future Flutter release.\n' - 'The caching strategy to be used by the PWA service worker.', - allowed: ServiceWorkerStrategy.values.map((ServiceWorkerStrategy e) => e.cliName), - allowedHelp: CliEnum.allowedHelp(ServiceWorkerStrategy.values), - ); - usesWebResourcesCdnFlag(); - - // - // Common compilation options among JavaScript and Wasm - // - argParser.addOption( - 'optimization-level', - abbr: 'O', - help: 'Sets the optimization level used for Dart compilation to JavaScript/Wasm.', - allowed: const ['0', '1', '2', '3', '4'], - ); - argParser.addFlag( - 'source-maps', - help: - 'Generate a sourcemap file. These can be used by browsers ' - 'to view and debug the original source code of a compiled and minified Dart ' - 'application.', - ); - - // - // JavaScript compilation options - // - argParser.addSeparator('JavaScript compilation options'); - argParser.addFlag( - 'csp', - negatable: false, - help: - 'Disable dynamic generation of code in the generated output. ' - 'This is necessary to satisfy CSP restrictions (see http://www.w3.org/TR/CSP/).', - ); - argParser.addOption( - 'dart2js-optimization', - help: - 'Sets the optimization level used for Dart compilation to JavaScript. ' - 'Deprecated: Please use "-O=" / "--optimization-level=".', - allowed: const ['O1', 'O2', 'O3', 'O4'], - ); - argParser.addFlag( - 'dump-info', - negatable: false, - help: - 'Passes "--dump-info" to the Javascript compiler which generates ' - 'information about the generated code in main.dart.js.info.json.', - hide: !verboseHelp, - ); - argParser.addFlag( - 'minify-js', - help: - 'Generate minified output for js. ' - 'If not explicitly set, uses the compilation mode (debug, profile, release).', - hide: !verboseHelp, - ); - argParser.addFlag( - 'minify-wasm', - help: - 'Generate minified output for wasm. ' - 'If not explicitly set, uses the compilation mode (debug, profile, release).', - hide: !verboseHelp, - ); - argParser.addFlag( - 'enable-wasm-deferred-loading', - help: 'Enable multi-module deferred loading for Wasm.', - hide: !verboseHelp, - ); - argParser.addFlag( - 'wasm-dry-run', - defaultsTo: true, - help: - 'Compiles wasm in dry run mode during JS only compilations. ' - 'Disable to suppress warnings.', - ); - argParser.addFlag( - 'no-frequency-based-minification', - negatable: false, - help: - 'Disables the frequency based minifier. ' - 'Useful for comparing the output between builds.', - hide: !verboseHelp, - ); - - // - // WebAssembly compilation options - // - argParser.addSeparator('WebAssembly compilation options'); - argParser.addFlag( - FlutterOptions.kWebWasmFlag, - help: 'Compile to WebAssembly (with fallback to JavaScript).\n$kWasmMoreInfo', - negatable: false, - ); - argParser.addFlag( - 'strip-wasm', - help: 'Whether to strip the resulting wasm file of static symbol names.', - defaultsTo: true, - ); + required this.buildSystem, + required this.featureFlags, + required ToolContext toolContext, + required super.verboseHelp, + }) : super( + logger: toolContext.logger, + outputPreferences: toolContext.outputPreferences, + toolContext: toolContext, + ) { + registerOptionBundles(const [ + CommonBuildOptionsBundle(), + BuildModeOptionsBundle(), + DartCompileOptionsBundle(), + WebOptionsBundle(), + ]); } - final FileSystem _fileSystem; + final BuildSystem buildSystem; + final FeatureFlags featureFlags; + + @override + ToolContext get toolContext => super.toolContext!; @override Future> get requiredArtifacts async => const { @@ -176,12 +72,12 @@ class BuildWebCommand extends BuildSubCommand { ); } - final String? optimizationLevelArg = stringArg('optimization-level'); + final String? optimizationLevelArg = getValue(WebOptions.optimizationLevel); final int? optimizationLevel = optimizationLevelArg != null ? int.parse(optimizationLevelArg) : null; - final String? dart2jsOptimizationLevelValue = stringArg('dart2js-optimization'); + final String? dart2jsOptimizationLevelValue = getValue(WebOptions.dart2jsOptimization); final int? jsOptimizationLevel = dart2jsOptimizationLevelValue != null ? int.parse(dart2jsOptimizationLevelValue.substring(1)) : optimizationLevel; @@ -189,13 +85,23 @@ class BuildWebCommand extends BuildSubCommand { final List dartDefines = extractDartDefines( defineConfigJsonMap: extractDartDefineConfigJsonMap(), ); - final bool useWasm = boolArg(FlutterOptions.kWebWasmFlag); + final bool useWasm = getValue(WebOptions.wasm); // See also: RunCommandBase.webRenderer and TestCommand.webRenderer. final webRenderer = WebRendererMode.fromDartDefines(dartDefines, useWasm: useWasm); - final bool sourceMaps = boolArg('source-maps'); - final bool? minifyJs = argResults!.wasParsed('minify-js') ? boolArg('minify-js') : null; - final bool? minifyWasm = argResults!.wasParsed('minify-wasm') ? boolArg('minify-wasm') : null; + final bool sourceMaps = getValue(WebOptions.sourceMaps); + final bool webContentHash = getValue(WebOptions.webContentHash); + if (webContentHash && getValue(WebOptions.enableWasmDeferredLoading)) { + throwToolExit( + '"--web-content-hash" does not yet support deferred loading: deferred ' + 'module files keep unhashed names and can be served stale from the ' + 'browser cache alongside a new entrypoint. Build without ' + '"--enable-wasm-deferred-loading" or without "--web-content-hash".', + ); + } + final bool? minifyJs = getValue(WebOptions.minifyJs); + final bool? minifyWasm = getValue(WebOptions.minifyWasm); + final bool? deprecatedJsInterop = getValue(WebOptions.deprecatedJsInterop); final List compilerConfigs; @@ -205,55 +111,64 @@ class BuildWebCommand extends BuildSubCommand { 'Do not attempt to set a web renderer when using "--${FlutterOptions.kWebWasmFlag}"', ); } - globals.logger.printBox(title: 'New feature', ''' + logger.printBox(title: 'New feature', ''' WebAssembly compilation is new. Understand the details before deploying to production. $kWasmMoreInfo'''); compilerConfigs = [ WasmCompilerConfig( optimizationLevel: optimizationLevel, - stripWasm: boolArg('strip-wasm'), + stripWasm: getValue(WebOptions.stripWasm), sourceMaps: sourceMaps, + webContentHash: webContentHash, minify: minifyWasm, - enableWasmDeferredLoading: boolArg('enable-wasm-deferred-loading'), + enableWasmDeferredLoading: getValue(WebOptions.enableWasmDeferredLoading), ), JsCompilerConfig( - csp: boolArg('csp'), - dumpInfo: boolArg('dump-info'), + csp: getValue(WebOptions.csp), + dumpInfo: getValue(WebOptions.dumpInfo), minify: minifyJs, - nativeNullAssertions: boolArg('native-null-assertions'), - useFrequencyBasedMinification: !boolArg('no-frequency-based-minification'), + nativeNullAssertions: getValue(CommonOptions.nativeNullAssertions), + useFrequencyBasedMinification: !getValue(WebOptions.noFrequencyBasedMinification), optimizationLevel: jsOptimizationLevel, sourceMaps: sourceMaps, + webContentHash: webContentHash, + deprecatedJsInterop: deprecatedJsInterop, ), ]; } else { compilerConfigs = [ JsCompilerConfig( - csp: boolArg('csp'), - dumpInfo: boolArg('dump-info'), + csp: getValue(WebOptions.csp), + dumpInfo: getValue(WebOptions.dumpInfo), minify: minifyJs, - nativeNullAssertions: boolArg('native-null-assertions'), - useFrequencyBasedMinification: !boolArg('no-frequency-based-minification'), + nativeNullAssertions: getValue(CommonOptions.nativeNullAssertions), + useFrequencyBasedMinification: !getValue(WebOptions.noFrequencyBasedMinification), optimizationLevel: jsOptimizationLevel, sourceMaps: sourceMaps, + webContentHash: webContentHash, + deprecatedJsInterop: deprecatedJsInterop, renderer: webRenderer, ), - if (boolArg('wasm-dry-run')) + + if (getValue(WebOptions.wasmDryRun)) WasmCompilerConfig( optimizationLevel: optimizationLevel, - stripWasm: boolArg('strip-wasm'), + stripWasm: getValue(WebOptions.stripWasm), sourceMaps: sourceMaps, + webContentHash: webContentHash, minify: minifyWasm, - enableWasmDeferredLoading: boolArg('enable-wasm-deferred-loading'), + enableWasmDeferredLoading: getValue(WebOptions.enableWasmDeferredLoading), dryRun: true, + // dart2js already reports deprecated JS interop imports as errors. + omitDeprecatedJsInteropFindings: deprecatedJsInterop == false, ), ]; } final BuildInfo buildInfo = await getBuildInfo(); - final String? baseHref = stringArg('base-href'); - final String? staticAssetsUrl = stringArg('static-assets-url'); + final String? baseHref = getValue(WebOptions.baseHref); + final String? staticAssetsUrl = getValue(WebOptions.staticAssetsUrl); if (baseHref != null && !(baseHref.startsWith('/') && baseHref.endsWith('/'))) { throwToolExit( 'Received a --base-href value of "$baseHref"\n' @@ -272,42 +187,120 @@ class BuildWebCommand extends BuildSubCommand { 'To configure this project for the web, run flutter create . --platforms web', ); } - if (!_fileSystem.currentDirectory - .childDirectory('web') - .childFile('index.html') - .readAsStringSync() - .contains(kBaseHrefPlaceholder) && - baseHref != null) { - throwToolExit( - "Couldn't find the placeholder for base href. " - 'Please add `` to web/index.html', - ); + final File indexHtmlFile = toolContext.fs.file(project.web.indexFile.path); + if (indexHtmlFile.existsSync()) { + final String indexHtmlContent = indexHtmlFile.readAsStringSync(); + if (!indexHtmlContent.contains(kBaseHrefPlaceholder) && baseHref != null) { + throwToolExit( + "Couldn't find the placeholder for base href. " + 'Please add `` to web/index.html', + ); + } + if (webContentHash) { + final File bootstrapJsFile = project.web.directory.childFile('flutter_bootstrap.js'); + _validateIndexHtmlForContentHash( + indexHtmlFile, + bootstrapJsFile: bootstrapJsFile.existsSync() ? bootstrapJsFile : null, + ); + } } - // Currently supporting options [output-dir] and [output] as - // valid approaches for setting output directory of build artifacts - final String? outputDirectoryPath = stringArg('output'); + final String? outputDirectoryPath = getValue(CommonOptions.outputDir); final Map webDefines = extractWebDefines(); + + final ToolContext( + :Artifacts artifacts, + :Cache cache, + :Config config, + :FileSystem fs, + :FlutterVersion flutterVersion, + :Platform platform, + :ProcessManager processManager, + :Terminal terminal, + ) = toolContext; final webBuilder = WebBuilder( - logger: globals.logger, - processManager: globals.processManager, - buildSystem: globals.buildSystem, - fileSystem: globals.fs, - flutterVersion: globals.flutterVersion, - analytics: globals.analytics, + logger: logger, + processManager: processManager, + buildSystem: buildSystem, + fileSystem: fs, + flutterVersion: flutterVersion, + analytics: analytics, + artifacts: artifacts, + buildTargets: const BuildTargetsImpl(), + cache: cache, + config: config, + platform: platform, + terminal: terminal, ); await webBuilder.buildWeb( project, targetFile, buildInfo, - ServiceWorkerStrategy.fromCliName(stringArg('pwa-strategy')), + getValue(WebOptions.pwaStrategy), compilerConfigs: compilerConfigs, baseHref: baseHref, staticAssetsUrl: staticAssetsUrl, outputDirectoryPath: outputDirectoryPath, webDefines: webDefines, ); + // TODO(kevmoo): Ensure https://github.com/flutter/website/issues/13825 is + // documented and merged before this feature is promoted to default/stable. + if (webContentHash) { + logger.printStatus( + '\nServing tip: Configure your web host to serve "index.html" and "flutter_bootstrap.js"\n' + 'with "Cache-Control: no-cache" (or revalidation) so browser clients immediately pick up new deployments.\n' + 'Hashed entrypoint files (*..*) can be served with long-term immutable caching (e.g. "Cache-Control: max-age=31536000, immutable").\n' + 'When "--no-web-resources-cdn" is used, bundled "canvaskit/**" files are not content-hashed ("urlHashed: false" in "precache_manifest.json") and should not be served with "immutable" caching.\n' + 'See https://docs.flutter.dev/deployment/web for caching guidance.', + ); + } return FlutterCommandResult.success(); } + + void _validateIndexHtmlForContentHash(File indexHtmlFile, {File? bootstrapJsFile}) { + final String indexHtmlContent = indexHtmlFile.readAsStringSync(); + final String uncommentedContent = stripHtmlAndJsComments(indexHtmlContent); + if (uncommentedContent.contains('main.dart.js') || + uncommentedContent.contains('loadEntrypoint')) { + throwToolExit( + 'Cannot build with "--web-content-hash" because web/index.html contains ' + 'direct references to "main.dart.js" or the deprecated "FlutterLoader.loadEntrypoint" API.\n' + 'Modern Flutter Web applications use the templated "flutter_bootstrap.js" loader script ' + 'which automatically resolves content-hashed entrypoints. ' + 'Please update web/index.html or run "flutter create . --platforms web" to migrate.', + ); + } + if (!uncommentedContent.contains('flutter_bootstrap.js') && + !uncommentedContent.contains('{{flutter_bootstrap_js}}') && + !uncommentedContent.contains('{{flutter_build_config}}')) { + throwToolExit( + 'Cannot build with "--web-content-hash" because web/index.html does not ' + 'reference "flutter_bootstrap.js", "{{flutter_bootstrap_js}}", or "{{flutter_build_config}}".\n' + 'Modern Flutter Web applications use the templated "flutter_bootstrap.js" loader script ' + 'which automatically resolves content-hashed entrypoints. ' + 'Please update web/index.html or run "flutter create . --platforms web" to migrate.', + ); + } + if (bootstrapJsFile != null) { + final String bootstrapContent = bootstrapJsFile.readAsStringSync(); + final String uncommentedBootstrap = stripHtmlAndJsComments(bootstrapContent); + if (uncommentedBootstrap.contains('main.dart.js') || + uncommentedBootstrap.contains('loadEntrypoint')) { + throwToolExit( + 'Cannot build with "--web-content-hash" because web/flutter_bootstrap.js contains ' + 'direct references to "main.dart.js" or the deprecated "FlutterLoader.loadEntrypoint" API.\n' + 'Please update web/flutter_bootstrap.js to use "{{flutter_build_config}}" and ' + '"_flutter.loader.load()".', + ); + } + if (!uncommentedBootstrap.contains('{{flutter_build_config}}')) { + throwToolExit( + 'Cannot build with "--web-content-hash" because web/flutter_bootstrap.js does not ' + 'contain the "{{flutter_build_config}}" placeholder required to inject content-hashed ' + 'entrypoint and manifest filenames.', + ); + } + } + } } diff --git a/packages/flutter_tools/lib/src/commands/build_windows.dart b/packages/flutter_tools/lib/src/commands/build_windows.dart index 037dff7f..095d80cc 100644 --- a/packages/flutter_tools/lib/src/commands/build_windows.dart +++ b/packages/flutter_tools/lib/src/commands/build_windows.dart @@ -6,12 +6,15 @@ import 'package:meta/meta.dart'; import '../base/analyze_size.dart'; import '../base/common.dart'; +import '../base/file_system.dart'; +import '../base/logger.dart'; import '../base/os.dart'; +import '../base/platform.dart'; import '../build_info.dart'; -import '../cache.dart'; +import '../build_system/build_system.dart'; +import '../context/tool_context.dart'; import '../features.dart'; -import '../globals.dart' as globals; -import '../runner/flutter_command.dart' show FlutterCommandResult; +import '../runner/flutter_command.dart'; import '../windows/build_windows.dart'; import '../windows/visual_studio.dart'; import 'build.dart'; @@ -19,26 +22,29 @@ import 'build.dart'; /// A command to build a windows desktop target through a build shell script. class BuildWindowsCommand extends BuildSubCommand { BuildWindowsCommand({ - required super.logger, - required OperatingSystemUtils operatingSystemUtils, - bool verboseHelp = false, - }) : _operatingSystemUtils = operatingSystemUtils, - super(verboseHelp: verboseHelp) { - addCommonDesktopBuildOptions(verboseHelp: verboseHelp); - usesFlavorOption(); - argParser.addFlag( - 'config-only', - help: 'Update the project configuration without performing a build.', - ); + required this.buildSystem, + required ToolContext super.toolContext, + required super.verboseHelp, + required this._featureFlags, + required this._visualStudio, + }) : super(logger: toolContext.logger, outputPreferences: toolContext.outputPreferences) { + registerOptionBundles(const [DesktopBuildOptionsBundle()]); } - final OperatingSystemUtils _operatingSystemUtils; + final BuildSystem buildSystem; + final FeatureFlags _featureFlags; + + @visibleForTesting + FeatureFlags get featureFlags => _featureFlags; + + @override + ToolContext get toolContext => super.toolContext!; @override final name = 'windows'; @override - bool get hidden => !featureFlags.isWindowsEnabled || !globals.platform.isWindows; + bool get hidden => !_featureFlags.isWindowsEnabled || !toolContext.platform.isWindows; @override Future> get requiredArtifacts async => { @@ -48,24 +54,28 @@ class BuildWindowsCommand extends BuildSubCommand { @override String get description => 'Build a Windows desktop application.'; - @visibleForTesting - VisualStudio? visualStudioOverride; + final VisualStudio _visualStudio; - bool get configOnly => boolArg('config-only'); + bool get configOnly => getValue(DesktopBuildOptionsBundle.configOnly); @override Future runCommand() async { + final FileSystem fs = toolContext.fs; + final Logger logger = this.logger; + final OperatingSystemUtils os = toolContext.os; + final Platform platform = toolContext.platform; + final BuildInfo buildInfo = await getBuildInfo(); - if (!featureFlags.isWindowsEnabled) { + if (!_featureFlags.isWindowsEnabled) { throwToolExit( '"build windows" is not currently supported. To enable, run "flutter config --enable-windows-desktop".', ); } - if (!globals.platform.isWindows) { + if (!platform.isWindows) { throwToolExit('"build windows" only supported on Windows hosts.'); } - final defaultTargetPlatform = (_operatingSystemUtils.hostPlatform == HostPlatform.windows_arm64) + final defaultTargetPlatform = (os.hostPlatform == HostPlatform.windows_arm64) ? 'windows-arm64' : 'windows-x64'; final targetPlatform = TargetPlatform.fromName(defaultTargetPlatform); @@ -75,10 +85,10 @@ class BuildWindowsCommand extends BuildSubCommand { buildInfo, targetPlatform, target: targetFile, - visualStudioOverride: visualStudioOverride, + visualStudioOverride: _visualStudio, sizeAnalyzer: SizeAnalyzer( - fileSystem: globals.fs, - logger: globals.logger, + fileSystem: fs, + logger: logger, appFilenamePattern: 'app.so', analytics: analytics, ), diff --git a/packages/flutter_tools/lib/src/commands/channel.dart b/packages/flutter_tools/lib/src/commands/channel.dart index adec381b..d5a6238a 100644 --- a/packages/flutter_tools/lib/src/commands/channel.dart +++ b/packages/flutter_tools/lib/src/commands/channel.dart @@ -3,9 +3,11 @@ // found in the LICENSE file. import '../base/common.dart'; +import '../base/logger.dart'; import '../base/process.dart'; import '../cache.dart'; -import '../globals.dart' as globals; +import '../context/tool_context.dart'; +import '../git.dart'; import '../runner/flutter_command.dart'; import '../runner/flutter_command_runner.dart'; import '../version.dart'; @@ -13,7 +15,8 @@ import '../version.dart'; import 'upgrade.dart' show precacheArtifacts; class ChannelCommand extends FlutterCommand { - ChannelCommand({bool verboseHelp = false}) { + ChannelCommand({required ToolContext super.toolContext, super.verboseHelp = false}) + : _toolContext = toolContext { argParser.addFlag( 'all', abbr: 'a', @@ -27,8 +30,19 @@ class ChannelCommand extends FlutterCommand { 'This is the equivalent of running "flutter precache" with the "--all-platforms" flag.', defaultsTo: true, ); + argParser.addFlag( + 'force', + abbr: 'f', + help: 'Force switch channels, potentially discarding local changes.', + negatable: false, + ); } + final ToolContext _toolContext; + + @override + ToolContext get toolContext => _toolContext; + @override String get name => 'channel'; @@ -72,21 +86,25 @@ class ChannelCommand extends FlutterCommand { } Future _listChannels({required bool showAll, required bool verbose}) async { + final FlutterVersion flutterVersion = _toolContext.flutterVersion; + final Logger logger = _toolContext.logger; + final Git git = _toolContext.git; + // Beware: currentBranch could contain PII. See getBranchName(). - final String currentChannel = globals.flutterVersion.channel; // limited to known branch names + final String currentChannel = flutterVersion.channel; // limited to known branch names assert( kOfficialChannels.contains(currentChannel) || kObsoleteBranches.containsKey(currentChannel) || currentChannel == kUserBranch, 'potential PII leak in channel name: "$currentChannel"', ); - final String currentBranch = globals.flutterVersion.getBranchName(); + final String currentBranch = flutterVersion.getBranchName(); final seenUnofficialChannels = {}; final rawOutput = []; - globals.printStatus('Flutter channels:'); - final int result = await globals.git.stream( - ['branch', '-r'], + logger.printStatus('Flutter channels:'); + final int result = await git.stream( + ['branch', '-r'], workingDirectory: Cache.flutterRoot, mapFunction: (String line) { rawOutput.add(line); @@ -125,7 +143,7 @@ class ChannelCommand extends FlutterCommand { currentIndicator = '*'; currentChannelIsOfficial = true; } - globals.printStatus('$currentIndicator $channel (${kChannelDescriptions[channel]})'); + logger.printStatus('$currentIndicator $channel (${kChannelDescriptions[channel]})'); } } @@ -133,13 +151,13 @@ class ChannelCommand extends FlutterCommand { if (showAll) { for (final branch in seenUnofficialChannels) { if (currentBranch == branch) { - globals.printStatus('* $branch'); + logger.printStatus('* $branch'); } else if (!branch.startsWith('HEAD ')) { - globals.printStatus(' $branch'); + logger.printStatus(' $branch'); } } } else if (!currentChannelIsOfficial) { - globals.printStatus('* $currentBranch'); + logger.printStatus('* $currentBranch'); } if (!currentChannelIsOfficial) { @@ -147,50 +165,68 @@ class ChannelCommand extends FlutterCommand { currentChannel == kUserBranch, 'Current channel is "$currentChannel", which is not an official branch. (Current branch is "$currentBranch".)', ); - globals.printStatus(''); - globals.printStatus('Currently not on an official channel.'); + logger.printStatus(''); + logger.printStatus('Currently not on an official channel.'); } } Future _switchChannel(String branchName) async { - globals.printStatus("Switching to flutter channel '$branchName'..."); + final Logger logger = _toolContext.logger; + final Git git = _toolContext.git; + final Cache cache = _toolContext.cache; + + logger.printStatus("Switching to flutter channel '$branchName'..."); if (kObsoleteBranches.containsKey(branchName)) { final String alternative = kObsoleteBranches[branchName]!; - globals.printStatus( + logger.printStatus( "This channel is obsolete. Consider switching to the '$alternative' channel instead.", ); } else if (!kOfficialChannels.contains(branchName)) { - globals.printStatus( + logger.printStatus( 'This is not an official channel. For a list of available channels, try "flutter channel".', ); } - await _checkout(branchName); + await _checkout(branchName, git: git, cache: cache, force: boolArg('force')); if (boolArg('cache-artifacts')) { - await precacheArtifacts(Cache.flutterRoot); + await precacheArtifacts( + workingDirectory: Cache.flutterRoot, + logger: logger, + processUtils: _toolContext.processUtils, + fileSystem: _toolContext.fs, + platform: _toolContext.platform, + ); } - globals.printStatus("Successfully switched to flutter channel '$branchName'."); - globals.printStatus( + logger.printStatus("Successfully switched to flutter channel '$branchName'."); + logger.printStatus( "To ensure that you're on the latest build from this channel, run 'flutter upgrade'", ); } - static Future upgradeChannel(FlutterVersion currentVersion) async { + static Future upgradeChannel( + FlutterVersion currentVersion, { + required Logger logger, + required Git git, + Cache? cache, + }) async { final String channel = currentVersion.channel; if (kObsoleteBranches.containsKey(channel)) { final String alternative = kObsoleteBranches[channel]!; - globals.printStatus("Transitioning from '$channel' to '$alternative'..."); - return _checkout(alternative); + logger.printStatus("Transitioning from '$channel' to '$alternative'..."); + return _checkout(alternative, git: git, cache: cache); } } - static Future _checkout(String branchName) async { + static Future _checkout( + String branchName, { + required Git git, + Cache? cache, + bool force = false, + }) async { // Get latest refs from upstream. - RunResult runResult = await globals.git.run([ - 'fetch', - ], workingDirectory: Cache.flutterRoot); + RunResult runResult = await git.run(['fetch'], workingDirectory: Cache.flutterRoot); if (runResult.processResult.exitCode == 0) { - runResult = await globals.git.run([ + runResult = await git.run([ 'show-ref', '--verify', '--quiet', @@ -198,15 +234,17 @@ class ChannelCommand extends FlutterCommand { ], workingDirectory: Cache.flutterRoot); if (runResult.processResult.exitCode == 0) { // branch already exists, try just switching to it - runResult = await globals.git.run([ + runResult = await git.run([ 'checkout', + if (force) '-f', branchName, '--', ], workingDirectory: Cache.flutterRoot); } else { // branch does not exist, we have to create it - runResult = await globals.git.run([ + runResult = await git.run([ 'checkout', + if (force) '-f', '--track', '-b', branchName, @@ -222,7 +260,7 @@ class ChannelCommand extends FlutterCommand { } else { // Remove the version check stamp, since it could contain out-of-date // information that pertains to the previous channel. - await FlutterVersion.resetFlutterVersionFreshnessCheck(); + await FlutterVersion.resetFlutterVersionFreshnessCheck(cache); } } } diff --git a/packages/flutter_tools/lib/src/commands/clean.dart b/packages/flutter_tools/lib/src/commands/clean.dart index 59a8558f..6fc220b5 100644 --- a/packages/flutter_tools/lib/src/commands/clean.dart +++ b/packages/flutter_tools/lib/src/commands/clean.dart @@ -5,18 +5,25 @@ import 'package:meta/meta.dart'; import '../base/common.dart'; +import '../base/config.dart'; import '../base/error_handling_io.dart'; import '../base/file_system.dart'; import '../base/logger.dart'; +import '../base/terminal.dart'; import '../build_info.dart'; -import '../globals.dart' as globals; +import '../context/tool_context.dart'; import '../ios/xcodeproj.dart'; import '../macos/xcode.dart'; import '../project.dart'; import '../runner/flutter_command.dart'; class CleanCommand extends FlutterCommand { - CleanCommand({bool verbose = false}) : _verbose = verbose { + CleanCommand({ + required ToolContext super.toolContext, + required this._xcode, + required this._xcodeProjectInterpreter, + this._verbose = false, + }) : _toolContext = toolContext { requiresPubspecYaml(); argParser.addOption( 'scheme', @@ -29,6 +36,13 @@ class CleanCommand extends FlutterCommand { 'Also clean the example directory, if one exists. ' 'Useful when developing in a package project.', ); + argParser.addFlag( + 'include-xcode-workspace', + negatable: false, + help: + 'Whether to run "xcodebuild clean" on the Xcode workspace for iOS and macOS projects. ' + "This removes build products and intermediate files from Xcode's build cache and can be slow to complete.", + ); argParser.addFlag( 'stop-gradle', negatable: false, @@ -38,6 +52,9 @@ class CleanCommand extends FlutterCommand { ); } + final ToolContext _toolContext; + final Xcode _xcode; + final XcodeProjectInterpreter _xcodeProjectInterpreter; final bool _verbose; @override @@ -54,16 +71,21 @@ class CleanCommand extends FlutterCommand { @override Future runCommand() async { - final FlutterProject flutterProject = FlutterProject.current(); - final Xcode? xcode = globals.xcode; - final bool cleanXcode = xcode != null && xcode.isInstalledAndMeetsVersionCheck; + final FileSystem fs = _toolContext.fs; + final FlutterProject flutterProject = _toolContext.projectFactory.fromDirectory( + fs.currentDirectory, + ); + final Xcode xcode = _xcode; + final bool userWantsXcodeClean = + boolArg('include-xcode-workspace') || (argResults?.wasParsed('scheme') ?? false); + final bool cleanXcode = xcode.isInstalledAndMeetsVersionCheck && userWantsXcodeClean; await _cleanProject(flutterProject, cleanXcode: cleanXcode); if (boolArg('include-example')) { if (flutterProject.hasExampleApp) { await _cleanProject(flutterProject.example, cleanXcode: cleanXcode); } else { - globals.printStatus('No example app found, skipping example cleaning.'); + _toolContext.logger.printStatus('No example app found, skipping example cleaning.'); } } @@ -81,7 +103,11 @@ class CleanCommand extends FlutterCommand { await _cleanXcode(flutterProject.macos); } - final Directory buildDir = flutterProject.directory.childDirectory(getBuildDirectory()); + final Config config = _toolContext.config; + final FileSystem fs = _toolContext.fs; + final Directory buildDir = flutterProject.directory.childDirectory( + getBuildDirectory(config, fs), + ); await deleteFile(buildDir, flutterProject); await deleteFile(flutterProject.dartTool, flutterProject); @@ -107,12 +133,18 @@ class CleanCommand extends FlutterCommand { if (xcodeWorkspace == null) { return; } - final Status xcodeStatus = globals.logger.startProgress('Cleaning Xcode workspace...'); + final Logger logger = _toolContext.logger; + final FileSystem fs = _toolContext.fs; + final Config config = _toolContext.config; + final Status xcodeStatus = logger.startProgress('Cleaning Xcode workspace...'); try { - final XcodeProjectInterpreter xcodeProjectInterpreter = globals.xcodeProjectInterpreter!; + final XcodeProjectInterpreter xcodeProjectInterpreter = _xcodeProjectInterpreter; + final Directory darwinBuildDirectory = fs.directory( + xcodeProject.darwinPlatform.buildDirectory(config: config, fileSystem: fs), + ); final XcodeProjectInfo projectInfo = (await xcodeProjectInterpreter.getInfo( xcodeProject, - buildDirectory: globals.fs.directory(xcodeProject.darwinPlatform.buildDirectory()), + buildDirectory: darwinBuildDirectory, ))!; if (argResults?.wasParsed('scheme') ?? false) { final scheme = argResults!['scheme'] as String; @@ -127,7 +159,7 @@ class CleanCommand extends FlutterCommand { xcodeWorkspace.path, scheme, verbose: _verbose, - buildDirectory: globals.fs.directory(xcodeProject.darwinPlatform.buildDirectory()), + buildDirectory: darwinBuildDirectory, ); } else { for (final String scheme in projectInfo.schemes) { @@ -136,7 +168,7 @@ class CleanCommand extends FlutterCommand { xcodeWorkspace.path, scheme, verbose: _verbose, - buildDirectory: globals.fs.directory(xcodeProject.darwinPlatform.buildDirectory()), + buildDirectory: darwinBuildDirectory, ); } } @@ -145,7 +177,7 @@ class CleanCommand extends FlutterCommand { if (argResults?.wasParsed('scheme') ?? false) { throwToolExit(message); } else { - globals.printTrace(message); + logger.printTrace(message); } } finally { xcodeStatus.stop(); @@ -157,32 +189,32 @@ class CleanCommand extends FlutterCommand { try { await ErrorHandlingFileSystem.noExitOnFailure(() => _deleteFile(file, project)); } on Exception catch (e) { - globals.printError('Failed to remove ${file.path}: $e'); + _toolContext.logger.printError('Failed to remove ${file.path}: $e'); } } Future _deleteFile(FileSystemEntity file, FlutterProject? project) async { + final Logger logger = _toolContext.logger; // This will throw a FileSystemException if the directory is missing permissions. try { if (!file.existsSync()) { return; } } on FileSystemException catch (err) { - globals.printError('Cannot clean ${file.path}.\n$err'); + logger.printError('Cannot clean ${file.path}.\n$err'); return; } - final Status deletionStatus = globals.logger.startProgress('Deleting ${file.basename}...'); + final Status deletionStatus = logger.startProgress('Deleting ${file.basename}...'); try { file.deleteSync(recursive: true); } on FileSystemException catch (error) { - deletionStatus.stop(); final String path = file.path; - if (globals.platform.isWindows) { + if (_toolContext.platform.isWindows) { if (await _tryStopGradleAndRetryDelete(file, project)) { return; } - globals.printError( + logger.printError( 'Failed to remove $path. ' 'A background process (e.g. Gradle daemon or Java) is locking files in the directory.\n' 'To automatically stop Gradle daemons during clean, run:\n' @@ -191,8 +223,10 @@ class CleanCommand extends FlutterCommand { ' cd android && ./gradlew --stop', ); } else { - globals.printError('Failed to remove $path: $error'); + logger.printError('Failed to remove $path: $error'); } + } finally { + deletionStatus.stop(); } } @@ -201,16 +235,18 @@ class CleanCommand extends FlutterCommand { /// /// Retries file deletion after stopping Gradle daemons and returns `true` if deletion succeeds. Future _tryStopGradleAndRetryDelete(FileSystemEntity file, FlutterProject? project) async { + final AnsiTerminal terminal = _toolContext.terminal; + final Logger logger = _toolContext.logger; final bool stopGradleFlag = (argResults?.wasParsed('stop-gradle') ?? false) && boolArg('stop-gradle'); - final bool isInteractive = globals.terminal.stdinHasTerminal && globals.terminal.usesTerminalUi; + final bool isInteractive = terminal.stdinHasTerminal && terminal.usesTerminalUi; var shouldStopGradle = stopGradleFlag; if (!stopGradleFlag && isInteractive) { try { - final String choice = await globals.terminal.promptForCharInput( + final String choice = await terminal.promptForCharInput( ['y', 'n'], - logger: globals.logger, + logger: logger, prompt: 'Files in build/ are locked by background processes (likely Gradle).\n' 'Stop active Gradle daemons ("gradlew --stop") and retry clean? [y/N]', @@ -226,20 +262,21 @@ class CleanCommand extends FlutterCommand { return false; } - final FlutterProject flutterProject = project ?? FlutterProject.current(); + final FlutterProject flutterProject = + project ?? _toolContext.projectFactory.fromDirectory(_toolContext.fs.currentDirectory); final File gradlewFile = flutterProject.android.hostAppGradleRoot.childFile('gradlew.bat'); if (!gradlewFile.existsSync()) { return false; } - final Status stopStatus = globals.logger.startProgress('Stopping Gradle daemons...'); + final Status stopStatus = logger.startProgress('Stopping Gradle daemons...'); try { - await globals.processUtils.run([ + await _toolContext.processUtils.run([ gradlewFile.path, '--stop', ], workingDirectory: gradlewFile.parent.path); } on Exception catch (e) { - globals.printTrace('Failed to stop Gradle daemons: $e'); + logger.printTrace('Failed to stop Gradle daemons: $e'); } finally { stopStatus.stop(); } diff --git a/packages/flutter_tools/lib/src/commands/config.dart b/packages/flutter_tools/lib/src/commands/config.dart index 15d71bbb..e048a3c4 100644 --- a/packages/flutter_tools/lib/src/commands/config.dart +++ b/packages/flutter_tools/lib/src/commands/config.dart @@ -2,52 +2,78 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:args/args.dart'; +import 'package:flutter_tools_core/flutter_tools_core.dart'; +import 'package:flutter_tools_extension/flutter_tools_extension.dart'; + import '../android/android_sdk.dart'; import '../android/android_studio.dart'; import '../android/java.dart'; import '../base/common.dart'; +import '../base/config.dart'; +import '../base/file_system.dart'; +import '../base/logger.dart'; +import '../context/android_context.dart'; +import '../context/tool_context.dart'; import '../convert.dart'; +import '../experimental/config.dart'; +import '../experimental/extension_arg_parser.dart'; +import '../experimental/extension_manager.dart'; import '../features.dart'; -import '../globals.dart' as globals; import '../ios/code_signing.dart'; +import '../ios/plist_parser.dart'; import '../runner/flutter_command.dart'; import '../runner/flutter_command_runner.dart'; -class ConfigCommand extends FlutterCommand { - ConfigCommand({bool verboseHelp = false}) { - argParser.addFlag( - 'list', - help: 'List all settings and their current values.', - negatable: false, - ); - argParser.addFlag( +class ConfigCommand extends FlutterCommand with ExtensionArgParserMixin { + ConfigCommand({ + required this._androidContext, + required ToolContext super.toolContext, + required this.featureFlags, + this._verboseHelp = false, + this._extensionManager, + }) : _toolContext = toolContext; + + final AndroidContext _androidContext; + final ToolContext _toolContext; + final FeatureFlags featureFlags; + final ExtensionManager? _extensionManager; + final bool _verboseHelp; + + @override + ToolContext get toolContext => _toolContext; + + var _extensionSettingsGroups = const []; + + @override + ArgParser createBaseArgParser() { + final ArgParser parser = super.createBaseArgParser(); + parser.addFlag('list', help: 'List all settings and their current values.', negatable: false); + parser.addFlag( 'analytics', - hide: !verboseHelp, + hide: !_verboseHelp, help: 'Enable or disable reporting anonymously tool usage statistics and crash reports.\n' '(An alias for "--${FlutterGlobalOptions.kEnableAnalyticsFlag}" ' 'and "--${FlutterGlobalOptions.kDisableAnalyticsFlag}" top level flags.)', ); - argParser.addFlag( + parser.addFlag( 'clear-ios-signing-settings', negatable: false, aliases: ['clear-ios-signing-cert'], - help: - 'Clear the saved development certificate or provisioning profile choice used to sign apps for iOS device deployment.', + help: 'Clear the saved development certificate or provisioning profile choice used to sign apps for iOS device deployment.', ); - argParser.addFlag( + parser.addFlag( 'select-ios-signing-settings', negatable: false, - help: - 'Complete prompt to select and save code signing settings used to sign apps for iOS device deployment.', + help: 'Complete prompt to select and save code signing settings used to sign apps for iOS device deployment.', ); - argParser.addOption('android-sdk', help: 'The Android SDK directory.'); - argParser.addOption( + parser.addOption('android-sdk', help: 'The Android SDK directory.'); + parser.addOption( 'android-studio-dir', - help: - 'The Android Studio installation directory. If unset, flutter will search for valid installations at well-known locations.', + help: 'The Android Studio installation directory. If unset, flutter will search for valid installations at well-known locations.', ); - argParser.addOption( + parser.addOption( 'jdk-dir', help: 'The Java Development Kit (JDK) installation directory. ' @@ -56,29 +82,81 @@ class ConfigCommand extends FlutterCommand { ' 2) the JDK found at the directory found in the JAVA_HOME environment variable, and\n' " 3) the directory containing the java binary found in the user's path.", ); - argParser.addOption( + parser.addOption( 'build-dir', help: 'The relative path to override a projects build directory.', valueHelp: 'out/', ); - addMachineOutputFlag(verboseHelp: verboseHelp); + parser.addFlag( + FlutterGlobalOptions.kMachineFlag, + negatable: false, + help: 'Outputs in a machine readable structured JSON format.', + hide: !_verboseHelp, + ); for (final Feature feature in featureFlags.allFeatures) { final String? configSetting = feature.configSetting; if (configSetting == null) { continue; } - final String channel = globals.flutterVersion.channel; - argParser.addFlag( + final String channel = _toolContext.flutterVersion.channel; + parser.addFlag( configSetting, help: feature.generateHelpMessage(), defaultsTo: feature.getSettingForChannel(channel).enabledByDefault, ); } - argParser.addFlag( + parser.addFlag( 'clear-features', help: 'Remove all configured features and restore them to the default values.', negatable: false, ); + return parser; + } + + Future get _activeExtensionConfig async { + if (_extensionManager case final extensionManager?) { + await extensionManager.ensureInitialized(); + final List extensions = extensionManager.configurationExtensions; + if (extensions.isNotEmpty) { + return ExtensionConfiguration(extensions: extensions, logger: _toolContext.logger); + } + } + return null; + } + + @override + Future initializeDynamicOptions() async { + if (await _activeExtensionConfig case final activeConfig?) { + _extensionSettingsGroups = await activeConfig.fetchExtensionSettings(); + if (_extensionSettingsGroups.isNotEmpty) { + rebuildDynamicArgParser(); + } + } + } + + @override + ArgParser buildDynamicArgParser(ArgParser dynamicParser) { + for (final ExtensionSettingsGroup(:featureFlags, :configOptions) in _extensionSettingsGroups) { + for (final FeatureFlag(:name, :help, :enabledByDefault) in featureFlags) { + if (!dynamicParser.options.containsKey(name)) { + dynamicParser.addFlag(name, help: help, defaultsTo: enabledByDefault); + } else { + _toolContext.logger.printTrace( + 'Extension feature flag "$name" conflicts with an existing option and was skipped.', + ); + } + } + for (final ConfigOption(:name, :help, :value) in configOptions) { + if (!dynamicParser.options.containsKey(name)) { + dynamicParser.addOption(name, help: help, defaultsTo: value); + } else { + _toolContext.logger.printTrace( + 'Extension config option "$name" conflicts with an existing option and was skipped.', + ); + } + } + } + return dynamicParser; } @override @@ -92,7 +170,7 @@ class ConfigCommand extends FlutterCommand { "Flutter tools over time. See Google's privacy policy: https://www.google.com/intl/en/policies/privacy/"; @override - final String category = FlutterCommandCategory.sdk; + final category = FlutterCommandCategory.sdk; @override final aliases = ['configure']; @@ -109,6 +187,10 @@ class ConfigCommand extends FlutterCommand { @override Future runCommand() async { + final Logger logger = _toolContext.logger; + final Config config = _toolContext.config; + final FileSystem fs = _toolContext.fs; + final List rest = argResults!.rest; if (rest.isNotEmpty) { throwToolExit( @@ -122,7 +204,7 @@ class ConfigCommand extends FlutterCommand { } if (boolArg('list')) { - globals.printStatus(settingsText); + logger.printStatus(await settingsText); return FlutterCommandResult.success(); } @@ -135,46 +217,53 @@ class ConfigCommand extends FlutterCommand { for (final Feature feature in featureFlags.allFeatures) { final String? configSetting = feature.configSetting; if (configSetting != null) { - globals.config.removeValue(configSetting); + config.removeValue(configSetting); } } - globals.printStatus(requireReloadTipText); + final ExtensionConfiguration? activeConfig = await _activeExtensionConfig; + final List groups = _extensionSettingsGroups.isNotEmpty + ? _extensionSettingsGroups + : await activeConfig?.fetchExtensionSettings() ?? const []; + for (final ExtensionSettingsGroup(:featureFlags) in groups) { + for (final FeatureFlag(:name) in featureFlags) { + config.removeValue(name); + } + } + logger.printStatus(requireReloadTipText); return FlutterCommandResult.success(); } if (argResults!.wasParsed('analytics')) { final bool value = boolArg('analytics'); - globals.printStatus('Analytics reporting ${value ? 'enabled' : 'disabled'}.'); + logger.printStatus('Analytics reporting ${value ? 'enabled' : 'disabled'}.'); - await globals.analytics.setTelemetry(value); + await analytics.setTelemetry(value); } if (argResults!.wasParsed('android-sdk')) { - _updateConfig('android-sdk', stringArg('android-sdk')!); + _updateConfig('android-sdk', stringArg('android-sdk')); } if (argResults!.wasParsed('android-studio-dir')) { - _updateConfig('android-studio-dir', stringArg('android-studio-dir')!); + _updateConfig('android-studio-dir', stringArg('android-studio-dir')); } if (argResults!.wasParsed('jdk-dir')) { - _updateConfig('jdk-dir', stringArg('jdk-dir')!); + _updateConfig('jdk-dir', stringArg('jdk-dir')); } if (argResults!.wasParsed('clear-ios-signing-settings')) { - XcodeCodeSigningSettings.resetSettings(globals.config, globals.logger); + XcodeCodeSigningSettings.resetSettings(config, logger); } if (argResults!.wasParsed('select-ios-signing-settings')) { final settings = XcodeCodeSigningSettings( - config: globals.config, - logger: globals.logger, - platform: globals.platform, - processUtils: globals.processUtils, - fileSystem: globals.fs, - fileSystemUtils: globals.fsUtils, - terminal: globals.terminal, - plistParser: globals.plistParser, + plistParser: PlistParser( + fileSystem: fs, + logger: logger, + processManager: _toolContext.processManager, + ), + toolContext: _toolContext, ); await settings.selectSettings(); @@ -182,7 +271,7 @@ class ConfigCommand extends FlutterCommand { if (argResults!.wasParsed('build-dir')) { final String buildDir = stringArg('build-dir')!; - if (globals.fs.path.isAbsolute(buildDir)) { + if (fs.path.isAbsolute(buildDir)) { throwToolExit('build-dir should be a relative path'); } _updateConfig('build-dir', buildDir); @@ -195,74 +284,92 @@ class ConfigCommand extends FlutterCommand { } if (argResults!.wasParsed(configSetting)) { final bool keyValue = boolArg(configSetting); - globals.config.setValue(configSetting, keyValue); - globals.printStatus('Setting "$configSetting" value to "$keyValue".'); + config.setValue(configSetting, keyValue); + logger.printStatus('Setting "$configSetting" value to "$keyValue".'); + if (!keyValue && feature.warningMessageOnDisable != null) { + logger.printWarning(feature.warningMessageOnDisable!); + } + } + } + + for (final ExtensionSettingsGroup(:featureFlags, :configOptions) in _extensionSettingsGroups) { + for (final FeatureFlag(:name) in featureFlags) { + if (argResults!.wasParsed(name)) { + final bool keyValue = boolArg(name); + config.setValue(name, keyValue); + logger.printStatus('Setting "$name" value to "$keyValue".'); + } + } + for (final ConfigOption(:name) in configOptions) { + if (argResults!.wasParsed(name)) { + _updateConfig(name, stringArg(name)); + } } } if (argResults == null || argResults!.arguments.isEmpty) { - globals.printStatus(usage); + logger.printStatus(usage); } else { - globals.printStatus('\n$requireReloadTipText'); + logger.printStatus('\n$requireReloadTipText'); } return FlutterCommandResult.success(); } Future handleMachine() async { + final Config config = _toolContext.config; + final Logger logger = _toolContext.logger; // Get all the current values. - final results = {}; - for (final String key in globals.config.keys) { - results[key] = globals.config.getValue(key); - } + final results = { + for (final String key in config.keys) key: config.getValue(key), + }; // Ensure we send any calculated ones, if overrides don't exist. - final AndroidStudio? androidStudio = globals.androidStudio; + final AndroidStudio? androidStudio = _androidContext.androidStudio; if (results['android-studio-dir'] == null && androidStudio != null) { results['android-studio-dir'] = androidStudio.directory; } - final AndroidSdk? androidSdk = globals.androidSdk; + final AndroidSdk? androidSdk = _androidContext.androidSdk; if (results['android-sdk'] == null && androidSdk != null) { results['android-sdk'] = androidSdk.directory.path; } - final Java? java = globals.java; + final Java? java = _androidContext.java; if (results['jdk-dir'] == null && java != null) { results['jdk-dir'] = java.javaHome; } - globals.printStatus(const JsonEncoder.withIndent(' ').convert(results)); + logger.printStatus(const JsonEncoder.withIndent(' ').convert(results)); } - void _updateConfig(String keyName, String keyValue) { - if (keyValue.isEmpty) { - globals.config.removeValue(keyName); - globals.printStatus('Removing "$keyName" value.'); + void _updateConfig(String keyName, String? keyValue) { + final Config config = _toolContext.config; + final Logger logger = _toolContext.logger; + if (keyValue == null || keyValue.isEmpty) { + config.removeValue(keyName); + logger.printStatus('Removing "$keyName" value.'); } else { - globals.config.setValue(keyName, keyValue); - globals.printStatus('Setting "$keyName" value to "$keyValue".'); + config.setValue(keyName, keyValue); + logger.printStatus('Setting "$keyName" value to "$keyValue".'); } } /// List all config settings. for feature flags, include whether they are available. - String get settingsText { - final featuresByName = {}; - final String channel = globals.flutterVersion.channel; - for (final Feature feature in featureFlags.allFeatures) { - final String? configSetting = feature.configSetting; - if (configSetting != null) { - featuresByName[configSetting] = feature; - } - } + Future get settingsText async { + final Config config = _toolContext.config; + final featuresByName = { + for (final feature in featureFlags.allFeatures) ?feature.configSetting: feature, + }; + final String channel = _toolContext.flutterVersion.channel; final keys = { ...featureFlags.allFeatures.map((Feature e) => e.configSetting).whereType(), - ...globals.config.keys, + ...config.keys, }; final Iterable settings = keys.map((String key) { - Object? value = globals.config.getValue(key); + Object? value = config.getValue(key); value ??= '(Not set)'; final buffer = StringBuffer(' $key: $value'); - if (featuresByName.containsKey(key)) { - final FeatureChannelSetting setting = featuresByName[key]!.getSettingForChannel(channel); + if (featuresByName[key] case final feature?) { + final FeatureChannelSetting setting = feature.getSettingForChannel(channel); if (!setting.available) { buffer.write(' (Unavailable)'); } @@ -276,12 +383,36 @@ class ConfigCommand extends FlutterCommand { } else { buffer.writeln(settings.join('\n')); } + + final ExtensionConfiguration? activeConfig = await _activeExtensionConfig; + final List groups = _extensionSettingsGroups.isNotEmpty + ? _extensionSettingsGroups + : (await activeConfig?.fetchExtensionSettings()) ?? const []; + if (groups.any((ExtensionSettingsGroup g) => g.isNotEmpty)) { + buffer.writeln('\nExtension Settings:'); + for (final ExtensionSettingsGroup(:title, :featureFlags, :configOptions, :isEmpty) + in groups) { + if (isEmpty) { + continue; + } + buffer.writeln(' $title:'); + for (final FeatureFlag(:name, :enabledByDefault) in featureFlags) { + final Object val = config.getValue(name) ?? enabledByDefault; + buffer.writeln(' $name: $val'); + } + for (final ConfigOption(:name, :value) in configOptions) { + final Object val = config.getValue(name) ?? value ?? '(Not set)'; + buffer.writeln(' $name: $val'); + } + } + } + return buffer.toString(); } /// List the status of the analytics reporting. String get analyticsUsage { - return 'Analytics reporting is currently ${globals.analytics.telemetryEnabled ? 'enabled' : 'disabled'}.'; + return 'Analytics reporting is currently ${analytics.telemetryEnabled ? 'enabled' : 'disabled'}.'; } /// Raising the reload tip for setting changes. diff --git a/packages/flutter_tools/lib/src/commands/create.dart b/packages/flutter_tools/lib/src/commands/create.dart index 7a1200e0..27183bfa 100644 --- a/packages/flutter_tools/lib/src/commands/create.dart +++ b/packages/flutter_tools/lib/src/commands/create.dart @@ -2,31 +2,43 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:args/args.dart'; +import 'package:flutter_tools_core/flutter_tools_core.dart'; import 'package:meta/meta.dart'; +import 'package:process/process.dart'; import 'package:unified_analytics/unified_analytics.dart'; import 'package:yaml/yaml.dart'; import '../android/gradle_utils.dart' as gradle; +import '../android/java.dart'; import '../base/common.dart'; -import '../base/context.dart'; +import '../base/config.dart'; import '../base/file_system.dart'; +import '../base/logger.dart'; import '../base/net.dart'; +import '../base/platform.dart'; +import '../base/template.dart'; import '../base/terminal.dart'; import '../base/utils.dart'; import '../base/version.dart'; import '../base/version_range.dart'; import '../cache.dart'; +import '../context/android_context.dart'; +import '../context/apple_context.dart'; +import '../context/tool_context.dart'; import '../convert.dart'; import '../dart/pub.dart'; import '../darwin/darwin.dart'; +import '../experimental/extension_arg_parser.dart'; +import '../experimental/templates.dart'; import '../features.dart'; import '../flutter_manifest.dart'; import '../flutter_project_metadata.dart'; -import '../globals.dart' as globals; import '../ios/code_signing.dart'; import '../macos/swift_packages.dart'; import '../project.dart'; import '../runner/flutter_command.dart'; +import '../template.dart'; import 'create_base.dart'; const kPlatformHelp = @@ -36,10 +48,36 @@ const kPlatformHelp = 'When adding platforms to a plugin project, the pubspec.yaml will be updated with the requested platform. ' 'Adding desktop platforms requires the corresponding desktop config setting to be enabled.'; -class CreateCommand extends FlutterCommand with CreateBase { - CreateCommand({bool verboseHelp = false}) { - addPubOptions(); - argParser.addFlag( +class CreateCommand extends FlutterCommand with CreateBase, ExtensionArgParserMixin { + CreateCommand({ + required this.androidContext, + required this.appleContext, + required this.templateRenderer, + required super.toolContext, + this.extensionTemplateManager, + this.net, + this.pub, + super.verboseHelp = false, + }); + + final AndroidContext androidContext; + final AppleContext appleContext; + final ExtensionTemplateManager? extensionTemplateManager; + final Net? net; + final Pub? pub; + + @override + final TemplateRenderer templateRenderer; + + @override + ArgParser createBaseArgParser() { + final parser = ArgParser( + usageLineLength: toolContext.outputPreferences.wrapText + ? toolContext.outputPreferences.wrapColumn + : null, + ); + addPubOptions(parser: parser); + parser.addFlag( 'with-driver-test', help: '(deprecated) Historically, this added a flutter_driver dependency and generated a ' @@ -47,26 +85,25 @@ class CreateCommand extends FlutterCommand with CreateBase { '"integration_test" package: https://pub.dev/packages/integration_test', hide: !verboseHelp, ); - argParser.addFlag('overwrite', help: 'When performing operations, overwrite existing files.'); - argParser.addOption( + parser.addFlag('overwrite', help: 'When performing operations, overwrite existing files.'); + parser.addOption( 'description', defaultsTo: 'A new Flutter project.', - help: - 'The description to use for your new Flutter project. This string ends up in the pubspec.yaml file.', + help: 'The description to use for your new Flutter project. This string ends up in the pubspec.yaml file.', ); - argParser.addOption( + parser.addOption( 'org', defaultsTo: 'com.example', help: 'The organization responsible for your new Flutter project, in reverse domain name notation. ' 'This string is used in Java package names and as prefix in the iOS bundle identifier.', ); - argParser.addOption( + parser.addOption( 'project-name', help: 'The project name for this new Flutter project. This must be a valid dart package name.', ); - argParser.addOption( + parser.addOption( 'ios-language', abbr: 'i', defaultsTo: 'swift', @@ -77,29 +114,28 @@ class CreateCommand extends FlutterCommand with CreateBase { 'This flag will be removed in a future version of Flutter.', hide: !verboseHelp, ); - argParser.addOption( + parser.addOption( 'android-language', abbr: 'a', defaultsTo: 'kotlin', allowed: ['java', 'kotlin'], - help: - 'The language to use for Android-specific code, either Kotlin (recommended) or Java (legacy).', + help: 'The language to use for Android-specific code, either Kotlin (recommended) or Java (legacy).', ); - argParser.addFlag( + parser.addFlag( 'skip-name-checks', help: 'Allow the creation of applications and plugins with invalid names. ' 'This is only intended to enable testing of the tool itself.', hide: !verboseHelp, ); - argParser.addFlag( + parser.addFlag( 'implementation-tests', help: 'Include implementation tests that verify the template functions correctly. ' 'This is only intended to enable testing of the tool itself.', hide: !verboseHelp, ); - argParser.addOption( + parser.addOption( 'initial-create-revision', help: 'The Flutter SDK git commit hash to store in .migrate_config. This parameter is used by the tool ' @@ -112,19 +148,28 @@ class CreateCommand extends FlutterCommand with CreateBase { }; platformsAllowedHelp['darwin'] = 'A shared platform for iOS and macOS. (only supported for plugins)'; - addPlatformsOptions(customHelp: kPlatformHelp, allowedHelp: platformsAllowedHelp); + addPlatformsOptions( + parser: parser, + customHelp: kPlatformHelp, + allowedHelp: platformsAllowedHelp, + ); final List enabledTemplates = - ParsedFlutterTemplateType.enabledValues(featureFlags); - argParser.addOption( + ParsedFlutterTemplateType.enabledValues(featureFlags, extensionTemplateManager: null); + final bool isToolExtensionEnabled = featureFlags.isToolExtensionsEnabled; + parser.addOption( 'template', abbr: 't', - allowed: enabledTemplates.map((ParsedFlutterTemplateType t) => t.cliName), + allowed: isToolExtensionEnabled + ? null + : enabledTemplates.map((ParsedFlutterTemplateType t) => t.cliName), help: 'Specify the type of project to create.', valueHelp: 'type', - allowedHelp: CliEnum.allowedHelp(enabledTemplates), + allowedHelp: { + for (final ParsedFlutterTemplateType t in enabledTemplates) t.cliName: t.helpText, + }, ); - argParser.addOption( + parser.addOption( 'sample', abbr: 's', help: @@ -135,14 +180,14 @@ class CreateCommand extends FlutterCommand with CreateBase { valueHelp: 'id', hide: !verboseHelp, ); - argParser.addFlag( + parser.addFlag( 'empty', abbr: 'e', help: 'Specifies creating using an application template with a main.dart that is minimal, ' 'including no comments, as a starting point for a new application. Implies "--template=app".', ); - argParser.addOption( + parser.addOption( 'list-samples', help: 'Specifies a JSON output file for a listing of Flutter code samples ' @@ -150,6 +195,72 @@ class CreateCommand extends FlutterCommand with CreateBase { valueHelp: 'path', hide: !verboseHelp, ); + return parser; + } + + @override + Future initializeDynamicOptions() async { + final ExtensionTemplateManager? manager = extensionTemplateManager; + if (manager != null) { + try { + await manager.getProjectTemplates(); + if (manager.cachedTemplates.isNotEmpty) { + rebuildDynamicArgParser(); + } + } on Object catch (e) { + toolContext.logger.printTrace('Failed to initialize dynamic templates: $e'); + } + } + } + + @override + ArgParser buildDynamicArgParser(ArgParser dynamicParser) { + final ExtensionTemplateManager? manager = extensionTemplateManager; + final List projectTemplates = + manager?.cachedTemplates ?? const []; + if (projectTemplates.isEmpty) { + return dynamicParser; + } + return ExtensionArgParserMixin.cloneParser( + baseArgParser, + optionCloner: (ArgParser newParser, Option opt) { + if (opt.name != 'template') { + ExtensionArgParserMixin.copyOption(newParser, opt); + return; + } + final Map? allowedHelp = opt.allowedHelp != null + ? Map.of(opt.allowedHelp!) + : null; + final List? allowed = opt.allowed != null ? List.of(opt.allowed!) : null; + if (allowedHelp != null) { + for (final template in projectTemplates) { + if (!template.hidden) { + allowedHelp[template.name] = + 'Generate a project using the ${template.name} template.'; + } + } + } + if (allowed != null) { + for (final template in projectTemplates) { + if (!allowed.contains(template.name)) { + allowed.add(template.name); + } + } + } + newParser.addOption( + opt.name, + abbr: opt.abbr, + aliases: opt.aliases, + allowed: allowed, + allowedHelp: allowedHelp, + defaultsTo: opt.defaultsTo as String?, + help: opt.help, + hide: opt.hide, + mandatory: opt.mandatory, + valueHelp: opt.valueHelp, + ); + }, + ); } @override @@ -174,19 +285,25 @@ class CreateCommand extends FlutterCommand with CreateBase { createAndroidLanguage: stringArg('android-language'), ); - // Lazy-initialize the net utilities with values from the context. - late final _net = Net( - httpClientFactory: context.get(), - logger: globals.logger, - platform: globals.platform, - ); + @override + ToolContext get toolContext => super.toolContext!; /// The hostname for the Flutter docs for the current channel. String get _snippetsHost => - globals.flutterVersion.channel == 'stable' ? 'api.flutter.dev' : 'main-api.flutter.dev'; + toolContext.flutterVersion.channel == 'stable' ? 'api.flutter.dev' : 'main-api.flutter.dev'; + Net get _netInstance => net ?? Net(logger: toolContext.logger, platform: toolContext.platform); + Pub get _pubInstance => + pub ?? + Pub( + fileSystem: toolContext.fs, + logger: toolContext.logger, + processManager: toolContext.processManager, + platform: toolContext.platform, + botDetector: toolContext.botDetector, + ); + /// Fetches the code for a sample from the Flutter docs website. Future _fetchSampleFromServer(String sampleId) async { - // Sanity check the sampleId if (sampleId.contains(RegExp(r'[^-\w\.]'))) { throwToolExit( 'Sample ID "$sampleId" contains invalid characters. Check the ID in the ' @@ -195,7 +312,7 @@ class CreateCommand extends FlutterCommand with CreateBase { } final snippetsUri = Uri.https(_snippetsHost, 'snippets/$sampleId.dart'); - final List? data = await _net.fetchUrl(snippetsUri); + final List? data = await _netInstance.fetchUrl(snippetsUri); if (data == null || data.isEmpty) { return null; } @@ -205,7 +322,7 @@ class CreateCommand extends FlutterCommand with CreateBase { /// Fetches the samples index file from the Flutter docs website. Future _fetchSamplesIndexFromServer() async { final snippetsUri = Uri.https(_snippetsHost, 'snippets/index.json'); - final List? data = await _net.fetchUrl(snippetsUri, maxAttempts: 2); + final List? data = await _netInstance.fetchUrl(snippetsUri, maxAttempts: 2); if (data == null || data.isEmpty) { return null; } @@ -215,8 +332,10 @@ class CreateCommand extends FlutterCommand with CreateBase { /// Fetches the samples index file from the server and writes it to /// [outputFilePath]. Future _writeSamplesJson(String outputFilePath) async { + final FileSystem fs = toolContext.fs; + final Logger logger = toolContext.logger; try { - final File outputFile = globals.fs.file(outputFilePath); + final File outputFile = fs.file(outputFilePath); if (outputFile.existsSync()) { throwToolExit('File "$outputFilePath" already exists', exitCode: 1); } @@ -225,22 +344,33 @@ class CreateCommand extends FlutterCommand with CreateBase { throwToolExit('Unable to download samples', exitCode: 2); } else { outputFile.writeAsStringSync(samplesJson); - globals.printStatus('Wrote samples JSON to "$outputFilePath"'); + logger.printStatus('Wrote samples JSON to "$outputFilePath"'); } } on Exception catch (e) { throwToolExit('Failed to write samples JSON to "$outputFilePath": $e', exitCode: 2); } } - FlutterTemplateType _getProjectType(Directory projectDir) { - FlutterTemplateType? template; - FlutterTemplateType? detectedProjectType; + ParsedFlutterTemplateType _getProjectType(Directory projectDir) { + ParsedFlutterTemplateType? template; + ParsedFlutterTemplateType? detectedProjectType; final bool metadataExists = projectDir.absolute.childFile('.metadata').existsSync(); final String? templateArgument = stringArg('template'); if (templateArgument != null) { final ParsedFlutterTemplateType? parsedTemplate = ParsedFlutterTemplateType.fromCliName( templateArgument, + extensionTemplateManager: extensionTemplateManager, ); + if (parsedTemplate == null) { + final Iterable enabledTemplateNames = ParsedFlutterTemplateType.enabledValues( + featureFlags, + extensionTemplateManager: extensionTemplateManager, + ).map((ParsedFlutterTemplateType t) => t.cliName); + throwToolExit( + 'Expected one of ${enabledTemplateNames.join(', ')} ' + 'for option "--template", but got "$templateArgument"', + ); + } switch (parsedTemplate) { case RemovedFlutterTemplateType(): throwToolExit( @@ -251,14 +381,16 @@ class CreateCommand extends FlutterCommand with CreateBase { ); case FlutterTemplateType(): template = parsedTemplate; - case null: - break; + case ExtensionProjectTemplateType(): + template = parsedTemplate; } } // If the project directory exists and isn't empty, then try to determine the template // type from the project directory. if (projectDir.existsSync() && projectDir.listSync().isNotEmpty) { - detectedProjectType = determineTemplateType(); + detectedProjectType = determineTemplateType( + extensionTemplateManager: extensionTemplateManager, + ); if (detectedProjectType == null && metadataExists) { // We can only be definitive that this is the wrong type if the .metadata file // exists and contains a type that we don't understand, or doesn't contain a type. @@ -283,6 +415,16 @@ class CreateCommand extends FlutterCommand with CreateBase { @override Future runCommand() async { + final FileSystem fs = toolContext.fs; + final Logger logger = toolContext.logger; + final Platform platform = toolContext.platform; + final ProcessManager processManager = toolContext.processManager; + final FileSystemUtils fsUtils = toolContext.fileSystemUtils; + final Config config = toolContext.config; + final AnsiTerminal terminal = toolContext.terminal; + final Cache cache = toolContext.cache; + final FlutterProjectFactory projectFactory = toolContext.projectFactory; + final String? listSamples = stringArg('list-samples'); if (listSamples != null) { // _writeSamplesJson can potentially be long-lived. @@ -298,10 +440,19 @@ class CreateCommand extends FlutterCommand with CreateBase { String? sampleCode; final String? sampleArgument = stringArg('sample'); final bool emptyArgument = boolArg('empty'); - final FlutterTemplateType template = _getProjectType(projectDir); + + final ExtensionTemplateManager? manager = extensionTemplateManager; + if (manager != null) { + try { + await manager.getProjectTemplates(); + } on Object catch (e) { + toolContext.logger.printTrace('Failed to retrieve project templates: $e'); + } + } + final ParsedFlutterTemplateType template = _getProjectType(projectDir); if (template == FlutterTemplateType.pluginFfi) { - globals.printWarning( + logger.printWarning( 'The "plugin_ffi" template is deprecated and will be removed in a future ' 'version of Flutter. Use the "package_ffi" template instead. ' 'For more information, see: ' @@ -352,7 +503,7 @@ class CreateCommand extends FlutterCommand with CreateBase { exitCode: 2, ); } else if (argResults!.wasParsed('ios-language')) { - globals.printWarning( + logger.printWarning( 'The "--ios-language" option is deprecated and no longer has any effect. ' 'Swift is always used for iOS-specific code. ' 'This flag will be removed in a future version of Flutter.', @@ -365,14 +516,14 @@ class CreateCommand extends FlutterCommand with CreateBase { validateProjectDir(overwrite: overwrite); if (boolArg('with-driver-test')) { - globals.printWarning( + logger.printWarning( 'The "--with-driver-test" argument has been deprecated and will no longer add a flutter ' 'driver template. Instead, learn how to use package:integration_test by ' 'visiting https://pub.dev/packages/integration_test .', ); } - final String dartSdk = globals.cache.dartSdkBuild; + final String dartSdk = cache.dartSdkBuild; final bool includeIos; final bool includeDarwin; final bool includeAndroid; @@ -414,7 +565,7 @@ class CreateCommand extends FlutterCommand with CreateBase { includeIos = featureFlags.isIOSEnabled && platforms.contains('ios'); includeMacos = featureFlags.isMacOSEnabled && platforms.contains('macos'); if (darwinRequested && !darwinSupported) { - globals.printWarning( + logger.printWarning( 'Warning: To use the "darwin" platform, you must have both iOS and macOS enabled.\n' 'Run "flutter config --enable-ios --enable-macos-desktop" and try again.', ); @@ -429,14 +580,14 @@ class CreateCommand extends FlutterCommand with CreateBase { String? developmentTeam; if (includeIos) { developmentTeam = await getCodeSigningIdentityDevelopmentTeam( - processManager: globals.processManager, - platform: globals.platform, - logger: globals.logger, - config: globals.config, - terminal: globals.terminal, - fileSystem: globals.fs, - fileSystemUtils: globals.fsUtils, - plistParser: globals.plistParser, + processManager: processManager, + platform: platform, + logger: logger, + config: config, + terminal: terminal, + fileSystem: fs, + fileSystemUtils: fsUtils, + plistParser: appleContext.plistParser, ); } @@ -472,10 +623,10 @@ class CreateCommand extends FlutterCommand with CreateBase { gradleVersion: gradle.templateDefaultGradleVersion, ); - final String relativeDirPath = globals.fs.path.relative(projectDirPath); + final String relativeDirPath = fs.path.relative(projectDirPath); final bool creatingNewProject = !projectDir.existsSync() || projectDir.listSync().isEmpty; if (creatingNewProject) { - globals.printStatus('Creating project $relativeDirPath...'); + logger.printStatus('Creating project $relativeDirPath...'); } else { if (sampleCode != null && !overwrite) { throwToolExit( @@ -483,10 +634,10 @@ class CreateCommand extends FlutterCommand with CreateBase { 'must specify --overwrite for samples to overwrite.', ); } - globals.printStatus('Recreating project $relativeDirPath...'); + logger.printStatus('Recreating project $relativeDirPath...'); } - final Directory relativeDir = globals.fs.directory(projectDirPath); + final Directory relativeDir = fs.directory(projectDirPath); var generatedFileCount = 0; final PubContext pubContext; switch (template) { @@ -499,7 +650,7 @@ class CreateCommand extends FlutterCommand with CreateBase { templateContext, overwrite: overwrite, printStatusWhenWriting: !creatingNewProject, - projectType: template, + projectType: template as FlutterTemplateType?, ); pubContext = PubContext.create; case FlutterTemplateType.module: @@ -524,7 +675,7 @@ class CreateCommand extends FlutterCommand with CreateBase { templateContext, overwrite: overwrite, printStatusWhenWriting: !creatingNewProject, - projectType: template, + projectType: template as FlutterTemplateType, ); pubContext = PubContext.createPlugin; case FlutterTemplateType.pluginFfi: @@ -533,7 +684,7 @@ class CreateCommand extends FlutterCommand with CreateBase { templateContext, overwrite: overwrite, printStatusWhenWriting: !creatingNewProject, - projectType: template, + projectType: template as FlutterTemplateType, ); pubContext = PubContext.createPlugin; case FlutterTemplateType.packageFfi: @@ -542,16 +693,105 @@ class CreateCommand extends FlutterCommand with CreateBase { templateContext, overwrite: overwrite, printStatusWhenWriting: !creatingNewProject, - projectType: template, + projectType: template as FlutterTemplateType, ); pubContext = PubContext.createPackage; + // Handle custom templates provided by tool extensions. + case ExtensionProjectTemplateType(): + final ExtensionTemplateManager? manager = extensionTemplateManager; + if (manager == null) { + throwToolExit('ExtensionTemplateManager is not available.'); + } + // Resolve the custom template by name from the cached templates. + ProjectTemplate? customTemplate; + for (final ProjectTemplate t in manager.cachedTemplates) { + if (t.name == template.cliName) { + customTemplate = t; + break; + } + } + if (customTemplate == null) { + throwToolExit('Custom template not found in manager: ${template.cliName}'); + } + // Resolve the absolute path to the template directory. + final Directory templateDir = manager.resolveTemplateDirectory(customTemplate.templatePath); + if (!templateDir.existsSync()) { + throwToolExit('Custom template source directory does not exist: ${templateDir.path}'); + } + + // Request the extension to generate template parameters based on the host context. + final Map renderedParameters = await manager.generateTemplateParameters( + customTemplate.name, + templateContext, + ); + + // Resolve all dependency templates. + final templateSources = []; + final imageSources = []; + for (final String depName in customTemplate.templateDependencies) { + final Directory internalDir = templatePathProvider.directoryInPackage(depName, fs); + if (internalDir.existsSync()) { + templateSources.add(internalDir); + final Directory imageDir = await templatePathProvider.imageDirectory( + depName, + fs, + logger, + ); + if (imageDir.existsSync()) { + imageSources.add(imageDir); + } + } else { + ProjectTemplate? depTemplate; + for (final ProjectTemplate cached in manager.cachedTemplates) { + if (cached.name == depName) { + depTemplate = cached; + break; + } + } + if (depTemplate != null) { + final Directory depDir = manager.resolveTemplateDirectory(depTemplate.templatePath); + if (!depDir.existsSync()) { + throwToolExit( + 'Custom template dependency "$depName" source directory does not exist: ${depDir.path}', + ); + } + templateSources.add(depDir); + } else { + throwToolExit( + 'Template dependency "$depName" not found in internal templates or active extensions.', + ); + } + } + } + templateSources.add(templateDir); + + final Template t = await Template.fromDirectories( + templateSources, + imageSourceDirectories: imageSources, + fileSystem: fs, + logger: logger, + templateRenderer: templateRenderer, + ); + + // Render the template into the project directory. + generatedFileCount += t.render( + relativeDir, + renderedParameters, + overwriteExisting: overwrite, + printStatusWhenWriting: !creatingNewProject, + ); + + pubContext = PubContext.create; + case RemovedFlutterTemplateType(): + throwToolExit('Unsupported template type: ${template.cliName}'); } _generatePubspecLock(relativeDir); if (shouldCallPubGet) { - final FlutterProject project = FlutterProject.fromDirectory(relativeDir); - await pub.get( + projectFactory.invalidate(relativeDir); + final FlutterProject project = projectFactory.fromDirectory(relativeDir); + await _pubInstance.get( context: pubContext, project: project, offline: offline, @@ -585,49 +825,54 @@ class CreateCommand extends FlutterCommand with CreateBase { if (sampleCode != null) { _applySample(relativeDir, sampleCode); } - globals.printStatus('Wrote $generatedFileCount files.'); - globals.printStatus('\nAll done!'); + logger.printStatus('Wrote $generatedFileCount files.'); + logger.printStatus('\nAll done!'); final application = '${emptyArgument ? 'empty ' : ''}${sampleCode != null ? 'sample ' : ''}application'; if (generatePackage) { - final String relativeMainPath = globals.fs.path.normalize( - globals.fs.path.join(relativeDirPath, 'lib', '${templateContext['projectName']}.dart'), + final String relativeMainPath = fs.path.normalize( + fs.path.join(relativeDirPath, 'lib', '${templateContext['projectName']}.dart'), ); - globals.printStatus('Your package code is in $relativeMainPath'); + logger.printStatus('Your package code is in $relativeMainPath'); } else if (generateModule) { - final String relativeMainPath = globals.fs.path.normalize( - globals.fs.path.join(relativeDirPath, 'lib', 'main.dart'), + final String relativeMainPath = fs.path.normalize( + fs.path.join(relativeDirPath, 'lib', 'main.dart'), ); - globals.printStatus('Your module code is in $relativeMainPath.'); + logger.printStatus('Your module code is in $relativeMainPath.'); } else if (generateMethodChannelsPlugin || generateFfiPlugin) { - final String relativePluginPath = globals.fs.path.normalize( - globals.fs.path.relative(projectDirPath), - ); + final String relativePluginPath = fs.path.normalize(fs.path.relative(projectDirPath)); final List requestedPlatforms = _getUserRequestedPlatforms(); final String platformsString = requestedPlatforms.join(', '); - _printPluginDirectoryLocationMessage(relativePluginPath, projectName, platformsString); + _printPluginDirectoryLocationMessage( + relativePluginPath, + projectName, + platformsString, + fs: fs, + logger: logger, + ); if (!creatingNewProject && requestedPlatforms.isNotEmpty) { - _printPluginUpdatePubspecMessage(relativePluginPath, platformsString); - } else if (_getSupportedPlatformsInPlugin(projectDir).isEmpty) { - _printNoPluginMessage(); + _printPluginUpdatePubspecMessage(relativePluginPath, platformsString, logger: logger); + } else if (_getSupportedPlatformsInPlugin(projectDir, fs: fs, logger: logger).isEmpty) { + _printNoPluginMessage(logger: logger); } final List platformsToWarn = _getPlatformWarningList(requestedPlatforms); if (platformsToWarn.isNotEmpty) { - _printWarningDisabledPlatform(platformsToWarn); + _printWarningDisabledPlatform(platformsToWarn, logger: logger); } final template = generateMethodChannelsPlugin ? 'plugin' : 'plugin_ffi'; - _printPluginAddPlatformMessage(relativePluginPath, template); + _printPluginAddPlatformMessage( + relativePluginPath, + template, + logger: logger, + usesPigeon: generateMethodChannelsPlugin, + ); } else { // Tell the user the next steps. - final FlutterProject project = FlutterProject.fromDirectory( - globals.fs.directory(projectDirPath), - ); + final FlutterProject project = projectFactory.fromDirectory(fs.directory(projectDirPath)); final FlutterProject app = project.hasExampleApp ? project.example : project; - final String relativeAppPath = globals.fs.path.normalize( - globals.fs.path.relative(app.directory.path), - ); - final String relativeAppMain = globals.fs.path.join(relativeAppPath, 'lib', 'main.dart'); + final String relativeAppPath = fs.path.normalize(fs.path.relative(app.directory.path)); + final String relativeAppMain = fs.path.join(relativeAppPath, 'lib', 'main.dart'); final List requestedPlatforms = _getUserRequestedPlatforms(); final String commandsToRun = [ @@ -636,7 +881,7 @@ class CreateCommand extends FlutterCommand with CreateBase { ].join('\n'); // Let them know a summary of the state of their tooling. - globals.printStatus(''' + logger.printStatus(''' You can find general documentation for Flutter at: https://docs.flutter.dev/ Detailed API documentation is available at: https://api.flutter.dev/ If you prefer video documentation, consider: https://www.youtube.com/c/flutterdev @@ -650,20 +895,23 @@ Your $application code is in $relativeAppMain. // Show warning if any selected platform is not enabled final List platformsToWarn = _getPlatformWarningList(requestedPlatforms); if (platformsToWarn.isNotEmpty) { - _printWarningDisabledPlatform(platformsToWarn); + _printWarningDisabledPlatform(platformsToWarn, logger: logger); } } // Show warning for Java/AGP or Java/Gradle incompatibility if building for // Android and Java version has been detected. - if (includeAndroid && globals.java?.version != null) { + final Java? java = androidContext.java; + if (includeAndroid && java != null && java.version != null && template is FlutterTemplateType) { _printIncompatibleJavaAgpGradleVersionsWarning( - javaVersion: versionToParsableString(globals.java?.version)!, - templateGradleVersion: templateContext['gradleVersion']! as String, + fs: fs, + javaVersion: versionToParsableString(java.version)!, + logger: logger, + projectDirPath: projectDirPath, + projectType: template, templateAgpVersion: templateContext['agpVersion']! as String, templateAgpVersionForModule: templateContext['agpVersionForModule']! as String, - projectType: template, - projectDirPath: projectDirPath, + templateGradleVersion: templateContext['gradleVersion']! as String, ); } @@ -682,7 +930,7 @@ Your $application code is in $relativeAppMain. : 'A new Flutter module project.'; templateContext['description'] = description; generatedCount += await renderTemplate( - globals.fs.path.join('module', 'common'), + toolContext.fs.path.join('module', 'common'), directory, templateContext, overwrite: overwrite, @@ -725,9 +973,15 @@ Your $application code is in $relativeAppMain. templateContext[platform] = false; } } + templateContext['iosOrMacOS'] = + (templateContext['ios'] as bool? ?? false) || (templateContext['macos'] as bool? ?? false); final List platformsToAdd = _getSupportedPlatformsFromTemplateContext(templateContext); - final List existingPlatforms = _getSupportedPlatformsInPlugin(directory); + final List existingPlatforms = _getSupportedPlatformsInPlugin( + directory, + fs: toolContext.fs, + logger: toolContext.logger, + ); for (final existingPlatform in existingPlatforms) { // re-generate files for existing platforms templateContext[existingPlatform] = true; @@ -770,7 +1024,7 @@ Your $application code is in $relativeAppMain. templateContext.remove('withSwiftPackageManager'); } - final FlutterProject project = FlutterProject.fromDirectory(directory); + final FlutterProject project = toolContext.projectFactory.fromDirectory(directory); final generateAndroid = templateContext['android'] == true; if (generateAndroid) { gradle.updateLocalProperties(project: project, requireAndroidSdk: false); @@ -828,7 +1082,11 @@ Your $application code is in $relativeAppMain. } final List platformsToAdd = _getSupportedPlatformsFromTemplateContext(templateContext); - final List existingPlatforms = _getSupportedPlatformsInPlugin(directory); + final List existingPlatforms = _getSupportedPlatformsInPlugin( + directory, + fs: toolContext.fs, + logger: toolContext.logger, + ); for (final existingPlatform in existingPlatforms) { // re-generate files for existing platforms templateContext[existingPlatform] = true; @@ -849,7 +1107,7 @@ Your $application code is in $relativeAppMain. printStatusWhenWriting: printStatusWhenWriting, ); - final FlutterProject project = FlutterProject.fromDirectory(directory); + final FlutterProject project = toolContext.projectFactory.fromDirectory(directory); final generateAndroid = templateContext['android'] == true; if (generateAndroid) { gradle.updateLocalProperties(project: project, requireAndroidSdk: false); @@ -913,7 +1171,7 @@ Your $application code is in $relativeAppMain. printStatusWhenWriting: printStatusWhenWriting, ); - final FlutterProject project = FlutterProject.fromDirectory(directory); + final FlutterProject project = toolContext.projectFactory.fromDirectory(directory); final projectName = templateContext['projectName'] as String?; final exampleProjectName = '${projectName}_example'; @@ -1032,12 +1290,16 @@ Your $application code is in $relativeAppMain. } // Determine what platforms are supported based on generated files. -List _getSupportedPlatformsInPlugin(Directory projectDir) { - final String pubspecPath = globals.fs.path.join(projectDir.absolute.path, 'pubspec.yaml'); +List _getSupportedPlatformsInPlugin( + Directory projectDir, { + required FileSystem fs, + required Logger logger, +}) { + final String pubspecPath = fs.path.join(projectDir.absolute.path, 'pubspec.yaml'); final FlutterManifest? manifest = FlutterManifest.createFromPath( pubspecPath, - fileSystem: globals.fs, - logger: globals.logger, + fileSystem: fs, + logger: logger, ); final Map? validSupportedPlatforms = manifest?.validSupportedPlatforms; final List platforms = validSupportedPlatforms == null @@ -1049,16 +1311,13 @@ List _getSupportedPlatformsInPlugin(Directory projectDir) { void _printPluginDirectoryLocationMessage( String pluginPath, String projectName, - String platformsString, -) { - final String relativePluginMain = globals.fs.path.join(pluginPath, 'lib', '$projectName.dart'); - final String relativeExampleMain = globals.fs.path.join( - pluginPath, - 'example', - 'lib', - 'main.dart', - ); - globals.printStatus(''' + String platformsString, { + required FileSystem fs, + required Logger logger, +}) { + final String relativePluginMain = fs.path.join(pluginPath, 'lib', '$projectName.dart'); + final String relativeExampleMain = fs.path.join(pluginPath, 'example', 'lib', 'main.dart'); + logger.printStatus(''' Your plugin code is in $relativePluginMain. @@ -1066,7 +1325,7 @@ Your example app code is in $relativeExampleMain. '''); if (platformsString.isNotEmpty) { - globals.printStatus(''' + logger.printStatus(''' Host platform code is in the $platformsString directories under $pluginPath. To edit platform code in an IDE see https://flutter.dev/to/edit-plugins. @@ -1074,8 +1333,12 @@ To edit platform code in an IDE see https://flutter.dev/to/edit-plugins. } } -void _printPluginUpdatePubspecMessage(String pluginPath, String platformsString) { - globals.printStatus( +void _printPluginUpdatePubspecMessage( + String pluginPath, + String platformsString, { + required Logger logger, +}) { + logger.printStatus( ''' You need to update $pluginPath/pubspec.yaml to support $platformsString. ''', @@ -1084,15 +1347,21 @@ You need to update $pluginPath/pubspec.yaml to support $platformsString. ); } -void _printNoPluginMessage() { - globals.printError(''' +void _printNoPluginMessage({required Logger logger}) { + logger.printError(''' You've created a plugin project that doesn't yet support any platforms. '''); } -void _printPluginAddPlatformMessage(String pluginPath, String template) { - globals.printStatus(''' -To add platforms, run `flutter create -t $template --platforms .` under $pluginPath. +void _printPluginAddPlatformMessage( + String pluginPath, + String template, { + required Logger logger, + required bool usesPigeon, +}) { + logger.printStatus(''' +To add platforms, run `flutter create -t $template --platforms .` under $pluginPath, +then update ${usesPigeon ? 'pigeon/messages.dart and ' : ''}pubspec.yaml to include the new platforms. For more information, see https://flutter.dev/to/pubspec-plugin-platforms. '''); @@ -1114,7 +1383,7 @@ List _getPlatformWarningList(List requestedPlatforms) { return platformsToWarn; } -void _printWarningDisabledPlatform(List platforms) { +void _printWarningDisabledPlatform(List platforms, {required Logger logger}) { final desktop = []; final web = []; final darwin = []; @@ -1134,19 +1403,19 @@ void _printWarningDisabledPlatform(List platforms) { final platforms = desktop.length > 1 ? 'platforms' : 'platform'; final verb = desktop.length > 1 ? 'are' : 'is'; - globals.printStatus(''' + logger.printStatus(''' The desktop $platforms: ${desktop.join(', ')} $verb currently not supported on your local environment. For more details, see: https://flutter.dev/to/add-desktop-support '''); } if (web.isNotEmpty) { - globals.printStatus(''' + logger.printStatus(''' The web is currently not supported on your local environment. For more details, see: https://flutter.dev/to/add-web-support '''); } if (darwin.isNotEmpty) { - globals.printStatus(''' + logger.printStatus(''' The darwin platform is currently not supported on your local environment. You must have a macOS host with Xcode installed to develop for iOS or macOS. '''); @@ -1160,21 +1429,23 @@ You must have a macOS host with Xcode installed to develop for iOS or macOS. // compatible, meaning that the Java version may only conflict with one of the // template Gradle or AGP versions. void _printIncompatibleJavaAgpGradleVersionsWarning({ + required FileSystem fs, required String javaVersion, - required String templateGradleVersion, + required Logger logger, + required String projectDirPath, + required FlutterTemplateType projectType, required String templateAgpVersion, required String templateAgpVersionForModule, - required FlutterTemplateType projectType, - required String projectDirPath, + required String templateGradleVersion, }) { // Determine if the Java version specified conflicts with the template Gradle or AGP version. final bool javaGradleVersionsCompatible = gradle.validateJavaAndGradle( - globals.logger, + logger, javaVersion: javaVersion, gradleVersion: templateGradleVersion, ); bool javaAgpVersionsCompatible = gradle.validateJavaAndAgp( - globals.logger, + logger, javaV: javaVersion, agpV: templateAgpVersion, ); @@ -1184,7 +1455,7 @@ void _printIncompatibleJavaAgpGradleVersionsWarning({ Version.parse(templateAgpVersion)! < Version.parse(templateAgpVersionForModule)!) { // If a module is being created, make sure to check for Java/AGP compatibility between the highest used version of AGP in the module template. javaAgpVersionsCompatible = gradle.validateJavaAndAgp( - globals.logger, + logger, javaV: javaVersion, agpV: templateAgpVersionForModule, ); @@ -1212,17 +1483,17 @@ void _printIncompatibleJavaAgpGradleVersionsWarning({ // Gradle template version incompatible with Java version. final gradle.JavaGradleCompat? validCompatibleGradleVersionRange = gradle - .getValidGradleVersionRangeForJavaVersion(globals.logger, javaV: javaVersion); + .getValidGradleVersionRangeForJavaVersion(logger, javaV: javaVersion); final compatibleGradleVersionMessage = validCompatibleGradleVersionRange == null ? '' : ' (compatible Gradle version range: ${validCompatibleGradleVersionRange.gradleMin} - ${validCompatibleGradleVersionRange.gradleMax})'; - globals.printWarning(''' + logger.printWarning(''' $incompatibleVersionsAndRecommendedOptionMessage Alternatively, to continue using your configured Java version, update the Gradle version specified in the following file to a compatible Gradle version$compatibleGradleVersionMessage: -${_getGradleWrapperPropertiesFilePath(projectType, projectDirPath)} +${_getGradleWrapperPropertiesFilePath(projectType, projectDirPath, fs: fs)} You may also update the Gradle version used by running `./gradlew wrapper --gradle-version=`. @@ -1239,14 +1510,14 @@ used. // AGP template version incompatible with Java version. final gradle.JavaAgpCompat? minimumCompatibleAgpVersion = gradle - .getMinimumAgpVersionForJavaVersion(globals.logger, javaV: javaVersion); + .getMinimumAgpVersionForJavaVersion(logger, javaV: javaVersion); final compatibleAgpVersionMessage = minimumCompatibleAgpVersion == null ? '' : ' (minimum compatible AGP version: ${minimumCompatibleAgpVersion.agpMin})'; final gradleBuildFilePaths = - ' ${_getBuildGradleConfigurationFilePaths(projectType, projectDirPath)!.join('\n - ')}'; + ' ${_getBuildGradleConfigurationFilePaths(projectType, projectDirPath, fs: fs)!.join('\n - ')}'; - globals.printWarning(''' + logger.printWarning(''' $incompatibleVersionsAndRecommendedOptionMessage Alternatively, to continue using your current Java version, update the AGP @@ -1295,17 +1566,18 @@ $validJavaRangeMessage. Configure this Java version globally for Flutter by runn // generated project type. String? _getGradleWrapperPropertiesFilePath( FlutterTemplateType projectType, - String projectDirPath, -) { + String projectDirPath, { + required FileSystem fs, +}) { var gradleWrapperPropertiesFilePath = ''; switch (projectType) { case FlutterTemplateType.app: - gradleWrapperPropertiesFilePath = globals.fs.path.join( + gradleWrapperPropertiesFilePath = fs.path.join( projectDirPath, 'android/gradle/wrapper/gradle-wrapper.properties', ); case FlutterTemplateType.module: - gradleWrapperPropertiesFilePath = globals.fs.path.join( + gradleWrapperPropertiesFilePath = fs.path.join( projectDirPath, '.android/gradle/wrapper/gradle-wrapper.properties', ); @@ -1325,27 +1597,26 @@ String? _getGradleWrapperPropertiesFilePath( // project type. List? _getBuildGradleConfigurationFilePaths( FlutterTemplateType projectType, - String projectDirPath, -) { + String projectDirPath, { + required FileSystem fs, +}) { final buildGradleConfigurationFilePaths = []; switch (projectType) { case FlutterTemplateType.app: case FlutterTemplateType.pluginFfi: - buildGradleConfigurationFilePaths.add( - globals.fs.path.join(projectDirPath, 'android/build.gradle'), - ); + buildGradleConfigurationFilePaths.add(fs.path.join(projectDirPath, 'android/build.gradle')); case FlutterTemplateType.module: const moduleBuildGradleFilePath = '.android/build.gradle'; const moduleAppBuildGradleFlePath = '.android/app/build.gradle'; const moduleFlutterBuildGradleFilePath = '.android/Flutter/build.gradle'; buildGradleConfigurationFilePaths.addAll([ - globals.fs.path.join(projectDirPath, moduleBuildGradleFilePath), - globals.fs.path.join(projectDirPath, moduleAppBuildGradleFlePath), - globals.fs.path.join(projectDirPath, moduleFlutterBuildGradleFilePath), + fs.path.join(projectDirPath, moduleBuildGradleFilePath), + fs.path.join(projectDirPath, moduleAppBuildGradleFlePath), + fs.path.join(projectDirPath, moduleFlutterBuildGradleFilePath), ]); case FlutterTemplateType.plugin: buildGradleConfigurationFilePaths.add( - globals.fs.path.join(projectDirPath, 'android/app/build.gradle'), + fs.path.join(projectDirPath, 'android/app/build.gradle'), ); case FlutterTemplateType.package: case FlutterTemplateType.packageFfi: diff --git a/packages/flutter_tools/lib/src/commands/create_base.dart b/packages/flutter_tools/lib/src/commands/create_base.dart index 6f9caf30..1c7eeefd 100644 --- a/packages/flutter_tools/lib/src/commands/create_base.dart +++ b/packages/flutter_tools/lib/src/commands/create_base.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:args/args.dart'; import 'package:meta/meta.dart'; import 'package:uuid/uuid.dart'; import 'package:yaml/yaml.dart'; @@ -9,14 +10,21 @@ import 'package:yaml/yaml.dart'; import '../android/gradle_utils.dart' as gradle; import '../base/common.dart'; import '../base/file_system.dart'; +import '../base/logger.dart'; +import '../base/os.dart'; +import '../base/platform.dart'; +import '../base/template.dart'; import '../base/utils.dart'; import '../cache.dart'; +import '../context/tool_context.dart'; import '../convert.dart'; +import '../experimental/templates.dart'; import '../flutter_project_metadata.dart'; -import '../globals.dart' as globals; +import '../isolated/mustache_template.dart'; import '../project.dart'; import '../runner/flutter_command.dart'; import '../template.dart'; +import '../version.dart'; const _kAvailablePlatforms = ['ios', 'android', 'windows', 'linux', 'macos', 'web']; @@ -39,6 +47,11 @@ const _kDefaultPlatformArgumentHelp = /// Common behavior for `flutter create` and `flutter widget-preview start` commands. mixin CreateBase on FlutterCommand { + ToolContext get _context => toolContext!; + + @protected + TemplateRenderer get templateRenderer => const MustacheTemplateRenderer(); + /// Pattern for a Windows file system drive (e.g. "D:"). /// /// `dart:io` does not recognize strings matching this pattern as absolute @@ -53,7 +66,9 @@ mixin CreateBase on FlutterCommand { @visibleForTesting Directory get projectDir { final String argProjectDir = argResults!.rest.first; - if (globals.platform.isWindows && kWindowsDrivePattern.hasMatch(argProjectDir)) { + final Platform platform = _context.platform; + final FileSystem fs = _context.fs; + if (platform.isWindows && kWindowsDrivePattern.hasMatch(argProjectDir)) { throwToolExit( 'You attempted to create a flutter project at the path "$argProjectDir", which is the name of a drive. This ' 'is usually a mistake--you probably want to specify a containing directory, like "$argProjectDir\\app_name". ' @@ -61,13 +76,14 @@ mixin CreateBase on FlutterCommand { '"$argProjectDir\\".', ); } - return globals.fs.directory(argResults!.rest.first); + return fs.directory(argProjectDir); } /// The normalized absolute path of [projectDir]. @protected String get projectDirPath { - return globals.fs.path.normalize(projectDir.absolute.path); + final FileSystem fs = _context.fs; + return fs.path.normalize(projectDir.absolute.path); } @protected @@ -82,8 +98,8 @@ mixin CreateBase on FlutterCommand { /// Adds `--pub` and `--offline` options. @protected - void addPubOptions() { - argParser + void addPubOptions({ArgParser? parser}) { + (parser ?? argParser) ..addFlag( 'pub', defaultsTo: true, @@ -102,8 +118,12 @@ mixin CreateBase on FlutterCommand { /// /// The help message of the argument is replaced with `customHelp` if `customHelp` is not null. @protected - void addPlatformsOptions({String? customHelp, required Map allowedHelp}) { - argParser.addMultiOption( + void addPlatformsOptions({ + ArgParser? parser, + String? customHelp, + required Map allowedHelp, + }) { + (parser ?? argParser).addMultiOption( 'platforms', help: customHelp ?? _kDefaultPlatformArgumentHelp, aliases: ['platform'], @@ -148,20 +168,26 @@ mixin CreateBase on FlutterCommand { /// Throws assertion if [projectDir] does not exist or empty. /// Returns null if no project type can be determined. @protected - FlutterTemplateType? determineTemplateType() { + ParsedFlutterTemplateType? determineTemplateType({ + required ExtensionTemplateManager? extensionTemplateManager, + }) { assert(projectDir.existsSync() && projectDir.listSync().isNotEmpty); - final File metadataFile = globals.fs.file( - globals.fs.path.join(projectDir.absolute.path, '.metadata'), + final FileSystem fs = _context.fs; + final Logger logger = _context.logger; + final File metadataFile = fs.file(fs.path.join(projectDir.absolute.path, '.metadata')); + final projectMetadata = FlutterProjectMetadata( + metadataFile, + logger, + extensionTemplateManager: extensionTemplateManager, ); - final projectMetadata = FlutterProjectMetadata(metadataFile, globals.logger); - final FlutterTemplateType? projectType = projectMetadata.projectType; + final ParsedFlutterTemplateType? projectType = projectMetadata.projectType; if (projectType != null) { return projectType; } bool exists(List path) { - return globals.fs - .directory(globals.fs.path.joinAll([projectDir.absolute.path, ...path])) + return fs + .directory(fs.path.joinAll([projectDir.absolute.path, ...path])) .existsSync(); } @@ -186,15 +212,17 @@ mixin CreateBase on FlutterCommand { Future getOrganization() async { String? organization = stringArg('org'); if (!argResults!.wasParsed('org')) { - final FlutterProject project = FlutterProject.fromDirectory(projectDir); - final Set existingOrganizations = await project.organizationNames; - if (existingOrganizations.length == 1) { - organization = existingOrganizations.first; - } else if (existingOrganizations.length > 1) { - throwToolExit( - 'Ambiguous organization in existing files: $existingOrganizations. ' - 'The --org command line argument must be specified to recreate project.', - ); + if (projectDir.existsSync() && projectDir.listSync().isNotEmpty) { + final FlutterProject project = _context.projectFactory.fromDirectory(projectDir); + final Set existingOrganizations = await project.organizationNames; + if (existingOrganizations.length == 1) { + organization = existingOrganizations.first; + } else if (existingOrganizations.length > 1) { + throwToolExit( + 'Ambiguous organization in existing files: $existingOrganizations. ' + 'The --org command line argument must be specified to recreate project.', + ); + } } } if (organization == null) { @@ -206,20 +234,28 @@ mixin CreateBase on FlutterCommand { /// Throws with exit 2 if the project directory is illegal. @protected void validateProjectDir({bool overwrite = false}) { - if (globals.fs.path.isWithin(flutterRoot, projectDirPath)) { + final FileSystem fs = _context.fs; + if (fs.path.isWithin(flutterRoot, projectDirPath)) { // Make exception for dev and examples to facilitate example project development. - final String examplesDirectory = globals.fs.path.join(flutterRoot, 'examples'); - final String devDirectory = globals.fs.path.join(flutterRoot, 'dev'); - final String engineExamplesDirectory = globals.fs.path.join( + final String examplesDirectory = fs.path.join(flutterRoot, 'examples'); + final String packageExamplesDirectory = fs.path.join( + flutterRoot, + 'packages', + 'flutter', + 'examples', + ); + final String devDirectory = fs.path.join(flutterRoot, 'dev'); + final String engineExamplesDirectory = fs.path.join( flutterRoot, 'engine', 'src', 'flutter', 'examples', ); - if (!globals.fs.path.isWithin(examplesDirectory, projectDirPath) && - !globals.fs.path.isWithin(devDirectory, projectDirPath) && - !globals.fs.path.isWithin(engineExamplesDirectory, projectDirPath)) { + if (!fs.path.isWithin(examplesDirectory, projectDirPath) && + !fs.path.isWithin(packageExamplesDirectory, projectDirPath) && + !fs.path.isWithin(devDirectory, projectDirPath) && + !fs.path.isWithin(engineExamplesDirectory, projectDirPath)) { throwToolExit( 'Cannot create a project within the Flutter SDK. ' "Target directory '$projectDirPath' is within the Flutter SDK at '$flutterRoot'.", @@ -230,7 +266,7 @@ mixin CreateBase on FlutterCommand { // If the destination directory is actually a file, then we refuse to // overwrite, on the theory that the user probably didn't expect it to exist. - if (globals.fs.isFileSync(projectDirPath)) { + if (fs.isFileSync(projectDirPath)) { final message = "Invalid project name: '$projectDirPath' - refers to an existing file."; throwToolExit( overwrite ? '$message Refusing to overwrite a file with a directory.' : message, @@ -242,7 +278,7 @@ mixin CreateBase on FlutterCommand { return; } - final FileSystemEntityType type = globals.fs.typeSync(projectDirPath); + final FileSystemEntityType type = fs.typeSync(projectDirPath); // ignore: exhaustive_cases, https://github.com/dart-lang/linter/issues/3017 switch (type) { @@ -270,7 +306,8 @@ mixin CreateBase on FlutterCommand { String? projectName = stringArg('project-name'); if (projectName == null) { - final File pubspec = globals.fs.directory(projectDirPath).childFile('pubspec.yaml'); + final FileSystem fs = _context.fs; + final File pubspec = fs.directory(projectDirPath).childFile('pubspec.yaml'); if (pubspec.existsSync()) { final String pubspecContents = pubspec.readAsStringSync(); @@ -290,7 +327,7 @@ mixin CreateBase on FlutterCommand { } } - final String projectDirName = globals.fs.path.basename(projectDirPath); + final String projectDirName = fs.path.basename(projectDirPath); projectName ??= projectDirName; } @@ -332,10 +369,11 @@ mixin CreateBase on FlutterCommand { bool darwin = false, bool implementationTests = false, }) { - final String pluginDartClass = _createPluginClassName(projectName); - final pluginClass = pluginDartClass.endsWith('Plugin') - ? pluginDartClass - : '${pluginDartClass}Plugin'; + final FlutterVersion flutterVersion = _context.flutterVersion; + final String projectNameUpperCamelCase = _createPluginClassName(projectName); + final pluginClass = projectNameUpperCamelCase.endsWith('Plugin') + ? projectNameUpperCamelCase + : '${projectNameUpperCamelCase}Plugin'; final String pluginClassSnakeCase = snakeCase(pluginClass); final String pluginClassCapitalSnakeCase = pluginClassSnakeCase.toUpperCase(); final String pluginClassLowerCamelCase = @@ -350,8 +388,11 @@ mixin CreateBase on FlutterCommand { return { 'organization': organization, 'projectName': projectName, + 'projectNameUpperCamelCase': projectNameUpperCamelCase, + 'projectNameCapitalSnakeCase': projectName.toUpperCase(), 'titleCaseProjectName': titleCaseProjectName, 'androidIdentifier': androidIdentifier, + 'androidIdentifierPosixPath': androidIdentifier.replaceAll('.', '/'), 'iosIdentifier': appleIdentifier, 'macosIdentifier': appleIdentifier, 'darwinIdentifier': appleIdentifier, @@ -365,7 +406,7 @@ mixin CreateBase on FlutterCommand { 'pluginClassSnakeCase': pluginClassSnakeCase, 'pluginClassLowerCamelCase': pluginClassLowerCamelCase, 'pluginClassCapitalSnakeCase': pluginClassCapitalSnakeCase, - 'pluginDartClass': pluginDartClass, + 'pluginDartClass': projectNameUpperCamelCase, 'pluginProjectUUID': const Uuid().v4().toUpperCase(), 'withFfi': withFfiPluginHook || withFfiPackage, 'withFfiPackage': withFfiPackage, @@ -374,11 +415,12 @@ mixin CreateBase on FlutterCommand { 'withSwiftPackageManager': withSwiftPackageManager, 'withPluginHook': withFfiPluginHook || withFfiPackage || withPlatformChannelPluginHook, 'withEmptyMain': withEmptyMain, + 'withJava': androidLanguage == 'java', 'androidLanguage': androidLanguage, 'hasIosDevelopmentTeam': iosDevelopmentTeam != null && iosDevelopmentTeam.isNotEmpty, 'iosDevelopmentTeam': iosDevelopmentTeam ?? '', - 'flutterRevision': escapeYamlString(globals.flutterVersion.frameworkRevision), - 'flutterChannel': escapeYamlString(globals.flutterVersion.getBranchName()), // may contain PII + 'flutterRevision': escapeYamlString(flutterVersion.frameworkRevision), + 'flutterChannel': escapeYamlString(flutterVersion.getBranchName()), // may contain PII 'ios': ios, 'android': android, 'web': web, @@ -413,11 +455,13 @@ mixin CreateBase on FlutterCommand { bool overwrite = false, bool printStatusWhenWriting = true, }) async { + final FileSystem fs = _context.fs; + final Logger logger = _context.logger; final Template template = await Template.fromName( templateName, - fileSystem: globals.fs, - logger: globals.logger, - templateRenderer: globals.templateRenderer, + fileSystem: fs, + logger: logger, + templateRenderer: templateRenderer, templateManifest: _templateManifest, ); return template.render( @@ -441,12 +485,14 @@ mixin CreateBase on FlutterCommand { bool overwrite = false, bool printStatusWhenWriting = true, }) async { + final FileSystem fs = _context.fs; + final Logger logger = _context.logger; final Template template = await Template.merged( names, directory, - fileSystem: globals.fs, - logger: globals.logger, - templateRenderer: globals.templateRenderer, + fileSystem: fs, + logger: logger, + templateRenderer: templateRenderer, templateManifest: _templateManifest, ); return template.render( @@ -479,7 +525,8 @@ mixin CreateBase on FlutterCommand { overwrite: overwrite, printStatusWhenWriting: printStatusWhenWriting, ); - final FlutterProject project = FlutterProject.fromDirectory(directory); + _context.projectFactory.invalidate(directory); + final FlutterProject project = _context.projectFactory.fromDirectory(directory); if (templateContext['android'] == true) { generatedCount += _injectGradleWrapper(project); } @@ -524,25 +571,43 @@ mixin CreateBase on FlutterCommand { platformsForMigrateConfig.add(SupportedPlatform.fuchsia); } if (generateMetadata) { - final File metadataFile = globals.fs.file( - globals.fs.path.join(projectDir.absolute.path, '.metadata'), - ); + final ToolContext(:FileSystem fs, :FlutterVersion flutterVersion, :Logger logger) = _context; + final File metadataFile = fs.file(fs.path.join(projectDir.absolute.path, '.metadata')); + // Seed the migrate config with the platforms already tracked in an + // existing .metadata file. Re-running `flutter create --platforms` must + // add the newly requested platforms without dropping the previously + // added ones, and without overwriting their recorded revisions. + // See https://github.com/flutter/flutter/issues/191567. + + var migrateConfig = MigrateConfig(); + if (metadataFile.existsSync()) { + try { + migrateConfig = FlutterProjectMetadata( + metadataFile, + logger, + extensionTemplateManager: null, + ).migrateConfig; + } on Exception catch (error) { + logger.printTrace('Failed to parse existing .metadata file: $error'); + } + } + final metadata = FlutterProjectMetadata.explicit( file: metadataFile, - versionRevision: globals.flutterVersion.frameworkRevision, - versionChannel: globals.flutterVersion.getBranchName(), // may contain PII + versionRevision: flutterVersion.frameworkRevision, + versionChannel: flutterVersion.getBranchName(), // may contain PII projectType: projectType, - migrateConfig: MigrateConfig(), - logger: globals.logger, + migrateConfig: migrateConfig, + extensionTemplateManager: null, + logger: logger, ); metadata.populate( platforms: platformsForMigrateConfig, projectDirectory: directory, update: false, - currentRevision: - stringArg('initial-create-revision') ?? globals.flutterVersion.frameworkRevision, - createRevision: globals.flutterVersion.frameworkRevision, - logger: globals.logger, + currentRevision: stringArg('initial-create-revision') ?? flutterVersion.frameworkRevision, + createRevision: flutterVersion.frameworkRevision, + logger: logger, ); metadata.writeFile(); } @@ -612,19 +677,20 @@ mixin CreateBase on FlutterCommand { late final Set _templateManifest = _computeTemplateManifest(); Set _computeTemplateManifest() { - final String flutterToolsAbsolutePath = globals.fs.path.join( + final FileSystem fs = _context.fs; + final String flutterToolsAbsolutePath = fs.path.join( Cache.flutterRoot!, 'packages', 'flutter_tools', ); - final String manifestPath = globals.fs.path.join( + final String manifestPath = fs.path.join( flutterToolsAbsolutePath, 'templates', 'template_manifest.json', ); final String manifestFileContents; try { - manifestFileContents = globals.fs.file(manifestPath).readAsStringSync(); + manifestFileContents = fs.file(manifestPath).readAsStringSync(); } on FileSystemException catch (e) { throwToolExit( 'Unable to read the template manifest at path "$manifestPath".\n' @@ -635,21 +701,23 @@ mixin CreateBase on FlutterCommand { final manifest = json.decode(manifestFileContents) as Map; return Set.from( (manifest['files']! as List).cast().map( - (String path) => Uri.file(globals.fs.path.join(flutterToolsAbsolutePath, path)), + (String path) => Uri.file(fs.path.join(flutterToolsAbsolutePath, path)), ), ); } int _injectGradleWrapper(FlutterProject project) { var filesCreated = 0; + final Cache cache = _context.cache; + final OperatingSystemUtils os = _context.os; copyDirectory( - globals.cache.getArtifactDirectory('gradle_wrapper'), + cache.getArtifactDirectory('gradle_wrapper'), project.android.hostAppGradleRoot, onFileCopied: (File sourceFile, File destinationFile) { filesCreated++; final String modes = sourceFile.statSync().modeString(); if (modes.contains('x')) { - globals.os.makeExecutable(destinationFile); + os.makeExecutable(destinationFile); } }, ); diff --git a/packages/flutter_tools/lib/src/commands/custom_devices.dart b/packages/flutter_tools/lib/src/commands/custom_devices.dart index 30a76453..e92ba79f 100644 --- a/packages/flutter_tools/lib/src/commands/custom_devices.dart +++ b/packages/flutter_tools/lib/src/commands/custom_devices.dart @@ -13,9 +13,9 @@ import '../base/error_handling_io.dart'; import '../base/file_system.dart'; import '../base/io.dart'; import '../base/logger.dart'; -import '../base/os.dart'; import '../base/platform.dart'; import '../base/terminal.dart'; +import '../context/tool_context.dart'; import '../convert.dart'; import '../custom_devices/custom_device.dart'; import '../custom_devices/custom_device_config.dart'; @@ -26,108 +26,26 @@ import '../runner/flutter_command.dart'; import '../runner/flutter_command_runner.dart'; class CustomDevicesCommand extends FlutterCommand { - factory CustomDevicesCommand({ - required CustomDevicesConfig customDevicesConfig, - required OperatingSystemUtils operatingSystemUtils, - required Terminal terminal, - required Platform platform, - required ProcessManager processManager, - required FileSystem fileSystem, - required Logger logger, - required FeatureFlags featureFlags, - }) { - return CustomDevicesCommand._common( - customDevicesConfig: customDevicesConfig, - operatingSystemUtils: operatingSystemUtils, - terminal: terminal, - platform: platform, - processManager: processManager, - fileSystem: fileSystem, - logger: logger, - featureFlags: featureFlags, - ); - } - - @visibleForTesting - factory CustomDevicesCommand.test({ - required CustomDevicesConfig customDevicesConfig, - required OperatingSystemUtils operatingSystemUtils, - required Terminal terminal, - required Platform platform, - required ProcessManager processManager, - required FileSystem fileSystem, - required Logger logger, - required FeatureFlags featureFlags, - }) { - return CustomDevicesCommand._common( - customDevicesConfig: customDevicesConfig, - operatingSystemUtils: operatingSystemUtils, - terminal: terminal, - platform: platform, - processManager: processManager, - fileSystem: fileSystem, - logger: logger, - featureFlags: featureFlags, - ); + CustomDevicesCommand({required FeatureFlags featureFlags, required ToolContext toolContext}) + : _featureFlags = featureFlags, + super(toolContext: toolContext) { + addSubcommand(CustomDevicesListCommand(featureFlags: featureFlags, toolContext: toolContext)); + addSubcommand(CustomDevicesResetCommand(featureFlags: featureFlags, toolContext: toolContext)); + addSubcommand(CustomDevicesAddCommand(featureFlags: featureFlags, toolContext: toolContext)); + addSubcommand(CustomDevicesDeleteCommand(featureFlags: featureFlags, toolContext: toolContext)); } - CustomDevicesCommand._common({ - required CustomDevicesConfig customDevicesConfig, - required OperatingSystemUtils operatingSystemUtils, - required Terminal terminal, - required Platform platform, - required ProcessManager processManager, - required FileSystem fileSystem, - required Logger logger, - required FeatureFlags featureFlags, - }) : _customDevicesConfig = customDevicesConfig, - _featureFlags = featureFlags { - addSubcommand( - CustomDevicesListCommand( - customDevicesConfig: customDevicesConfig, - featureFlags: featureFlags, - logger: logger, - ), - ); - addSubcommand( - CustomDevicesResetCommand( - customDevicesConfig: customDevicesConfig, - featureFlags: featureFlags, - fileSystem: fileSystem, - logger: logger, - ), - ); - addSubcommand( - CustomDevicesAddCommand( - customDevicesConfig: customDevicesConfig, - operatingSystemUtils: operatingSystemUtils, - terminal: terminal, - platform: platform, - featureFlags: featureFlags, - processManager: processManager, - fileSystem: fileSystem, - logger: logger, - ), - ); - addSubcommand( - CustomDevicesDeleteCommand( - customDevicesConfig: customDevicesConfig, - featureFlags: featureFlags, - fileSystem: fileSystem, - logger: logger, - ), - ); - } - - final CustomDevicesConfig _customDevicesConfig; final FeatureFlags _featureFlags; + @override + ToolContext get toolContext => super.toolContext!; + @override String get description { String configFileLine; if (_featureFlags.areCustomDevicesEnabled) { configFileLine = - '\nMakes changes to the config file at "${_customDevicesConfig.configPath}".\n'; + '\nMakes changes to the config file at "${toolContext.customDevicesConfig.configPath}".\n'; } else { configFileLine = ''; } @@ -160,21 +78,20 @@ Requires the custom devices feature to be enabled. You can enable it using "flut /// to the subcommands, like backing up the config file & checking if the /// feature is enabled. abstract class CustomDevicesCommandBase extends FlutterCommand { - CustomDevicesCommandBase({ - required this.customDevicesConfig, - required this.featureFlags, - required this.fileSystem, - required this.logger, - }); + CustomDevicesCommandBase({required this.featureFlags, required ToolContext super.toolContext}); + + @override + ToolContext get toolContext => super.toolContext!; @protected - final CustomDevicesConfig customDevicesConfig; + CustomDevicesConfig get customDevicesConfig => toolContext.customDevicesConfig; @protected final FeatureFlags featureFlags; + @protected - final FileSystem? fileSystem; + FileSystem get fileSystem => toolContext.fs; @protected - final Logger logger; + Logger get logger => toolContext.logger; /// The path to the (potentially non-existing) backup of the config file. @protected @@ -185,7 +102,7 @@ abstract class CustomDevicesCommandBase extends FlutterCommand { /// doesn't exist. (True otherwise) @protected bool backup() { - final File configFile = fileSystem!.file(customDevicesConfig.configPath); + final File configFile = fileSystem.file(customDevicesConfig.configPath); if (configFile.existsSync()) { configFile.copySync(configBackupPath); return true; @@ -208,11 +125,7 @@ abstract class CustomDevicesCommandBase extends FlutterCommand { } class CustomDevicesListCommand extends CustomDevicesCommandBase { - CustomDevicesListCommand({ - required super.customDevicesConfig, - required super.featureFlags, - required super.logger, - }) : super(fileSystem: null); + CustomDevicesListCommand({required super.featureFlags, required super.toolContext}); @override String get description => ''' @@ -233,10 +146,11 @@ List the currently configured custom devices, both enabled and disabled, reachab throwToolExit('Could not list custom devices.'); } + final String configPath = customDevicesConfig.configPath; if (devices.isEmpty) { - logger.printStatus('No custom devices found in "${customDevicesConfig.configPath}"'); + logger.printStatus('No custom devices found in "$configPath"'); } else { - logger.printStatus('List of custom devices in "${customDevicesConfig.configPath}":'); + logger.printStatus('List of custom devices in "$configPath":'); for (final device in devices) { logger.printStatus( 'id: ${device.id}, label: ${device.label}, enabled: ${device.enabled}', @@ -251,12 +165,7 @@ List the currently configured custom devices, both enabled and disabled, reachab } class CustomDevicesResetCommand extends CustomDevicesCommandBase { - CustomDevicesResetCommand({ - required super.customDevicesConfig, - required super.featureFlags, - required FileSystem super.fileSystem, - required super.logger, - }); + CustomDevicesResetCommand({required super.featureFlags, required super.toolContext}); @override String get description => ''' @@ -275,7 +184,7 @@ If a file already exists at the backup location, it will be overwritten. final bool wasBackedUp = backup(); - ErrorHandlingFileSystem.deleteIfExists(fileSystem!.file(customDevicesConfig.configPath)); + ErrorHandlingFileSystem.deleteIfExists(fileSystem.file(customDevicesConfig.configPath)); customDevicesConfig.ensureFileExists(); logger.printStatus( @@ -289,19 +198,7 @@ If a file already exists at the backup location, it will be overwritten. } class CustomDevicesAddCommand extends CustomDevicesCommandBase { - CustomDevicesAddCommand({ - required super.customDevicesConfig, - required OperatingSystemUtils operatingSystemUtils, - required Terminal terminal, - required Platform platform, - required super.featureFlags, - required ProcessManager processManager, - required FileSystem super.fileSystem, - required super.logger, - }) : _operatingSystemUtils = operatingSystemUtils, - _terminal = terminal, - _platform = platform, - _processManager = processManager { + CustomDevicesAddCommand({required super.featureFlags, required super.toolContext}) { argParser.addFlag( _kCheck, help: @@ -354,10 +251,6 @@ class CustomDevicesAddCommand extends CustomDevicesCommandBase { r'^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$', ); - final OperatingSystemUtils _operatingSystemUtils; - final Terminal _terminal; - final Platform _platform; - final ProcessManager _processManager; late StreamQueue inputs; @override @@ -373,7 +266,8 @@ class CustomDevicesAddCommand extends CustomDevicesCommandBase { /// Check this config by executing some of the commands, see if they run /// fine. Future _checkConfigWithLogging(CustomDeviceConfig config) async { - final device = CustomDevice(config: config, logger: logger, processManager: _processManager); + final ProcessManager pm = toolContext.processManager; + final device = CustomDevice(config: config, logger: logger, processManager: pm); var result = true; @@ -388,7 +282,7 @@ class CustomDevicesAddCommand extends CustomDevicesCommandBase { result = false; } - final Directory temp = await fileSystem!.systemTempDirectory.createTemp(); + final Directory temp = await fileSystem.systemTempDirectory.createTemp(); try { final bool ok = await device.tryInstall(localPath: temp.path, appName: temp.basename); @@ -419,13 +313,13 @@ class CustomDevicesAddCommand extends CustomDevicesCommandBase { deviceName: device.displayName, forwardPortCommand: config.forwardPortCommand!, forwardPortSuccessRegex: config.forwardPortSuccessRegex!, - processManager: _processManager, + processManager: pm, logger: logger, ); try { // find a random port we can forward - final int port = await _operatingSystemUtils.findFreePort(); + final int port = await toolContext.os.findFreePort(); final ForwardedPort? forwardedPort = await portForwarder.tryForward(port, port); if (forwardedPort == null) { @@ -573,7 +467,8 @@ class CustomDevicesAddCommand extends CustomDevicesCommandBase { // So instead, we add the keystrokes stream events to a new single-subscription // stream and listen to that instead. final nonClosingKeystrokes = StreamController(); - final StreamSubscription keystrokesSubscription = _terminal.keystrokes.listen( + final Terminal terminal = toolContext.terminal; + final StreamSubscription keystrokesSubscription = terminal.keystrokes.listen( (String s) => nonClosingKeystrokes.add(s.trim()), cancelOnError: true, ); @@ -718,13 +613,14 @@ class CustomDevicesAddCommand extends CustomDevicesCommandBase { : null, ); - if (_platform.isWindows) { + final Platform platform = toolContext.platform; + if (platform.isWindows) { config = config.copyWith( pingCommand: ['ping', if (ipv6) '-6', '-n', '1', '-w', '500', targetStr], explicitPingSuccessRegex: true, pingSuccessRegex: RegExp(r'[<=]\d+ms'), ); - } else if (_platform.isLinux || _platform.isMacOS) { + } else if (platform.isLinux || platform.isMacOS) { config = config.copyWith( pingCommand: ['ping', if (ipv6) '-6', '-c', '1', '-w', '1', targetStr], explicitPingSuccessRegex: true, @@ -763,12 +659,7 @@ class CustomDevicesAddCommand extends CustomDevicesCommandBase { } class CustomDevicesDeleteCommand extends CustomDevicesCommandBase { - CustomDevicesDeleteCommand({ - required super.customDevicesConfig, - required super.featureFlags, - required FileSystem super.fileSystem, - required super.logger, - }); + CustomDevicesDeleteCommand({required super.featureFlags, required super.toolContext}); @override String get description => ''' @@ -783,17 +674,14 @@ Delete a device from the config file. checkFeatureEnabled(); final id = globalResults![FlutterGlobalOptions.kDeviceIdOption] as String?; + final String configPath = customDevicesConfig.configPath; if (id == null || !customDevicesConfig.contains(id)) { - throwToolExit( - 'Couldn\'t find device with id "$id" in config at "${customDevicesConfig.configPath}"', - ); + throwToolExit('Couldn\'t find device with id "$id" in config at "$configPath"'); } backup(); customDevicesConfig.remove(id); - logger.printStatus( - 'Successfully removed device with id "$id" from config at "${customDevicesConfig.configPath}"', - ); + logger.printStatus('Successfully removed device with id "$id" from config at "$configPath"'); return FlutterCommandResult.success(); } } diff --git a/packages/flutter_tools/lib/src/commands/daemon.dart b/packages/flutter_tools/lib/src/commands/daemon.dart index fb29bc4b..e9de22fd 100644 --- a/packages/flutter_tools/lib/src/commands/daemon.dart +++ b/packages/flutter_tools/lib/src/commands/daemon.dart @@ -6,9 +6,13 @@ import 'dart:async'; import 'package:async/async.dart'; import 'package:meta/meta.dart'; +import 'package:process/process.dart'; +import 'package:unified_analytics/unified_analytics.dart'; import 'package:uuid/uuid.dart'; +import '../android/android_sdk.dart'; import '../android/android_workflow.dart'; +import '../android/java.dart'; import '../application_package.dart'; import '../base/common.dart'; import '../base/file_system.dart'; @@ -17,6 +21,10 @@ import '../base/logger.dart'; import '../base/terminal.dart'; import '../base/utils.dart'; import '../build_info.dart'; +import '../build_system/build_system.dart'; +import '../build_system/build_targets.dart'; +import '../context/android_context.dart'; +import '../context/tool_context.dart'; import '../convert.dart'; import '../daemon.dart'; import '../device.dart'; @@ -24,7 +32,7 @@ import '../device_port_forwarder.dart'; import '../device_vm_service_discovery_for_attach.dart'; import '../emulator.dart'; import '../features.dart'; -import '../globals.dart' as globals; +import '../macos/xcode.dart'; import '../project.dart'; import '../proxied_devices/debounce_data_stream.dart'; import '../proxied_devices/file_transfer.dart'; @@ -44,15 +52,36 @@ const protocolVersion = '0.6.1'; /// It can be shutdown with a `daemon.shutdown` command (or by killing the /// process). class DaemonCommand extends FlutterCommand { - DaemonCommand({this.hidden = false}) { + DaemonCommand({ + required this._androidContext, + required this._buildSystem, + required this._buildTargets, + required super.toolContext, + required this._xcode, + this._androidWorkflow, + this._deviceManager, + this.hidden = false, + }) { argParser.addOption( 'listen-on-tcp-port', - help: - 'If specified, the daemon will be listening for commands on the specified port instead of stdio.', + help: 'If specified, the daemon will be listening for commands on the specified port instead of stdio.', valueHelp: 'port', ); } + final AndroidContext _androidContext; + final BuildSystem _buildSystem; + final BuildTargets _buildTargets; + final Xcode? _xcode; + final AndroidWorkflow? _androidWorkflow; + final DeviceManager? _deviceManager; + + AndroidSdk? get _androidSdk => _androidContext.androidSdk; + Java? get _java => _androidContext.java; + + @override + ToolContext get toolContext => super.toolContext!; + @override final name = 'daemon'; @@ -67,6 +96,13 @@ class DaemonCommand extends FlutterCommand { @override Future runCommand() async { + final ToolContext( + :Logger logger, + :Stdio stdio, + :AnsiTerminal terminal, + :OutputPreferences outputPreferences, + ) = toolContext; + if (argResults!['listen-on-tcp-port'] != null) { int? port; try { @@ -76,24 +112,44 @@ class DaemonCommand extends FlutterCommand { } await DaemonServer( - port: port, + analytics: analytics, + buildSystem: _buildSystem, + buildTargets: _buildTargets, + featureFlags: featureFlags, logger: StdoutLogger( - terminal: globals.terminal, - stdio: globals.stdio, - outputPreferences: globals.outputPreferences, + terminal: terminal, + stdio: stdio, + outputPreferences: outputPreferences, ), - notifyingLogger: asLogger(globals.logger), + toolContext: toolContext, + xcode: _xcode, + androidSdk: _androidSdk, + androidWorkflow: _androidWorkflow, + deviceManager: _deviceManager, + java: _java, + notifyingLogger: asLogger(logger), + port: port, ).run(); return FlutterCommandResult.success(); } final daemon = Daemon( DaemonConnection( - daemonStreams: DaemonStreams.fromStdio(globals.stdio, logger: globals.logger), - logger: globals.logger, + daemonStreams: DaemonStreams.fromStdio(stdio, logger: logger), + logger: logger, ), - notifyingLogger: asLogger(globals.logger), + analytics: analytics, + buildSystem: _buildSystem, + buildTargets: _buildTargets, + featureFlags: featureFlags, + toolContext: toolContext, + xcode: _xcode, + androidSdk: _androidSdk, + androidWorkflow: _androidWorkflow, + deviceManager: _deviceManager, + java: _java, + notifyingLogger: asLogger(logger), ); - globals.printStatus('Device daemon started.'); + logger.printStatus('Device daemon started.'); final int code = await daemon.onExit; if (code != 0) { throwToolExit('Daemon exited with non-zero exit code: $code', exitCode: code); @@ -105,14 +161,27 @@ class DaemonCommand extends FlutterCommand { @visibleForTesting class DaemonServer { DaemonServer({ - this.port, + required this.analytics, + required this.buildSystem, + required this.buildTargets, + required this.featureFlags, required this.logger, + required this.toolContext, + required this.xcode, + this.androidSdk, + this.androidWorkflow, + @visibleForTesting this._bind = ServerSocket.bind, + this.deviceManager, + this.java, this.notifyingLogger, - @visibleForTesting - Future Function(InternetAddress address, int port) bind = ServerSocket.bind, - }) : _bind = bind; + this.port, + }); final int? port; + final BuildSystem buildSystem; + final BuildTargets buildTargets; + final ToolContext toolContext; + final Xcode? xcode; /// Stdout logger used to print general server-related errors. final Logger logger; @@ -120,6 +189,13 @@ class DaemonServer { // Logger that sends the message to the other end of daemon connection. final NotifyingLogger? notifyingLogger; + final Analytics analytics; + final DeviceManager? deviceManager; + final Java? java; + final AndroidSdk? androidSdk; + final FeatureFlags featureFlags; + final AndroidWorkflow? androidWorkflow; + final Future Function(InternetAddress address, int port) _bind; Future run() async { @@ -140,48 +216,119 @@ class DaemonServer { // We have to listen to socket.done. Otherwise when the connection is // reset, we will receive an uncatchable exception. // https://github.com/dart-lang/sdk/issues/25518 - final Future socketDone = socket.done.then( - (_) {}, - onError: (Object error, StackTrace stackTrace) { - logger.printError('Socket error: $error'); - logger.printTrace('$stackTrace'); - }, - ); + final Future socketDone = socket.done.handleError(( + Object error, + StackTrace stackTrace, + ) { + logger.printError('Socket error: $error'); + logger.printTrace('$stackTrace'); + }); final daemon = Daemon( DaemonConnection( daemonStreams: DaemonStreams.fromSocket(socket, logger: logger), logger: logger, ), + analytics: analytics, + buildSystem: buildSystem, + buildTargets: buildTargets, + toolContext: toolContext, + xcode: xcode, notifyingLogger: notifyingLogger, + deviceManager: deviceManager, + java: java, + androidSdk: androidSdk, + featureFlags: featureFlags, + androidWorkflow: androidWorkflow, ); await daemon.onExit; await socketDone; }); // Wait indefinitely until the server closes. - await subscription.asFuture(); - await subscription.cancel(); + try { + await subscription.asFuture(); + } finally { + await subscription.cancel(); + } } } typedef CommandHandler = Future? Function(Map args); -typedef CommandHandlerWithBinary = - Future Function(Map args, Stream>? binary); +typedef CommandHandlerWithBinary = Future Function( + Map args, + Stream>? binary, +); class Daemon { Daemon( this.connection, { - this.notifyingLogger, - this.logToStdout = false, + required Analytics analytics, + required BuildSystem buildSystem, + required BuildTargets buildTargets, + required FeatureFlags featureFlags, + required ToolContext toolContext, + required Xcode? xcode, + AndroidSdk? androidSdk, + AndroidWorkflow? androidWorkflow, + DeviceManager? deviceManager, FileTransfer fileTransfer = const FileTransfer(), - }) { + Java? java, + this.logToStdout = false, + this.notifyingLogger, + }) : _logger = notifyingLogger ?? toolContext.logger, + _fs = toolContext.fs, + _stdio = toolContext.stdio { + final ToolContext(:ProcessManager processManager, :FlutterProjectFactory projectFactory) = + toolContext; + final AndroidWorkflow workflow = + androidWorkflow ?? + AndroidWorkflow(androidSdkBuilder: () => androidSdk, featureFlags: featureFlags); + // Set up domains. - registerDomain(daemonDomain = DaemonDomain(this)); - registerDomain(appDomain = AppDomain(this)); - registerDomain(deviceDomain = DeviceDomain(this)); - registerDomain(emulatorDomain = EmulatorDomain(this)); + registerDomain( + daemonDomain = DaemonDomain( + this, + featureFlags: featureFlags, + fileSystem: _fs, + logger: _logger, + projectFactory: projectFactory, + stdio: _stdio, + ), + ); + registerDomain( + appDomain = AppDomain( + this, + analytics: analytics, + buildSystem: buildSystem, + buildTargets: buildTargets, + toolContext: toolContext, + xcode: xcode, + ), + ); + registerDomain( + deviceDomain = DeviceDomain( + this, + fileSystem: _fs, + logger: _logger, + projectFactory: projectFactory, + deviceManager: deviceManager, + ), + ); + registerDomain( + emulatorDomain = EmulatorDomain( + this, + androidWorkflow: workflow, + fileSystem: _fs, + logger: _logger, + processManager: processManager, + androidSdk: androidSdk, + java: java, + ), + ); registerDomain(devToolsDomain = DevToolsDomain(this)); - registerDomain(proxyDomain = ProxyDomain(this, fileTransfer: fileTransfer)); + registerDomain( + proxyDomain = ProxyDomain(this, fileSystem: _fs, fileTransfer: fileTransfer, logger: _logger), + ); // Start listening. _commandSubscription = connection.incomingCommands.listen( @@ -195,21 +342,46 @@ class Daemon { ); } - factory Daemon.createMachineDaemon() { + factory Daemon.createMachineDaemon({ + required Analytics analytics, + required BuildSystem buildSystem, + required BuildTargets buildTargets, + required FeatureFlags featureFlags, + required ToolContext toolContext, + required Xcode? xcode, + AndroidSdk? androidSdk, + AndroidWorkflow? androidWorkflow, + DeviceManager? deviceManager, + Java? java, + }) { + final ToolContext(:Logger logger, :Stdio stdio) = toolContext; final daemon = Daemon( DaemonConnection( - daemonStreams: DaemonStreams.fromStdio(globals.stdio, logger: globals.logger), - logger: globals.logger, + daemonStreams: DaemonStreams.fromStdio(stdio, logger: logger), + logger: logger, ), - notifyingLogger: (globals.logger is NotifyingLogger) - ? globals.logger as NotifyingLogger - : NotifyingLogger(verbose: globals.logger.isVerbose, parent: globals.logger), + analytics: analytics, + buildSystem: buildSystem, + buildTargets: buildTargets, + toolContext: toolContext, + xcode: xcode, + notifyingLogger: (logger is NotifyingLogger) + ? logger + : NotifyingLogger(verbose: logger.isVerbose, parent: logger), logToStdout: true, + deviceManager: deviceManager, + java: java, + androidSdk: androidSdk, + featureFlags: featureFlags, + androidWorkflow: androidWorkflow, ); return daemon; } final DaemonConnection connection; + final Stdio _stdio; + final Logger _logger; + final FileSystem _fs; late DaemonDomain daemonDomain; late AppDomain appDomain; @@ -239,7 +411,7 @@ class Daemon { final Object? id = request.data['id']; if (id == null) { - globals.stdio.stderrWrite('no id for request: $request\n'); + _stdio.stderrWrite('no id for request: $request\n'); return; } @@ -375,7 +547,15 @@ abstract class Domain { /// /// This domain fires the `daemon.logMessage` event. class DaemonDomain extends Domain { - DaemonDomain(Daemon daemon) : super(daemon, 'daemon') { + DaemonDomain( + Daemon daemon, { + required this._featureFlags, + required FileSystem fileSystem, + required this._logger, + required this._projectFactory, + required this._stdio, + }) : _fs = fileSystem, + super(daemon, 'daemon') { registerHandler('version', version); registerHandler('shutdown', shutdown); registerHandler('getSupportedPlatforms', getSupportedPlatforms); @@ -391,9 +571,9 @@ class DaemonDomain extends Domain { // ignore: avoid_print print(message.message); } else if (message.level == 'error' || message.level == 'warning') { - globals.stdio.stderrWrite('${message.message}\n'); + _stdio.stderrWrite('${message.message}\n'); if (message.stackTrace != null) { - globals.stdio.stderrWrite('${message.stackTrace.toString().trimRight()}\n'); + _stdio.stderrWrite('${message.stackTrace.toString().trimRight()}\n'); } } } else { @@ -413,6 +593,12 @@ class DaemonDomain extends Domain { }); } + final FileSystem _fs; + final FeatureFlags _featureFlags; + final Logger _logger; + final FlutterProjectFactory _projectFactory; + final Stdio _stdio; + StreamSubscription? _subscription; Future version(Map args) { @@ -431,7 +617,7 @@ class DaemonDomain extends Domain { if (res is Map && res['url'] is String) { return res['url']! as String; } else { - globals.printError( + _logger.printError( 'Invalid response to exposeUrl - params should include a String url field', ); return url; @@ -458,8 +644,8 @@ class DaemonDomain extends Domain { final platformTypes = []; final platformTypesMap = {}; try { - final FlutterProject flutterProject = FlutterProject.fromDirectory( - globals.fs.directory(projectRoot), + final FlutterProject flutterProject = _projectFactory.fromDirectory( + _fs.directory(projectRoot), ); final Set supportedPlatforms = flutterProject .getSupportedPlatforms() @@ -469,7 +655,7 @@ class DaemonDomain extends Domain { final reasons = >[]; switch (platform) { case PlatformType.linux: - if (!featureFlags.isLinuxEnabled) { + if (!_featureFlags.isLinuxEnabled) { reasons.add({ 'reasonText': 'the Linux feature is not enabled', 'fixText': 'Run "flutter config --enable-linux-desktop"', @@ -484,7 +670,7 @@ class DaemonDomain extends Domain { }); } case PlatformType.macos: - if (!featureFlags.isMacOSEnabled) { + if (!_featureFlags.isMacOSEnabled) { reasons.add({ 'reasonText': 'the macOS feature is not enabled', 'fixText': 'Run "flutter config --enable-macos-desktop"', @@ -499,7 +685,7 @@ class DaemonDomain extends Domain { }); } case PlatformType.windows: - if (!featureFlags.isWindowsEnabled) { + if (!_featureFlags.isWindowsEnabled) { reasons.add({ 'reasonText': 'the Windows feature is not enabled', 'fixText': 'Run "flutter config --enable-windows-desktop"', @@ -515,7 +701,7 @@ class DaemonDomain extends Domain { }); } case PlatformType.ios: - if (!featureFlags.isIOSEnabled) { + if (!_featureFlags.isIOSEnabled) { reasons.add({ 'reasonText': 'the iOS feature is not enabled', 'fixText': 'Run "flutter config --enable-ios"', @@ -530,7 +716,7 @@ class DaemonDomain extends Domain { }); } case PlatformType.android: - if (!featureFlags.isAndroidEnabled) { + if (!_featureFlags.isAndroidEnabled) { reasons.add({ 'reasonText': 'the Android feature is not enabled', 'fixText': 'Run "flutter config --enable-android"', @@ -546,7 +732,7 @@ class DaemonDomain extends Domain { }); } case PlatformType.web: - if (!featureFlags.isWebEnabled) { + if (!_featureFlags.isWebEnabled) { reasons.add({ 'reasonText': 'the Web feature is not enabled', 'fixText': 'Run "flutter config --enable-web"', @@ -561,7 +747,7 @@ class DaemonDomain extends Domain { }); } case PlatformType.fuchsia: - if (!featureFlags.isFuchsiaEnabled) { + if (!_featureFlags.isFuchsiaEnabled) { reasons.add({ 'reasonText': 'the Fuchsia feature is not enabled', 'fixText': 'Run "flutter config --enable-fuchsia"', @@ -577,7 +763,7 @@ class DaemonDomain extends Domain { }); } case PlatformType.custom: - if (!featureFlags.areCustomDevicesEnabled) { + if (!_featureFlags.areCustomDevicesEnabled) { reasons.add({ 'reasonText': 'the custom devices feature is not enabled', 'fixText': 'Run "flutter config --enable-custom-devices"', @@ -600,7 +786,7 @@ class DaemonDomain extends Domain { PlatformType.values.forEach(handlePlatformType); return { - // TODO(fujino): delete this key https://github.com/flutter/flutter/issues/140473 + // TODO(bkonyi): remove 'platformTypes' once clients have migrated to 'platforms'. See https://github.com/flutter/flutter/issues/140473 'platforms': platformTypes, 'platformTypes': platformTypesMap, }; @@ -633,23 +819,39 @@ class DaemonDomain extends Domain { /// The [name] of this value will be sent as a response to daemon client. enum _ReasonCode { create, config } -typedef RunOrAttach = - Future Function({ - Completer? connectionInfoCompleter, - Completer? appStartedCompleter, - }); +typedef RunOrAttach = Future Function({ + Completer? connectionInfoCompleter, + Completer? appStartedCompleter, +}); /// This domain responds to methods like [startApp] and [stop]. /// /// It fires events for application start, stop, and stdout and stderr. class AppDomain extends Domain { - AppDomain(Daemon daemon) : super(daemon, 'app') { + AppDomain( + Daemon daemon, { + required this._analytics, + required this._buildSystem, + required this._buildTargets, + required this._toolContext, + required this._xcode, + }) : _fs = _toolContext.fs, + _logger = _toolContext.logger, + super(daemon, 'app') { registerHandler('restart', restart); registerHandler('callServiceExtension', callServiceExtension); registerHandler('stop', stop); registerHandler('detach', detach); } + final FileSystem _fs; + final Analytics _analytics; + final Logger _logger; + final ToolContext _toolContext; + final BuildSystem _buildSystem; + final BuildTargets _buildTargets; + final Xcode? _xcode; + static const _uuidGenerator = Uuid(); static String _getNewAppId() => _uuidGenerator.v4(); @@ -683,15 +885,17 @@ class AppDomain extends Domain { } // We change the current working directory for the duration of the `start` command. - final Directory cwd = globals.fs.currentDirectory; - globals.fs.currentDirectory = globals.fs.directory(projectDirectory); - final FlutterProject flutterProject = FlutterProject.current(); + final Directory cwd = _fs.currentDirectory; + _fs.currentDirectory = _fs.directory(projectDirectory); + final FlutterProject flutterProject = _toolContext.projectFactory.fromDirectory( + _fs.currentDirectory, + ); final FlutterDevice flutterDevice = await FlutterDevice.create( device, - target: target, + toolContext: _toolContext, buildInfo: options.buildInfo, - platform: globals.platform, + target: target, userIdentifier: userIdentifier, ); @@ -704,15 +908,12 @@ class AppDomain extends Domain { target: target, debuggingOptions: options, stayResident: true, - urlTunneller: options.webEnableExposeUrl! ? daemon.daemonDomain.exposeUrl : null, + urlTunneller: options.webConfig.enableExposeUrl ? daemon.daemonDomain.exposeUrl : null, machine: machine, - analytics: globals.analytics, - systemClock: globals.systemClock, - logger: globals.logger, - terminal: globals.terminal, - platform: globals.platform, - outputPreferences: globals.outputPreferences, - fileSystem: globals.fs, + analytics: _analytics, + buildSystem: _buildSystem, + buildTargets: _buildTargets, + toolContext: _toolContext, webDefines: webDefines, ); } else if (enableHotReload) { @@ -725,8 +926,11 @@ class AppDomain extends Domain { dillOutputPath: dillOutputPath, hostIsIde: true, machine: machine, - analytics: globals.analytics, - logger: globals.logger, + analytics: _analytics, + buildSystem: _buildSystem, + buildTargets: _buildTargets, + toolContext: _toolContext, + xcode: _xcode, ); } else { runner = ColdRunner( @@ -735,6 +939,11 @@ class AppDomain extends Domain { debuggingOptions: options, applicationBinary: applicationBinary, machine: machine, + analytics: _analytics, + buildSystem: _buildSystem, + buildTargets: _buildTargets, + toolContext: _toolContext, + xcode: _xcode, ); } @@ -755,7 +964,7 @@ class AppDomain extends Domain { enableHotReload, cwd, LaunchMode.run, - asLogger(globals.logger), + asLogger(_logger), ); } @@ -813,6 +1022,8 @@ class AppDomain extends Domain { }), ); } + // Kept separate from [AppInstance.started]. Runners attach listeners to this + // completer that have no error handler, so it must only ever be completed. final appStartedCompleter = Completer(); // This future won't complete until the application has shutdown, so we don't want to @@ -833,23 +1044,36 @@ class AppDomain extends Domain { } finally { // If the full directory is used instead of the path then this causes // a TypeError with the ErrorHandlingFileSystem. - globals.fs.currentDirectory = cwd.path; + _fs.currentDirectory = cwd.path; _apps.remove(app); } }); - await Future.any(>[ - appStartedCompleter.future.then((void value) { - _sendAppEvent(app, 'started'); - }), - appRunFuture, - ]); + try { + await Future.any(>[ + appStartedCompleter.future.then((void value) { + app._markStarted(); + _sendAppEvent(app, 'started'); + }), + appRunFuture, + ]); + } on Object catch (error, stackTrace) { + // `appRunFuture` only converts an [Exception] into a `stop` event, so an + // [Error] thrown by the runner or by the `finally` above surfaces here. + // Settle anything waiting on [AppInstance.started] before it propagates, + // otherwise a deferred `app.restart` waits forever. + app._failedToStart(error, stackTrace); + rethrow; + } // If appRunFuture completes early due to a fatal initialization error - // without actually starting the app, we must explicitly throw an exception - // to prevent the IDE/client from hanging indefinitely. + // without actually starting the app, we must explicitly fail both this + // request and anything waiting on [AppInstance.started], to prevent the + // IDE/client from hanging indefinitely. if (!appStartedCompleter.isCompleted) { - throw DaemonException('App failed to start'); + final failure = DaemonException('App failed to start'); + app._failedToStart(failure); + throw failure; } return app; } @@ -873,6 +1097,14 @@ class AppDomain extends Domain { throw DaemonException("app '$appId' not found"); } + // The `app.start` event carries the app ID and is sent before the runner + // has finished starting up, so a client can ask for a restart while the + // initial compile is still in flight. Servicing it now would issue a + // recompile against a compiler that has not accepted its first compile + // yet. Defer instead of dropping it: the client may have edited a file + // that the initial compile did not pick up. + await app.started; + return _queueAndDebounceReloadAction( app, fullRestart ? OperationType.restart : OperationType.reload, @@ -913,14 +1145,10 @@ class AppDomain extends Domain { ); } - /// Returns an error, or the service extension result (a map with two fixed - /// keys, `type` and `method`). The result may have one or more additional keys, - /// depending on the specific service extension end-point. For example: + /// Returns an error, or the service extension result. For example: /// /// { - /// "value":"android", - /// "type":"_extensionType", - /// "method":"ext.flutter.platformOverride" + /// "value":"android" /// } Future> callServiceExtension(Map args) async { final String? appId = _getStringArg(args, 'appId', required: true); @@ -1011,7 +1239,14 @@ typedef _DeviceEventHandler = void Function(Device device); /// It exports a `getDevices()` call, as well as firing `device.added` and /// `device.removed` events. class DeviceDomain extends Domain { - DeviceDomain(Daemon daemon) : super(daemon, 'device') { + DeviceDomain( + Daemon daemon, { + required FileSystem fileSystem, + required this._logger, + required this._projectFactory, + this._deviceManager, + }) : _fs = fileSystem, + super(daemon, 'device') { registerHandler('getDevices', getDevices); registerHandler('discoverDevices', discoverDevices); registerHandler('enable', enable); @@ -1033,9 +1268,14 @@ class DeviceDomain extends Domain { // Use the device manager discovery so that client provided device types // are usable via the daemon protocol. - globals.deviceManager!.deviceDiscoverers.forEach(addDeviceDiscoverer); + _deviceManager?.deviceDiscoverers.forEach(addDeviceDiscoverer); } + final DeviceManager? _deviceManager; + final FileSystem _fs; + final Logger _logger; + final FlutterProjectFactory _projectFactory; + /// An incrementing number used to generate unique ids. var _id = 0; final _applicationPackages = {}; @@ -1062,7 +1302,7 @@ class DeviceDomain extends Domain { final Map response = await _deviceToMap(device); sendEvent(eventName, response); } on Exception catch (err) { - globals.printError('$err'); + _logger.printError('$err'); } }); }; @@ -1287,8 +1527,8 @@ class DeviceDomain extends Domain { FlutterProject? project; try { - project = FlutterProject.current(); - } on ToolExit catch (_) { + project = _projectFactory.fromDirectory(_fs.currentDirectory); + } on ToolExit { // In daemon mode the cwd may not be a Flutter project, so we just ignore // these errors and use 'Unknown' as the package name below. } @@ -1378,7 +1618,7 @@ class DeviceDomain extends Domain { fuchsiaModule: fuchsiaModule, filterDevicePort: filterDevicePort, ipv6: ipv6 ?? false, - logger: globals.logger, + logger: _logger, ); _vmServiceDiscoverySubscriptions[id] = discovery.uris.listen( (Uri uri) => sendEvent('device.VMServiceDiscoveryForAttach.$id', uri.toString()), @@ -1579,18 +1819,42 @@ class NotifyingLogger extends DelegatingLogger { /// A running application, started by this daemon. class AppInstance { - AppInstance( - this.id, { - required this.runner, - this.logToStdout = false, - required MachineOutputLogger logger, - }) : _logger = logger; + AppInstance(this.id, {required this.runner, this.logToStdout = false, required this._logger}) { + // Nothing is obliged to await [started], so make sure a failure never + // surfaces as an unhandled async error. + _startedCompleter.future.ignore(); + } final String id; final ResidentRunner runner; final bool logToStdout; final MachineOutputLogger _logger; + final _startedCompleter = Completer(); + + /// Completes once [runner] reports the app has started, which is the same + /// signal the `app.started` event is sent from, or with an error if the app + /// exited before it got there. + /// + /// For a runner with an incremental compiler this is after the initial + /// compile was accepted, which is what makes it safe to ask for a reload. It + /// does not mean the VM service is connected: the web runner attaches that + /// asynchronously afterwards, so the VM service may still be unattached once + /// this completes. + Future get started => _startedCompleter.future; + + void _markStarted() { + _startedCompleter.complete(); + } + + void _failedToStart(Object error, [StackTrace? stackTrace]) { + // Reachable after [_markStarted] when sending the `app.started` event is + // what threw, so this cannot assume the completer is still pending. + if (!_startedCompleter.isCompleted) { + _startedCompleter.completeError(error, stackTrace); + } + } + Future restart({bool fullRestart = false, bool pause = false, String? reason}) { return runner.restart(fullRestart: fullRestart, pause: pause, reason: reason); } @@ -1609,20 +1873,32 @@ class AppInstance { /// This domain responds to methods like [getEmulators] and [launch]. class EmulatorDomain extends Domain { - EmulatorDomain(Daemon daemon) : super(daemon, 'emulator') { + EmulatorDomain( + Daemon daemon, { + required AndroidWorkflow androidWorkflow, + required FileSystem fileSystem, + required Logger logger, + required ProcessManager processManager, + AndroidSdk? androidSdk, + EmulatorManager? emulatorManager, + Java? java, + }) : emulators = + emulatorManager ?? + EmulatorManager( + fileSystem: fileSystem, + logger: logger, + javaBuilder: () => java, + androidSdkBuilder: () => androidSdk, + processManager: processManager, + androidWorkflow: androidWorkflow, + ), + super(daemon, 'emulator') { registerHandler('getEmulators', getEmulators); registerHandler('launch', launch); registerHandler('create', create); } - EmulatorManager emulators = EmulatorManager( - fileSystem: globals.fs, - logger: globals.logger, - java: globals.java, - androidSdk: globals.androidSdk, - processManager: globals.processManager, - androidWorkflow: androidWorkflow!, - ); + final EmulatorManager emulators; Future>> getEmulators([Map? args]) async { final List list = await emulators.getAllAvailableEmulators(); @@ -1654,9 +1930,13 @@ class EmulatorDomain extends Domain { } class ProxyDomain extends Domain { - ProxyDomain(Daemon daemon, {required FileTransfer fileTransfer}) - : _fileTransfer = fileTransfer, - super(daemon, 'proxy') { + ProxyDomain( + Daemon daemon, { + required FileSystem fileSystem, + required this._fileTransfer, + required this._logger, + }) : _fs = fileSystem, + super(daemon, 'proxy') { registerHandlerWithBinary('writeTempFile', writeTempFile); registerHandler('calculateFileHashes', calculateFileHashes); registerHandlerWithBinary('updateFile', updateFile); @@ -1666,6 +1946,8 @@ class ProxyDomain extends Domain { } final FileTransfer _fileTransfer; + final FileSystem _fs; + final Logger _logger; final _forwardedConnections = {}; var _id = 0; @@ -1726,14 +2008,14 @@ class ProxyDomain extends Domain { try { socket = await Socket.connect(InternetAddress.loopbackIPv4, targetPort); } on SocketException { - globals.logger.printTrace('Connecting to localhost:$targetPort failed with IPv4'); + _logger.printTrace('Connecting to localhost:$targetPort failed with IPv4'); } try { // If connecting to IPv4 loopback interface fails, try IPv6. socket ??= await Socket.connect(InternetAddress.loopbackIPv6, targetPort); } on SocketException { - globals.logger.printError('Connecting to localhost:$targetPort failed'); + _logger.printError('Connecting to localhost:$targetPort failed'); } if (socket == null) { @@ -1747,7 +2029,7 @@ class ProxyDomain extends Domain { }, onError: (Object error, StackTrace stackTrace) { // Socket error, probably disconnected. - globals.logger.printTrace('Socket error: $error, $stackTrace'); + _logger.printTrace('Socket error: $error, $stackTrace'); }, ); @@ -1757,7 +2039,7 @@ class ProxyDomain extends Domain { (Object? obj) => obj, onError: (Object error, StackTrace stackTrace) { // Socket error, probably disconnected. - globals.logger.printTrace('Socket error: $error, $stackTrace'); + _logger.printTrace('Socket error: $error, $stackTrace'); }, ) .then((Object? _) { @@ -1803,7 +2085,7 @@ class ProxyDomain extends Domain { Directory? _tempDirectory; Directory get tempDirectory => - _tempDirectory ??= globals.fs.systemTempDirectory.childDirectory('flutter_tool_daemon') + _tempDirectory ??= _fs.systemTempDirectory.childDirectory('flutter_tool_daemon') ..createSync(); } diff --git a/packages/flutter_tools/lib/src/commands/darwin_add_to_app.dart b/packages/flutter_tools/lib/src/commands/darwin_add_to_app.dart index a3401797..c13db664 100644 --- a/packages/flutter_tools/lib/src/commands/darwin_add_to_app.dart +++ b/packages/flutter_tools/lib/src/commands/darwin_add_to_app.dart @@ -9,9 +9,12 @@ import '../base/file_system.dart'; import '../base/io.dart'; import '../base/logger.dart'; import '../build_info.dart'; +import '../context/apple_context.dart'; +import '../context/tool_context.dart'; import '../convert.dart'; import '../darwin/darwin.dart'; import '../ios/code_signing.dart'; +import '../isolated/native_assets/native_assets_manifest.dart'; import '../runner/flutter_command.dart' show FlutterOptions; import '../xcode_project.dart'; @@ -21,11 +24,18 @@ import '../xcode_project.dart'; /// codesign files/directories [codesign]. It also has special logic for codesigning /// Flutter XCFrameworks [codesignFlutterXCFramework]. class DarwinAddToAppCodesigning { - DarwinAddToAppCodesigning({ - required XcodeCodeSigningSettings xcodeCodeSigningSettings, - required Logger logger, - }) : _logger = logger, - _xcodeCodeSigningSettings = xcodeCodeSigningSettings; + DarwinAddToAppCodesigning({required this._xcodeCodeSigningSettings, required this._logger}); + + DarwinAddToAppCodesigning.fromContexts({ + required AppleContext appleContext, + required ToolContext toolContext, + }) : this( + logger: toolContext.logger, + xcodeCodeSigningSettings: XcodeCodeSigningSettings.fromContexts( + appleContext: appleContext, + toolContext: toolContext, + ), + ); final XcodeCodeSigningSettings _xcodeCodeSigningSettings; final Logger _logger; @@ -331,24 +341,22 @@ class DarwinAddToAppNativeAssets { if (!manifestFile.existsSync()) { return const {}; } - final manifest = json.decode(manifestFile.readAsStringSync()) as Map; - final nativeAssets = manifest['native-assets'] as Map?; - if (nativeAssets == null) { - return const {}; - } + final manifestJson = json.decode(manifestFile.readAsStringSync()) as Map; + final manifest = NativeAssetsManifest.fromJson(manifestJson); final result = {}; - for (final Object? targetAssets in nativeAssets.values) { - if (targetAssets is! Map) { - continue; - } - for (final MapEntry entry in targetAssets.entries) { - final String assetId = entry.key; - final Object? pathInfo = entry.value; - // The path info is a list of strings, where the first string is the type of path (see - // [KernelAssetAbsolutePath]), and the second string is the actual path. - if (pathInfo is List && pathInfo.length >= 2) { - final path = pathInfo[1]! as String; - result[assetId] = path; + for (final Map targetAssets in manifest.assets.values) { + for (final MapEntry(key: assetId, value: path) + in targetAssets.entries) { + if (path + case NativeAssetAbsolutePath(path: final String pathString) || + NativeAssetSystemPath(path: final String pathString)) { + // A code asset is recorded under the name it is loaded with, which for + // a framework is its `@rpath`-relative install name. Drop the prefix + // to get back to where it sits in the bundle. + const rpathPrefix = '@rpath/'; + result[assetId] = pathString.startsWith(rpathPrefix) + ? pathString.substring(rpathPrefix.length) + : pathString; } } } diff --git a/packages/flutter_tools/lib/src/commands/debug_adapter.dart b/packages/flutter_tools/lib/src/commands/debug_adapter.dart index a421416b..e4a09787 100644 --- a/packages/flutter_tools/lib/src/commands/debug_adapter.dart +++ b/packages/flutter_tools/lib/src/commands/debug_adapter.dart @@ -4,8 +4,10 @@ import 'dart:async'; +import '../base/io.dart'; +import '../base/logger.dart'; +import '../context/tool_context.dart'; import '../debug_adapters/server.dart'; -import '../globals.dart' as globals; import '../runner/flutter_command.dart'; /// This command will start up a Debug Adapter that communicates using the Debug Adapter Protocol (DAP). @@ -23,7 +25,8 @@ import '../runner/flutter_command.dart'; /// The DAP specification can be found at /// https://microsoft.github.io/debug-adapter-protocol/. class DebugAdapterCommand extends FlutterCommand { - DebugAdapterCommand({bool verboseHelp = false}) : hidden = !verboseHelp { + DebugAdapterCommand({required super.toolContext, bool verboseHelp = false}) + : hidden = !verboseHelp { usesIpv6Flag(verboseHelp: verboseHelp); addDdsOptions(verboseHelp: verboseHelp); argParser.addFlag( @@ -34,6 +37,9 @@ class DebugAdapterCommand extends FlutterCommand { ); } + @override + ToolContext get toolContext => super.toolContext!; + @override final name = 'debug-adapter'; @@ -52,16 +58,18 @@ class DebugAdapterCommand extends FlutterCommand { @override Future runCommand() async { + final Logger logger = toolContext.logger; + final Stdio stdio = toolContext.stdio; final server = DapServer( - globals.stdio.stdin, - globals.stdio.stdout.nonBlocking, - fileSystem: globals.fs, - platform: globals.platform, + stdio.stdin, + stdio.stdout.nonBlocking, + fileSystem: toolContext.fs, + platform: toolContext.platform, ipv6: ipv6 ?? false, enableDds: enableDds, test: boolArg('test'), onError: (Object? e) { - globals.printError( + logger.printError( 'Input could not be parsed as a Debug Adapter Protocol message.\n' 'The "flutter debug-adapter" command is intended for use by tooling ' 'that communicates using the Debug Adapter Protocol.\n\n' diff --git a/packages/flutter_tools/lib/src/commands/devices.dart b/packages/flutter_tools/lib/src/commands/devices.dart index b7595156..53032a7f 100644 --- a/packages/flutter_tools/lib/src/commands/devices.dart +++ b/packages/flutter_tools/lib/src/commands/devices.dart @@ -7,13 +7,20 @@ import '../base/logger.dart'; import '../base/platform.dart'; import '../base/terminal.dart'; import '../base/utils.dart'; +import '../context/tool_context.dart'; import '../convert.dart'; import '../device.dart'; -import '../globals.dart' as globals; +import '../doctor.dart'; import '../runner/flutter_command.dart'; +/// The `flutter devices` command, which lists all connected devices. class DevicesCommand extends FlutterCommand { - DevicesCommand({bool verboseHelp = false}) { + DevicesCommand({ + required this._deviceManager, + required this._doctor, + required super.toolContext, + super.verboseHelp, + }) { addMachineOutputFlag(verboseHelp: verboseHelp); argParser.addOption( 'timeout', @@ -25,6 +32,9 @@ class DevicesCommand extends FlutterCommand { usesDeviceConnectionOption(); } + final DeviceManager _deviceManager; + final Doctor _doctor; + @override final name = 'devices'; @@ -34,6 +44,9 @@ class DevicesCommand extends FlutterCommand { @override final String category = FlutterCommandCategory.tools; + @override + ToolContext get toolContext => super.toolContext!; + @override Duration? get deviceDiscoveryTimeout { if (argResults?['timeout'] != null) { @@ -49,8 +62,10 @@ class DevicesCommand extends FlutterCommand { @override Future validateCommand() { if (argResults?['timeout'] != null) { - globals.printWarning( - '${globals.logger.terminal.warningMark} The "--timeout" argument is deprecated; use "--${FlutterOptions.kDeviceTimeout}" instead.', + final Logger logger = toolContext.logger; + final Terminal terminal = toolContext.terminal; + logger.printWarning( + '${terminal.warningMark} The "--timeout" argument is deprecated; use "--${FlutterOptions.kDeviceTimeout}" instead.', ); } return super.validateCommand(); @@ -58,7 +73,7 @@ class DevicesCommand extends FlutterCommand { @override Future runCommand() async { - if (globals.doctor?.canListAnything != true) { + if (!_doctor.canListAnything) { throwToolExit( "Unable to locate a development device; please run 'flutter doctor' for " 'information about installing additional components.', @@ -67,9 +82,9 @@ class DevicesCommand extends FlutterCommand { } final output = DevicesCommandOutput( - platform: globals.platform, - logger: globals.logger, - deviceManager: globals.deviceManager, + platform: toolContext.platform, + logger: toolContext.logger, + deviceManager: _deviceManager, deviceDiscoveryTimeout: deviceDiscoveryTimeout, deviceConnectionInterface: deviceConnectionInterface, ); @@ -80,39 +95,39 @@ class DevicesCommand extends FlutterCommand { } } +/// Output generator for the [DevicesCommand]. class DevicesCommandOutput { factory DevicesCommandOutput({ - required Platform platform, required Logger logger, - DeviceManager? deviceManager, - Duration? deviceDiscoveryTimeout, + required Platform platform, + required DeviceManager deviceManager, DeviceConnectionInterface? deviceConnectionInterface, + Duration? deviceDiscoveryTimeout, }) { if (platform.isMacOS) { return DevicesCommandOutputWithExtendedWirelessDeviceDiscovery( - logger: logger, - deviceManager: deviceManager, - deviceDiscoveryTimeout: deviceDiscoveryTimeout, deviceConnectionInterface: deviceConnectionInterface, + deviceDiscoveryTimeout: deviceDiscoveryTimeout, + deviceManager: deviceManager, + logger: logger, ); } return DevicesCommandOutput._private( - logger: logger, - deviceManager: deviceManager, - deviceDiscoveryTimeout: deviceDiscoveryTimeout, deviceConnectionInterface: deviceConnectionInterface, + deviceDiscoveryTimeout: deviceDiscoveryTimeout, + deviceManager: deviceManager, + logger: logger, ); } DevicesCommandOutput._private({ - required Logger logger, - required DeviceManager? deviceManager, - required this.deviceDiscoveryTimeout, - required this.deviceConnectionInterface, - }) : _deviceManager = deviceManager, - _logger = logger; + required this._deviceManager, + required this._logger, + this.deviceConnectionInterface, + this.deviceDiscoveryTimeout, + }); - final DeviceManager? _deviceManager; + final DeviceManager _deviceManager; final Logger _logger; final Duration? deviceDiscoveryTimeout; final DeviceConnectionInterface? deviceConnectionInterface; @@ -125,35 +140,30 @@ class DevicesCommandOutput { deviceConnectionInterface == null || deviceConnectionInterface == DeviceConnectionInterface.wireless; - Future> _getAttachedDevices(DeviceManager deviceManager) async { + Future> _getAttachedDevices() async { if (!_includeAttachedDevices) { return []; } - return deviceManager.getAllDevices( + return _deviceManager.getAllDevices( filter: DeviceDiscoveryFilter(deviceConnectionInterface: DeviceConnectionInterface.attached), ); } - Future> _getWirelessDevices(DeviceManager deviceManager) async { + Future> _getWirelessDevices() async { if (!_includeWirelessDevices) { return []; } - return deviceManager.getAllDevices( + return _deviceManager.getAllDevices( filter: DeviceDiscoveryFilter(deviceConnectionInterface: DeviceConnectionInterface.wireless), ); } Future findAndOutputAllTargetDevices({required bool machine}) async { - var attachedDevices = []; - var wirelessDevices = []; - final DeviceManager? deviceManager = _deviceManager; - if (deviceManager != null) { - // Refresh the cache and then get the attached and wireless devices from - // the cache. - await deviceManager.refreshAllDevices(timeout: deviceDiscoveryTimeout); - attachedDevices = await _getAttachedDevices(deviceManager); - wirelessDevices = await _getWirelessDevices(deviceManager); - } + // Refresh the cache and then get the attached and wireless devices from + // the cache. + await _deviceManager.refreshAllDevices(timeout: deviceDiscoveryTimeout); + final List attachedDevices = await _getAttachedDevices(); + final List wirelessDevices = await _getWirelessDevices(); final List allDevices = attachedDevices + wirelessDevices; if (machine) { @@ -186,7 +196,7 @@ class DevicesCommandOutput { Future _printDiagnostics({required bool foundAny}) async { final status = StringBuffer(); status.writeln(); - final List diagnostics = await _deviceManager?.getDeviceDiagnostics() ?? []; + final List diagnostics = await _deviceManager.getDeviceDiagnostics(); if (diagnostics.isNotEmpty) { for (final diagnostic in diagnostics) { status.writeln(diagnostic); @@ -209,9 +219,8 @@ class DevicesCommandOutput { Future printDevicesAsJson(List devices) async { _logger.printStatus( - const JsonEncoder.withIndent( - ' ', - ).convert(await Future.wait(devices.map((Device d) => d.toJson()))), + const JsonEncoder.withIndent(' ') + .convert(await Future.wait(devices.map((Device d) => d.toJson()))), ); } } @@ -222,10 +231,10 @@ const _noWirelessDevicesFoundMessage = 'No wireless devices were found.'; class DevicesCommandOutputWithExtendedWirelessDeviceDiscovery extends DevicesCommandOutput { DevicesCommandOutputWithExtendedWirelessDeviceDiscovery({ + required super.deviceManager, required super.logger, - super.deviceManager, - super.deviceDiscoveryTimeout, super.deviceConnectionInterface, + super.deviceDiscoveryTimeout, }) : super._private(); @override @@ -238,26 +247,20 @@ class DevicesCommandOutputWithExtendedWirelessDeviceDiscovery extends DevicesCom } if (machine) { - final List devices = - await _deviceManager?.refreshAllDevices( - filter: DeviceDiscoveryFilter(deviceConnectionInterface: deviceConnectionInterface), - timeout: DeviceManager.minimumWirelessDeviceDiscoveryTimeout, - ) ?? - []; + final List devices = await _deviceManager.refreshAllDevices( + filter: DeviceDiscoveryFilter(deviceConnectionInterface: deviceConnectionInterface), + timeout: DeviceManager.minimumWirelessDeviceDiscoveryTimeout, + ); await printDevicesAsJson(devices); return; } - final Future? extendedWirelessDiscovery = _deviceManager - ?.refreshExtendedWirelessDeviceDiscoverers( + final Future extendedWirelessDiscovery = _deviceManager + .refreshExtendedWirelessDeviceDiscoverers( timeout: DeviceManager.minimumWirelessDeviceDiscoveryTimeout, ); - var attachedDevices = []; - final DeviceManager? deviceManager = _deviceManager; - if (deviceManager != null) { - attachedDevices = await _getAttachedDevices(deviceManager); - } + final List attachedDevices = await _getAttachedDevices(); // Number of lines to clear starts at 1 because it's inclusive of the line // the cursor is on, which will be blank for this use case. @@ -283,10 +286,7 @@ class DevicesCommandOutputWithExtendedWirelessDeviceDiscovery extends DevicesCom final Status waitingStatus = _logger.startSpinner(); await extendedWirelessDiscovery; - var wirelessDevices = []; - if (deviceManager != null) { - wirelessDevices = await _getWirelessDevices(deviceManager); - } + final List wirelessDevices = await _getWirelessDevices(); waitingStatus.stop(); final Terminal terminal = _logger.terminal; @@ -302,7 +302,7 @@ class DevicesCommandOutputWithExtendedWirelessDeviceDiscovery extends DevicesCom _logger.printStatus(terminal.clearLines(numLinesToClear), newline: false); } - if (attachedDevices.isNotEmpty || !_logger.terminal.supportsColor) { + if (attachedDevices.isNotEmpty || !terminal.supportsColor) { _logger.printStatus(''); } diff --git a/packages/flutter_tools/lib/src/commands/doctor.dart b/packages/flutter_tools/lib/src/commands/doctor.dart index 9ee7b16a..91b0a630 100644 --- a/packages/flutter_tools/lib/src/commands/doctor.dart +++ b/packages/flutter_tools/lib/src/commands/doctor.dart @@ -2,13 +2,27 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import '../android/android_workflow.dart'; +import '../android/android_workflow.dart' as android_workflow; import '../base/common.dart'; -import '../globals.dart' as globals; +import '../context/tool_context.dart'; +import '../doctor.dart'; +import '../experimental/extension_manager.dart'; import '../runner/flutter_command.dart'; +/// The `flutter doctor` command, which displays diagnostic information about the +/// installed developer environment and tooling. class DoctorCommand extends FlutterCommand { - DoctorCommand({this.verbose = false}) { + /// Creates a new [DoctorCommand]. + /// + /// If [_androidLicenseValidator] is omitted, it is lazily resolved from the + /// active context when `--android-licenses` is supplied. + DoctorCommand({ + required this._doctor, + required ToolContext super.toolContext, + this._androidLicenseValidator, + this.extensionManager, + this.verbose = false, + }) { argParser.addFlag( 'android-licenses', negatable: false, @@ -24,7 +38,13 @@ class DoctorCommand extends FlutterCommand { ); } + final Doctor _doctor; + final android_workflow.AndroidLicenseValidator? _androidLicenseValidator; final bool verbose; + final ExtensionManager? extensionManager; + + @override + ToolContext get toolContext => super.toolContext!; @override final name = 'doctor'; @@ -40,7 +60,7 @@ class DoctorCommand extends FlutterCommand { if (argResults?.wasParsed('check-for-remote-artifacts') ?? false) { final String engineRevision = stringArg('check-for-remote-artifacts')!; if (engineRevision.startsWith(RegExp(r'[a-f0-9]{1,40}'))) { - final bool success = await globals.doctor?.checkRemoteArtifacts(engineRevision) ?? false; + final bool success = await _doctor.checkRemoteArtifacts(engineRevision); if (success) { throwToolExit( 'Artifacts for engine $engineRevision are missing or are ' @@ -55,13 +75,22 @@ class DoctorCommand extends FlutterCommand { ); } } - final bool success = - await globals.doctor?.diagnose( - androidLicenses: boolArg('android-licenses'), - verbose: verbose, - androidLicenseValidator: androidLicenseValidator, - ) ?? - false; + android_workflow.AndroidLicenseValidator? androidLicenseValidator = _androidLicenseValidator; + if (androidLicenseValidator == null && boolArg('android-licenses')) { + try { + androidLicenseValidator = android_workflow.androidLicenseValidator; + } on Exception { + // Fallback when running in hermetic test environments without AppContext. + } on Error { + // Catches UnsupportedError in testWithoutContext and StateError if AppContext is missing. + } + } + final bool success = await _doctor.diagnose( + androidLicenses: boolArg('android-licenses'), + verbose: verbose, + androidLicenseValidator: androidLicenseValidator, + extensionManager: extensionManager, + ); return FlutterCommandResult(success ? ExitStatus.success : ExitStatus.warning); } } diff --git a/packages/flutter_tools/lib/src/commands/downgrade.dart b/packages/flutter_tools/lib/src/commands/downgrade.dart index 2ae9409b..15813885 100644 --- a/packages/flutter_tools/lib/src/commands/downgrade.dart +++ b/packages/flutter_tools/lib/src/commands/downgrade.dart @@ -3,7 +3,6 @@ // found in the LICENSE file. import 'package:meta/meta.dart'; -import 'package:process/process.dart'; import '../base/common.dart'; import '../base/file_system.dart'; @@ -12,8 +11,8 @@ import '../base/logger.dart'; import '../base/process.dart'; import '../base/terminal.dart'; import '../cache.dart'; +import '../context/tool_context.dart'; import '../git.dart'; -import '../globals.dart' as globals; import '../persistent_tool_state.dart'; import '../runner/flutter_command.dart'; import '../version.dart'; @@ -41,21 +40,15 @@ String downgradePositionalArgumentErrorMessage(List args) { /// Additionally, if they had switched channels to stable before trying to downgrade, /// the command would fail since there was no previously recorded stable version. class DowngradeCommand extends FlutterCommand { - DowngradeCommand({ - bool verboseHelp = false, - PersistentToolState? persistentToolState, - required Logger logger, - FlutterVersion? flutterVersion, - Terminal? terminal, - Stdio? stdio, - FileSystem? fileSystem, - }) : _terminal = terminal, - _flutterVersion = flutterVersion, - _persistentToolState = persistentToolState, - _stdio = stdio, - _logger = logger, - _fileSystem = fileSystem, - _git = globals.git { + DowngradeCommand({required ToolContext toolContext, bool verboseHelp = false}) + : _terminal = toolContext.terminal, + _flutterVersion = toolContext.flutterVersion, + _persistentToolState = toolContext.persistentToolState, + _stdio = toolContext.stdio, + _logger = toolContext.logger, + _fileSystem = toolContext.fs, + _git = toolContext.git, + super(toolContext: toolContext, verboseHelp: verboseHelp) { argParser.addOption( 'working-directory', hide: !verboseHelp, @@ -73,14 +66,13 @@ class DowngradeCommand extends FlutterCommand { ); } - Terminal? _terminal; - FlutterVersion? _flutterVersion; - PersistentToolState? _persistentToolState; - ProcessManager? _processManager; + final Terminal _terminal; + FlutterVersion _flutterVersion; + final PersistentToolState _persistentToolState; final Logger _logger; final Git _git; - Stdio? _stdio; - FileSystem? _fileSystem; + final Stdio _stdio; + final FileSystem _fileSystem; @override String get description => 'Downgrade Flutter to the last active version for the current channel.'; @@ -100,19 +92,13 @@ class DowngradeCommand extends FlutterCommand { // Commands do not necessarily have access to the correct zone injected // values when being created. Fields must be lazily instantiated in runCommand, // at least until the zone injection is refactored. - _terminal ??= globals.terminal; - _flutterVersion ??= globals.flutterVersion; - _persistentToolState ??= globals.persistentToolState; - _processManager ??= globals.processManager; - _stdio ??= globals.stdio; - _fileSystem ??= globals.fs; String workingDirectory = Cache.flutterRoot!; if (argResults!.wasParsed('working-directory')) { workingDirectory = stringArg('working-directory')!; - _flutterVersion = FlutterVersion(fs: _fileSystem!, flutterRoot: workingDirectory, git: _git); + _flutterVersion = FlutterVersion(fs: _fileSystem, flutterRoot: workingDirectory, git: _git); } - final String currentChannel = _flutterVersion!.channel; + final String currentChannel = _flutterVersion.channel; final Channel? channel = getChannelForName(currentChannel); if (channel == null) { throwToolExit( @@ -120,9 +106,8 @@ class DowngradeCommand extends FlutterCommand { 'Use "flutter channel" to switch to an official channel. ', ); } - final PersistentToolState persistentToolState = _persistentToolState!; - final String? lastFlutterVersion = persistentToolState.lastActiveVersion(channel); - final String? currentFlutterVersion = _flutterVersion?.frameworkRevision; + final String? lastFlutterVersion = _persistentToolState.lastActiveVersion(channel); + final String currentFlutterVersion = _flutterVersion.frameworkRevision; if (lastFlutterVersion == null || currentFlutterVersion == lastFlutterVersion) { final String trailing = await _createErrorMessage(workingDirectory, channel); throwToolExit( @@ -149,11 +134,9 @@ class DowngradeCommand extends FlutterCommand { final String humanReadableVersion = parseResult.stdout; // If there is a terminal attached, prompt the user to confirm the downgrade. - final Stdio stdio = _stdio!; - final Terminal terminal = _terminal!; - if (stdio.hasTerminal && boolArg('prompt')) { - terminal.usesTerminalUi = true; - final String result = await terminal.promptForCharInput( + if (_stdio.hasTerminal && boolArg('prompt')) { + _terminal.usesTerminalUi = true; + final String result = await _terminal.promptForCharInput( const ['y', 'n'], prompt: 'Downgrade flutter to version $humanReadableVersion?', logger: _logger, @@ -208,7 +191,7 @@ class DowngradeCommand extends FlutterCommand { if (channel == currentChannel) { continue; } - final String? sha = _persistentToolState?.lastActiveVersion(channel); + final String? sha = _persistentToolState.lastActiveVersion(channel); if (sha == null) { continue; } diff --git a/packages/flutter_tools/lib/src/commands/drive.dart b/packages/flutter_tools/lib/src/commands/drive.dart index 87f47ba6..5dd1215b 100644 --- a/packages/flutter_tools/lib/src/commands/drive.dart +++ b/packages/flutter_tools/lib/src/commands/drive.dart @@ -4,33 +4,29 @@ import 'dart:async'; -import 'package:args/args.dart'; import 'package:meta/meta.dart'; import 'package:package_config/package_config_types.dart'; import '../android/android_device.dart'; import '../application_package.dart'; -import '../artifacts.dart'; import '../base/common.dart'; import '../base/file_system.dart'; import '../base/io.dart'; import '../base/logger.dart'; -import '../base/platform.dart'; import '../base/signals.dart'; -import '../base/terminal.dart'; -import '../base/utils.dart'; import '../build_info.dart'; +import '../context/tool_context.dart'; import '../dart/package_map.dart'; import '../device.dart'; import '../drive/drive_service.dart'; +import '../drive/import_validator.dart'; import '../drive/web_driver_service.dart' show Browser; -import '../globals.dart' as globals; import '../ios/devices.dart'; import '../resident_runner.dart'; -import '../runner/flutter_command.dart' - show FlutterCommandCategory, FlutterCommandResult, FlutterOptions; +import '../runner/flutter_command.dart'; import '../web/devfs_config.dart'; import '../web/web_device.dart'; +import '../web/web_options.dart'; import 'run.dart'; /// Runs integration (a.k.a. end-to-end) tests. @@ -55,142 +51,154 @@ import 'run.dart'; /// exit code. class DriveCommand extends RunCommandBase { DriveCommand({ - bool verboseHelp = false, - @visibleForTesting FlutterDriverFactory? flutterDriverFactory, + required super.buildSystem, + required super.buildTargets, + required super.toolContext, + @visibleForTesting this._flutterDriverFactory, @visibleForTesting this.signalsToHandle = const {ProcessSignal.sigint, ProcessSignal.sigterm}, - required FileSystem fileSystem, - required Logger logger, - required Platform platform, - required Terminal terminal, - required OutputPreferences outputPreferences, - required this.signals, - }) : _flutterDriverFactory = flutterDriverFactory, - _fileSystem = fileSystem, - _logger = logger, - _platform = platform, - _terminal = terminal, - _outputPreferences = outputPreferences, - _fsUtils = FileSystemUtils(fileSystem: fileSystem, platform: platform), - super(verboseHelp: verboseHelp) { + super.verboseHelp = false, + }) { requiresPubspecYaml(); - addEnableExperimentation(hide: !verboseHelp); - - // By default, the drive app should not publish the VM service port over mDNS - // to prevent a local network permission dialog on iOS 14+, - // which cannot be accepted or dismissed in a CI environment. - addPublishPort(enabledByDefault: false, verboseHelp: verboseHelp); - argParser - ..addFlag( - _kKeepAppRunning, - help: - 'Will keep the Flutter application running when done testing.\n' - 'By default, "flutter drive" stops the application after tests are finished, ' - 'and "--$_kKeepAppRunning" overrides this. On the other hand, if "--use-existing-app" ' - 'is specified, then "flutter drive" instead defaults to leaving the application ' - 'running, and "--no-$_kKeepAppRunning" overrides it.', - ) - ..addOption( - _kUseExistingApp, - help: - 'Connect to an already running instance via the given Dart VM Service URL. ' - 'If this option is given, the application will not be automatically started, ' - 'and it will only be stopped if "--no-$_kKeepAppRunning" is explicitly set.', - valueHelp: 'url', - ) - ..addOption( - 'driver', - help: - 'The test file to run on the host (as opposed to the target file to run on ' - 'the device).\n' - 'By default, this file has the same base name as the target file, but in the ' - '"test_driver/" directory instead, and with "_test" inserted just before the ' - 'extension, so e.g. if the target is "lib/main.dart", the driver will be ' - '"test_driver/main_test.dart".', - valueHelp: 'path', - ) - ..addFlag( - 'build', - defaultsTo: true, - help: - '(deprecated) Build the app before running. To use an existing app, pass the "--${FlutterOptions.kUseApplicationBinary}" ' - 'flag with an existing APK.', - ) - ..addOption( - 'screenshot', - valueHelp: 'path/to/directory', - help: 'Directory location to write screenshots on test failure.', - ) - ..addOption( - 'driver-port', - defaultsTo: '4444', - help: 'The port where Webdriver server is launched at.', - valueHelp: '4444', - ) - ..addFlag( - 'headless', - defaultsTo: true, - help: 'Whether the driver browser is going to be launched in headless mode.', - ) - ..addOption( - 'browser-name', - defaultsTo: Browser.chrome.cliName, - help: 'Name of the browser where tests will be executed.', - allowed: Browser.values.map((Browser e) => e.cliName), - allowedHelp: CliEnum.allowedHelp(Browser.values), - ) - ..addOption( - 'browser-dimension', - defaultsTo: '1600x1024', - help: - 'The dimension of the browser when running a Flutter Web test. ' - 'Format is "width x height[@dpr]" where dpr is optional device pixel ratio. ' - 'This will affect screenshot dimensions and all offset-related actions.', - valueHelp: '1600x1024[@1]', - ) - ..addFlag( - 'android-emulator', - defaultsTo: true, - help: - 'Whether to perform Flutter Driver testing using an Android Emulator. ' - 'Works only if "browser-name" is set to "android-chrome".', - ) - ..addOption( - 'chrome-binary', - help: - 'Location of the Chrome binary. ' - 'Works only if "browser-name" is set to "chrome".', - ) - ..addMultiOption( - 'test-arguments', - help: - 'Additional arguments to pass to the Dart VM running The test script.\n\n' - 'This can be used to opt-in to use "dart test" as a runner for the test script, ' - 'which allows, among other things, changing the reporter. For example, to opt-in ' - 'to the "expanded" reporter, pass both "test" and "--reporter=expanded".\n\n' - 'Please leave feedback at .', - ) - ..addOption( - 'profile-memory', - help: - 'Launch devtools and profile application memory, writing ' - 'The output data to the file path provided to this argument as JSON.', - valueHelp: 'profile_memory.json', - ) - ..addOption( - 'timeout', - help: - 'Timeout the test after the given number of seconds. If the ' - '"--screenshot" option is provided, a screenshot will be taken ' - 'before exiting. Defaults to no timeout.', - valueHelp: '360', - ); + + argParser.addDescriptors(>[ + CommonOptions.enableExperiment, + // By default, the drive app should not publish the VM service port over mDNS + // to prevent a local network permission dialog on iOS 14+, + // which cannot be accepted or dismissed in a CI environment. + _publishPort, + _keepAppRunning, + _useExistingApp, + _driver, + DebuggingOptionDescriptors.build, + _screenshot, + _driverPort, + _headless, + _browserName, + _browserDimension, + _androidEmulator, + _chromeBinary, + _testArguments, + _profileMemory, + _timeout, + ], verboseHelp: verboseHelp); } + static final FlagOptionDescriptor _publishPort = DebuggingOptionDescriptors.publishPortOption( + enabledByDefault: false, + ); + static const _kKeepAppRunning = 'keep-app-running'; static const _kUseExistingApp = 'use-existing-app'; - final Signals signals; + static const _keepAppRunning = FlagOptionDescriptor( + name: _kKeepAppRunning, + help: + 'Will keep the Flutter application running when done testing.\n' + 'By default, "flutter drive" stops the application after tests are finished, ' + 'and "--$_kKeepAppRunning" overrides this. On the other hand, if "--use-existing-app" ' + 'is specified, then "flutter drive" instead defaults to leaving the application ' + 'running, and "--no-$_kKeepAppRunning" overrides it.', + ); + + static const _useExistingApp = StringOptionDescriptor( + name: _kUseExistingApp, + help: + 'Connect to an already running instance via the given Dart VM Service URL. ' + 'If this option is given, the application will not be automatically started, ' + 'and it will only be stopped if "--no-$_kKeepAppRunning" is explicitly set.', + valueHelp: 'url', + ); + + static const _driver = StringOptionDescriptor( + name: 'driver', + help: + 'The test file to run on the host (as opposed to the target file to run on ' + 'the device).\n' + 'By default, this file has the same base name as the target file, but in the ' + '"test_driver/" directory instead, and with "_test" inserted just before the ' + 'extension, so e.g. if the target is "lib/main.dart", the driver will be ' + '"test_driver/main_test.dart".', + valueHelp: 'path', + ); + + static const _screenshot = StringOptionDescriptor( + name: 'screenshot', + valueHelp: 'path/to/directory', + help: 'Directory location to write screenshots on test failure.', + ); + + static const _driverPort = IntOptionDescriptor( + name: 'driver-port', + defaultsTo: 4444, + help: 'The port where Webdriver server is launched at.', + valueHelp: '4444', + ); + + static const _headless = FlagOptionDescriptor( + name: 'headless', + defaultsTo: true, + help: 'Whether the driver browser is going to be launched in headless mode.', + ); + + static const _browserName = DefaultedEnumOptionDescriptor( + name: 'browser-name', + defaultsTo: Browser.chrome, + values: Browser.values, + help: 'Name of the browser where tests will be executed.', + ); + + static const _browserDimension = DefaultedStringOptionDescriptor( + name: 'browser-dimension', + defaultsTo: '1600x1024', + help: + 'The dimension of the browser when running a Flutter Web test. ' + 'Format is "width x height[@dpr]" where dpr is optional device pixel ratio. ' + 'This will affect screenshot dimensions and all offset-related actions.', + valueHelp: '1600x1024[@1]', + ); + + static const _androidEmulator = FlagOptionDescriptor( + name: 'android-emulator', + defaultsTo: true, + help: + 'Whether to perform Flutter Driver testing using an Android Emulator. ' + 'Works only if "browser-name" is set to "android-chrome".', + ); + + static const _chromeBinary = StringOptionDescriptor( + name: 'chrome-binary', + help: + 'Location of the Chrome binary. ' + 'Works only if "browser-name" is set to "chrome".', + ); + + static const _testArguments = MultiOptionDescriptor( + name: 'test-arguments', + help: + 'Additional arguments to pass to the Dart VM running The test script.\n\n' + 'This can be used to opt-in to use "dart test" as a runner for the test script, ' + 'which allows, among other things, changing the reporter. For example, to opt-in ' + 'to the "expanded" reporter, pass both "test" and "--reporter=expanded".\n\n' + 'Please leave feedback at .', + ); + + static const _profileMemory = StringOptionDescriptor( + name: 'profile-memory', + help: + 'Launch devtools and profile application memory, writing ' + 'The output data to the file path provided to this argument as JSON.', + valueHelp: 'profile_memory.json', + ); + + static const _timeout = StringOptionDescriptor( + name: 'timeout', + help: + 'Timeout the test after the given number of seconds. If the ' + '"--screenshot" option is provided, a screenshot will be taken ' + 'before exiting. Defaults to no timeout.', + valueHelp: '360', + ); /// The [ProcessSignal]s that will lead to a screenshot being taken (if the option is provided). final Set signalsToHandle; @@ -200,19 +208,14 @@ class DriveCommand extends RunCommandBase { // specified not to. @override bool get shouldRunPub { - if (argResults!.wasParsed('pub') && !boolArg('pub')) { + if (wasParsed(CommonOptions.pub) && !getValue(CommonOptions.pub)) { return false; } return true; } FlutterDriverFactory? _flutterDriverFactory; - final FileSystem _fileSystem; - final Logger _logger; - final Platform _platform; - final Terminal _terminal; - final OutputPreferences _outputPreferences; - final FileSystemUtils _fsUtils; + Timer? timeoutTimer; Map? screenshotTokens; @@ -233,9 +236,9 @@ class DriveCommand extends RunCommandBase { @override final aliases = ['driver']; - String? get userIdentifier => stringArg(FlutterOptions.kDeviceUser); + String? get userIdentifier => getValue(CommonOptions.deviceUser); - String? get screenshot => stringArg('screenshot'); + String? get screenshot => getValue(_screenshot); @override bool get startPausedDefault => true; @@ -243,7 +246,7 @@ class DriveCommand extends RunCommandBase { @override bool get cachePubGet => false; - String? get applicationBinaryPath => stringArg(FlutterOptions.kUseApplicationBinary); + String? get applicationBinaryPath => getValue(CommonOptions.useApplicationBinary); Future get targetedDevice async { return findTargetDevice(includeDevicesUnsupportedByProject: applicationBinaryPath == null); @@ -254,43 +257,80 @@ class DriveCommand extends RunCommandBase { // change it to be enabled. @override Future get disablePortPublication async { - final ArgResults? localArgResults = argResults; final Device? device = await targetedDevice; final bool isWirelessIOSDevice = device is IOSDevice && device.isWirelesslyConnected; - if (isWirelessIOSDevice && - localArgResults != null && - !localArgResults.wasParsed('publish-port')) { - _logger.printTrace( + if (isWirelessIOSDevice && argResults != null && !wasParsed(_publishPort)) { + toolContext.logger.printTrace( 'A wireless iOS device is being used. Changing `publish-port` to be enabled.', ); return false; } - return !boolArg('publish-port'); + return !getValue(_publishPort); } @override Future validateCommand() async { + // For Android prebuilt applications run in release mode, validate that engine configuration flags + // are not passed. + validatePrebuiltAndroidApplicationFlags(); + + final ToolContext(:FileSystem fs, :Logger logger) = toolContext; if (userIdentifier != null) { final Device? device = await findTargetDevice(); if (device is! AndroidDevice) { throwToolExit('--${FlutterOptions.kDeviceUser} is only supported for Android'); } } + + // Ensure host-side flutter_driver test scripts do not import device-side + // libraries (e.g. dart:ui, package:flutter, package:flutter_test). + final String? testFile = _getTestFile(); + if (testFile != null && fs.isFileSync(testFile)) { + final File packageConfigFile = findPackageConfigFileOrDefault(fs.currentDirectory); + if (packageConfigFile.existsSync()) { + final PackageConfig packageConfig = await loadPackageConfigWithLogging( + packageConfigFile, + logger: logger, + throwOnError: false, + ); + final validator = DriverTestImportValidator( + fileSystem: fs, + logger: logger, + packageConfig: packageConfig, + projectRootPath: fs.currentDirectory.path, + ); + final List errors = validator.validate(fs.file(testFile)); + if (errors.isNotEmpty) { + final buffer = StringBuffer(); + buffer.writeln('flutter_driver test "$testFile" has invalid imports:'); + for (final error in errors) { + buffer.writeln(' $error'); + } + buffer.writeln( + 'flutter_driver tests run on the host VM and cannot import libraries that ' + 'depend on dart:ui (like package:flutter or package:flutter_test).', + ); + throwToolExit(buffer.toString()); + } + } + } + return super.validateCommand(); } @override Future runCommand() async { + final ToolContext(:FileSystem fs, :Logger logger) = toolContext; final String? testFile = _getTestFile(); if (testFile == null) { throwToolExit(null); } - if (await _fileSystem.type(testFile) != FileSystemEntityType.file) { + if (await fs.type(testFile) != FileSystemEntityType.file) { // A very common source of error is holding "flutter drive" wrong, // and providing the "test_driver/foo_test.dart" as the target, when // the intention was to provide "lib/foo.dart". - if (_fileSystem.path.isWithin('test_driver', targetFile)) { - _logger.printError( + if (fs.path.isWithin('test_driver', targetFile)) { + logger.printError( 'The file path passed to --target should be an app entrypoint that ' 'contains a "main()". Did you mean "flutter drive --driver $targetFile"?', ); @@ -302,7 +342,7 @@ class DriveCommand extends RunCommandBase { throwToolExit(null); } if (screenshot != null && !device.supportsScreenshot) { - _logger.printError('Screenshot not supported for ${device.displayName}.'); + logger.printError('Screenshot not supported for ${device.displayName}.'); } final WebDevServerConfig? webDevServerConfig = @@ -314,20 +354,19 @@ class DriveCommand extends RunCommandBase { final web = webDevServerConfig != null; _flutterDriverFactory ??= FlutterDriverFactory( + analytics: analytics, applicationPackageFactory: ApplicationPackageFactory.instance!, - logger: _logger, - platform: _platform, - terminal: _terminal, - outputPreferences: _outputPreferences, - processUtils: globals.processUtils, - dartSdkPath: globals.artifacts!.getArtifactPath(Artifact.engineDartBinary), + buildSystem: buildSystem, + buildTargets: buildTargets, + dartSdkPath: toolContext.artifacts.getArtifactPath(.engineDartBinary), devtoolsLauncher: DevtoolsLauncher.instance!, + toolContext: toolContext, ); - final File packageConfigFile = findPackageConfigFileOrDefault(_fileSystem.currentDirectory); + final File packageConfigFile = findPackageConfigFileOrDefault(fs.currentDirectory); final PackageConfig packageConfig = await loadPackageConfigWithLogging( packageConfigFile, - logger: _logger, + logger: logger, throwOnError: false, ); final DriverService driverService = _flutterDriverFactory!.createDriverService(web); @@ -337,11 +376,12 @@ class DriveCommand extends RunCommandBase { ); final File? applicationBinary = applicationBinaryPath == null ? null - : _fileSystem.file(applicationBinaryPath); + : fs.file(applicationBinaryPath); var screenshotTaken = false; try { - if (stringArg(_kUseExistingApp) == null) { + final String? useExistingApp = getValue(_useExistingApp); + if (useExistingApp == null) { await driverService.start( buildInfo, device, @@ -357,32 +397,31 @@ class DriveCommand extends RunCommandBase { webDefines: extractWebDefines(), ); } else { - final Uri? uri = Uri.tryParse(stringArg(_kUseExistingApp)!); + final Uri? uri = Uri.tryParse(useExistingApp); if (uri == null) { - throwToolExit('Invalid VM Service URI: ${stringArg(_kUseExistingApp)}'); + throwToolExit('Invalid VM Service URI: $useExistingApp'); } await driverService.reuseApplication(uri, device, debuggingOptions); } - final Future testResultFuture = driverService.startTest( - testFile, - stringsArg('test-arguments'), - packageConfig, - chromeBinary: stringArg('chrome-binary'), - headless: boolArg('headless'), - webBrowserFlags: stringsArg(FlutterOptions.kWebBrowserFlag), - browserDimension: stringArg('browser-dimension')!.split(RegExp('[,x@]')), - browserName: stringArg('browser-name'), - driverPort: stringArg('driver-port') != null - ? int.tryParse(stringArg('driver-port')!) - : null, - androidEmulator: boolArg('android-emulator'), - profileMemory: stringArg('profile-memory'), + final driveSpec = DriveTestSpecification( + arguments: getValue(_testArguments), + packageConfig: packageConfig, + chromeBinary: getValue(_chromeBinary), + headless: getValue(_headless), + webBrowserFlags: getValue(WebOptions.webBrowserFlags), + browserDimension: DriveTestSpecification.parseBrowserDimension(getValue(_browserDimension)), + browserName: getValue(_browserName).cliName, + driverPort: getValue(_driverPort), + androidEmulator: getValue(_androidEmulator), + profileMemory: getValue(_profileMemory), ); + final Future testResultFuture = driverService.startTest(testFile, driveSpec); + if (screenshot != null) { // If the test is sent a signal or times out, take a screenshot - _registerScreenshotCallbacks(device, _fileSystem.directory(screenshot)); + _registerScreenshotCallbacks(device, fs.directory(screenshot)); } final int testResult = await testResultFuture; @@ -394,23 +433,23 @@ class DriveCommand extends RunCommandBase { if (testResult != 0 && screenshot != null) { // Take a screenshot while the app is still running. - await _takeScreenshot(device, _fileSystem.directory(screenshot)); + await _takeScreenshot(device, fs.directory(screenshot)); screenshotTaken = true; } if (_keepAppRunningWhenComplete) { - _logger.printStatus('Leaving the application running.'); + logger.printStatus('Leaving the application running.'); } else { await driverService.stop(userIdentifier: userIdentifier); } if (testResult != 0) { throwToolExit(null); } - } on Exception catch (_) { + } on Exception { // On exceptions, including ToolExit, take a screenshot on the device // unless a screenshot was already taken on test failure. if (!screenshotTaken && screenshot != null) { - await _takeScreenshot(device, _fileSystem.directory(screenshot)); + await _takeScreenshot(device, fs.directory(screenshot)); } rethrow; } @@ -423,20 +462,20 @@ class DriveCommand extends RunCommandBase { /// /// Interprets the results of `--keep-app-running` and `--use-existing-app`. bool get _keepAppRunningWhenComplete { - if (boolArg(_kKeepAppRunning)) { + if (getValue(_keepAppRunning)) { // --keep-app-running return true; - } else if (argResults!.wasParsed(_kKeepAppRunning)) { + } else if (wasParsed(_keepAppRunning)) { // --no-keep-app-running return false; } else { // Default --keep-app-running to whether --use-existing-app was used. - return argResults!.wasParsed(_kUseExistingApp); + return wasParsed(_useExistingApp); } } int? get _timeoutSeconds { - final String? timeoutString = stringArg('timeout'); + final String? timeoutString = getValue(_timeout); if (timeoutString == null) { return null; } @@ -451,12 +490,13 @@ class DriveCommand extends RunCommandBase { } void _registerScreenshotCallbacks(Device device, Directory screenshotDir) { - _logger.printTrace('Registering signal handlers...'); + final ToolContext(:Logger logger, :Signals signals) = toolContext; + logger.printTrace('Registering signal handlers...'); final tokens = {}; for (final ProcessSignal signal in signalsToHandle) { tokens[signal] = signals.addHandler(signal, (ProcessSignal signal) { _unregisterScreenshotCallbacks(); - _logger.printError('Caught $signal'); + logger.printError('Caught $signal'); return _takeScreenshot(device, screenshotDir); }); } @@ -474,7 +514,8 @@ class DriveCommand extends RunCommandBase { void _unregisterScreenshotCallbacks() { if (screenshotTokens != null) { - _logger.printTrace('Unregistering signal handlers...'); + final ToolContext(:Logger logger, :Signals signals) = toolContext; + logger.printTrace('Unregistering signal handlers...'); for (final MapEntry entry in screenshotTokens!.entries) { signals.removeHandler(entry.key, entry.value); } @@ -483,34 +524,34 @@ class DriveCommand extends RunCommandBase { } String? _getTestFile() { - if (argResults!['driver'] != null) { - return stringArg('driver'); + final ToolContext(:FileSystem fs, :Logger logger) = toolContext; + final String? driver = getValue(_driver); + if (driver != null) { + return driver; } // If the --driver argument wasn't provided, then derive the value from // the target file. - String appFile = _fileSystem.path.normalize(targetFile); + String appFile = fs.path.normalize(targetFile); // This command extends `flutter run` and therefore CWD == package dir - final String packageDir = _fileSystem.currentDirectory.path; + final String packageDir = fs.currentDirectory.path; // Make appFile path relative to package directory because we are looking // for the corresponding test file relative to it. - if (!_fileSystem.path.isRelative(appFile)) { - if (!_fileSystem.path.isWithin(packageDir, appFile)) { - _logger.printError( - 'Application file $appFile is outside the package directory $packageDir', - ); + if (!fs.path.isRelative(appFile)) { + if (!fs.path.isWithin(packageDir, appFile)) { + logger.printError('Application file $appFile is outside the package directory $packageDir'); return null; } - appFile = _fileSystem.path.relative(appFile, from: packageDir); + appFile = fs.path.relative(appFile, from: packageDir); } - final List parts = _fileSystem.path.split(appFile); + final List parts = fs.path.split(appFile); if (parts.length < 2) { - _logger.printError( + logger.printError( 'Application file $appFile must reside in one of the sub-directories ' 'of the package structure, not in the root directory.', ); @@ -520,23 +561,24 @@ class DriveCommand extends RunCommandBase { // Look for the test file inside `test_driver/` matching the sub-path, e.g. // if the application is `lib/foo/bar.dart`, the test file is expected to // be `test_driver/foo/bar_test.dart`. - final String pathWithNoExtension = _fileSystem.path.withoutExtension( - _fileSystem.path.joinAll([packageDir, 'test_driver', ...parts.skip(1)]), + final String pathWithNoExtension = fs.path.withoutExtension( + fs.path.joinAll([packageDir, 'test_driver', ...parts.skip(1)]), ); - return '${pathWithNoExtension}_test${_fileSystem.path.extension(appFile)}'; + return '${pathWithNoExtension}_test${fs.path.extension(appFile)}'; } Future _takeScreenshot(Device device, Directory outputDirectory) async { if (!device.supportsScreenshot) { return; } + final ToolContext(:FileSystemUtils fileSystemUtils, :Logger logger) = toolContext; try { outputDirectory.createSync(recursive: true); - final File outputFile = _fsUtils.getUniqueFile(outputDirectory, 'drive', 'png'); + final File outputFile = fileSystemUtils.getUniqueFile(outputDirectory, 'drive', 'png'); await device.takeScreenshot(outputFile); - _logger.printStatus('Screenshot written to ${outputFile.path}'); + logger.printStatus('Screenshot written to ${outputFile.path}'); } on Exception catch (error) { - _logger.printError('Error taking screenshot: $error'); + logger.printError('Error taking screenshot: $error'); } } } diff --git a/packages/flutter_tools/lib/src/commands/emulators.dart b/packages/flutter_tools/lib/src/commands/emulators.dart index f8753052..939b6d5e 100644 --- a/packages/flutter_tools/lib/src/commands/emulators.dart +++ b/packages/flutter_tools/lib/src/commands/emulators.dart @@ -5,14 +5,23 @@ import 'package:args/args.dart'; import '../base/common.dart'; +import '../base/logger.dart'; +import '../base/platform.dart'; import '../base/utils.dart'; +import '../context/tool_context.dart'; +import '../doctor.dart'; import '../doctor_validator.dart'; import '../emulator.dart'; -import '../globals.dart' as globals; import '../runner/flutter_command.dart'; +/// The `flutter emulators` command, which lists, launches, and creates emulators. class EmulatorsCommand extends FlutterCommand { - EmulatorsCommand() { + EmulatorsCommand({ + required this._doctor, + required this._emulatorManager, + required ToolContext super.toolContext, + super.verboseHelp, + }) { argParser.addOption('launch', help: 'The full or partial ID of the emulator to launch.'); argParser.addFlag( 'cold', @@ -30,6 +39,12 @@ class EmulatorsCommand extends FlutterCommand { ); } + final Doctor _doctor; + final EmulatorManager _emulatorManager; + + @override + ToolContext get toolContext => super.toolContext!; + @override final name = 'emulators'; @@ -44,10 +59,12 @@ class EmulatorsCommand extends FlutterCommand { @override Future runCommand() async { - if (globals.doctor!.workflows.every((Workflow w) => !w.canListEmulators)) { + final Platform platform = toolContext.platform; + + if (_doctor.workflows.every((Workflow w) => !w.canListEmulators)) { throwToolExit( 'Unable to find any emulator sources. Please ensure you have some\n' - 'Android AVD images ${globals.platform.isMacOS ? 'or an iOS Simulator ' : ''}available.', + 'Android AVD images ${platform.isMacOS ? 'or an iOS Simulator ' : ''}available.', exitCode: 1, ); } @@ -58,9 +75,7 @@ class EmulatorsCommand extends FlutterCommand { } else if (argumentResults.wasParsed('create')) { await _createEmulator(name: stringArg('name')); } else { - final String? searchText = argumentResults.rest.isNotEmpty - ? argumentResults.rest.first - : null; + final String? searchText = argumentResults.rest.firstOrNull; await _listEmulators(searchText); } @@ -68,10 +83,11 @@ class EmulatorsCommand extends FlutterCommand { } Future _launchEmulator(String id, {required bool coldBoot}) async { - final List emulators = await emulatorManager!.getEmulatorsMatching(id); + final Logger logger = toolContext.logger; + final List emulators = await _emulatorManager.getEmulatorsMatching(id); if (emulators.isEmpty) { - globals.printStatus("No emulator found that matches '$id'."); + logger.printStatus("No emulator found that matches '$id'."); } else if (emulators.length > 1) { _printEmulatorList(emulators, "More than one emulator matches '$id':"); } else { @@ -80,27 +96,29 @@ class EmulatorsCommand extends FlutterCommand { } Future _createEmulator({String? name}) async { - final CreateEmulatorResult createResult = await emulatorManager!.createEmulator(name: name); + final Logger logger = toolContext.logger; + final CreateEmulatorResult createResult = await _emulatorManager.createEmulator(name: name); if (createResult.success) { - globals.printStatus("Emulator '${createResult.emulatorName}' created successfully."); + logger.printStatus("Emulator '${createResult.emulatorName}' created successfully."); } else { - globals.printStatus("Failed to create emulator '${createResult.emulatorName}'.\n"); + logger.printStatus("Failed to create emulator '${createResult.emulatorName}'.\n"); final String? error = createResult.error; if (error != null) { - globals.printStatus(error.trim()); + logger.printStatus(error.trim()); } _printAdditionalInfo(); } } Future _listEmulators(String? searchText) async { + final Logger logger = toolContext.logger; final List emulators = searchText == null - ? await emulatorManager!.getAllAvailableEmulators() - : await emulatorManager!.getEmulatorsMatching(searchText); + ? await _emulatorManager.getAllAvailableEmulators() + : await _emulatorManager.getEmulatorsMatching(searchText); if (emulators.isEmpty) { - globals.printStatus('No emulators available.'); + logger.printStatus('No emulators available.'); _printAdditionalInfo(showCreateInstruction: true); } else { _printEmulatorList( @@ -111,28 +129,30 @@ class EmulatorsCommand extends FlutterCommand { } void _printEmulatorList(List emulators, String message) { - globals.printStatus('$message\n'); - Emulator.printEmulators(emulators, globals.logger); + final Logger logger = toolContext.logger; + logger.printStatus('$message\n'); + Emulator.printEmulators(emulators, logger); _printAdditionalInfo(showCreateInstruction: true, showRunInstruction: true); } void _printAdditionalInfo({bool showRunInstruction = false, bool showCreateInstruction = false}) { - globals.printStatus(''); + final Logger logger = toolContext.logger; + logger.printStatus(''); if (showRunInstruction) { - globals.printStatus("To run an emulator, run 'flutter emulators --launch '."); + logger.printStatus("To run an emulator, run 'flutter emulators --launch '."); } if (showCreateInstruction) { - globals.printStatus( + logger.printStatus( "To create a new emulator, run 'flutter emulators --create [--name xyz]'.", ); } if (showRunInstruction || showCreateInstruction) { - globals.printStatus(''); + logger.printStatus(''); } // TODO(dantup): Update this link to flutter.dev if/when we have a better page. // That page can then link out to these places if required. - globals.printStatus( + logger.printStatus( 'You can find more information on managing emulators at the links below:\n' ' https://developer.android.com/studio/run/managing-avds\n' ' https://developer.android.com/studio/command-line/avdmanager', diff --git a/packages/flutter_tools/lib/src/commands/generate.dart b/packages/flutter_tools/lib/src/commands/generate.dart index c440d267..d91bbf31 100644 --- a/packages/flutter_tools/lib/src/commands/generate.dart +++ b/packages/flutter_tools/lib/src/commands/generate.dart @@ -2,13 +2,19 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import '../globals.dart' as globals; +import '../context/tool_context.dart'; import '../runner/flutter_command.dart'; class GenerateCommand extends FlutterCommand { - GenerateCommand() { + GenerateCommand({required ToolContext super.toolContext}) : _toolContext = toolContext { usesTargetOption(); } + + final ToolContext _toolContext; + + @override + ToolContext get toolContext => _toolContext; + @override String get description => 'run code generators.'; @@ -20,7 +26,7 @@ class GenerateCommand extends FlutterCommand { @override Future runCommand() async { - globals.printError( + _toolContext.logger.printError( '"flutter generate" is deprecated, use "dart run build_runner" instead. ' 'The following dependencies must be added to dev_dependencies in pubspec.yaml:\n' 'build_runner: ^1.10.0\n' diff --git a/packages/flutter_tools/lib/src/commands/generate_localizations.dart b/packages/flutter_tools/lib/src/commands/generate_localizations.dart index 186a6c7a..1005d0f0 100644 --- a/packages/flutter_tools/lib/src/commands/generate_localizations.dart +++ b/packages/flutter_tools/lib/src/commands/generate_localizations.dart @@ -8,6 +8,7 @@ import '../artifacts.dart'; import '../base/common.dart'; import '../base/file_system.dart'; import '../base/logger.dart'; +import '../context/tool_context.dart'; import '../localizations/gen_l10n.dart'; import '../localizations/localizations_utils.dart'; import '../runner/flutter_command.dart'; @@ -19,15 +20,8 @@ import '../runner/flutter_command.dart'; /// For a more comprehensive tutorial on the tool, please see the /// [internationalization guide](https://flutter.dev/to/internationalization). class GenerateLocalizationsCommand extends FlutterCommand { - GenerateLocalizationsCommand({ - required FileSystem fileSystem, - required Logger logger, - required Artifacts artifacts, - required ProcessManager processManager, - }) : _fileSystem = fileSystem, - _logger = logger, - _artifacts = artifacts, - _processManager = processManager { + GenerateLocalizationsCommand({required ToolContext super.toolContext}) + : _toolContext = toolContext { argParser.addOption( 'arb-dir', help: 'The directory where the template and translated arb files are located.', @@ -183,8 +177,7 @@ class GenerateLocalizationsCommand extends FlutterCommand { ); argParser.addFlag( 'format', - help: - 'When specified, the "dart format" command is run after generating the localization files.', + help: 'When specified, the "dart format" command is run after generating the localization files.', ); argParser.addFlag( 'use-escaping', @@ -214,10 +207,10 @@ class GenerateLocalizationsCommand extends FlutterCommand { ); } - final FileSystem _fileSystem; - final Logger _logger; - final Artifacts _artifacts; - final ProcessManager _processManager; + final ToolContext _toolContext; + + @override + ToolContext get toolContext => _toolContext; @override String get description => 'Generate localizations for the current project.'; @@ -234,39 +227,49 @@ class GenerateLocalizationsCommand extends FlutterCommand { if (argResults!.rest.isNotEmpty) { throwToolExit('Unexpected positional argument "${argResults!.rest.first}".'); } + final FileSystem fs = _toolContext.fs; + final Logger logger = _toolContext.logger; + final ProcessManager processManager = _toolContext.processManager; + final Artifacts artifacts = _toolContext.artifacts; + // Keep in mind that this is also defined in the following locations: // 1. flutter_tools/lib/src/build_system/targets/localizations.dart // 2. flutter_tools/test/general.shard/build_system/targets/localizations_test.dart // Keep the value consistent in all three locations to ensure behavior is the // same across "flutter gen-l10n" and "flutter run". - final String defaultArbDir = _fileSystem.path.join('lib', 'l10n'); + final String defaultArbDir = fs.path.join('lib', 'l10n'); // Get all options associated with gen-l10n. final LocalizationOptions options; - if (_fileSystem.file('l10n.yaml').existsSync()) { + final File l10nYaml = fs.file('l10n.yaml'); + if (l10nYaml.existsSync()) { options = parseLocalizationsOptionsFromYAML( - file: _fileSystem.file('l10n.yaml'), - logger: _logger, - fileSystem: _fileSystem, + file: l10nYaml, + logger: logger, + fileSystem: fs, defaultArbDir: defaultArbDir, ); - _logger.printStatus( + logger.printStatus( 'Because l10n.yaml exists, the options defined there will be used ' 'instead.\n' 'To use the command line arguments, delete the l10n.yaml file in the ' 'Flutter project.\n\n', ); } else { - options = parseLocalizationsOptionsFromCommand(command: this, defaultArbDir: defaultArbDir); + options = parseLocalizationsOptionsFromCommand( + command: this, + defaultArbDir: defaultArbDir, + logger: logger, + ); } // Run the localizations generator. await generateLocalizations( - logger: _logger, + logger: logger, options: options, - projectDir: _fileSystem.currentDirectory, - fileSystem: _fileSystem, - artifacts: _artifacts, - processManager: _processManager, + projectDir: fs.currentDirectory, + fileSystem: fs, + artifacts: artifacts, + processManager: processManager, ); return FlutterCommandResult.success(); diff --git a/packages/flutter_tools/lib/src/commands/ide_config.dart b/packages/flutter_tools/lib/src/commands/ide_config.dart index cde6f431..823e3504 100644 --- a/packages/flutter_tools/lib/src/commands/ide_config.dart +++ b/packages/flutter_tools/lib/src/commands/ide_config.dart @@ -4,13 +4,19 @@ import '../base/common.dart'; import '../base/file_system.dart'; +import '../base/logger.dart'; +import '../base/template.dart'; import '../cache.dart'; -import '../globals.dart' as globals; +import '../context/tool_context.dart'; +import '../isolated/mustache_template.dart'; import '../runner/flutter_command.dart'; import '../template.dart'; class IdeConfigCommand extends FlutterCommand { - IdeConfigCommand() { + IdeConfigCommand({ + required super.toolContext, + this._templateRenderer = const MustacheTemplateRenderer(), + }) { argParser.addFlag('overwrite', help: 'When performing operations, overwrite existing files.'); argParser.addFlag( 'update-templates', @@ -33,6 +39,11 @@ class IdeConfigCommand extends FlutterCommand { ); } + final TemplateRenderer _templateRenderer; + + @override + ToolContext get toolContext => super.toolContext!; + @override final name = 'ide-config'; @@ -59,8 +70,10 @@ class IdeConfigCommand extends FlutterCommand { static const _ideName = 'intellij'; Directory get _templateDirectory { - return globals.fs.directory( - globals.fs.path.join( + final FileSystem fs = toolContext.fs; + return fs.directory( + fs.path.join( + // TODO(bkonyi): Use instance getter once Cache is refactored (PR #190795). Cache.flutterRoot!, 'packages', 'flutter_tools', @@ -71,21 +84,25 @@ class IdeConfigCommand extends FlutterCommand { } Directory get _createTemplatesDirectory { - return globals.fs.directory( - globals.fs.path.join(Cache.flutterRoot!, 'packages', 'flutter_tools', 'templates'), - ); + final FileSystem fs = toolContext.fs; + // TODO(bkonyi): Use instance getter once Cache is refactored (PR #190795). + return fs.directory(fs.path.join(Cache.flutterRoot!, 'packages', 'flutter_tools', 'templates')); } - Directory get _flutterRoot => globals.fs.directory(globals.fs.path.absolute(Cache.flutterRoot!)); + Directory get _flutterRoot { + final FileSystem fs = toolContext.fs; + // TODO(bkonyi): Use instance getter once Cache is refactored (PR #190795). + return fs.directory(fs.path.absolute(Cache.flutterRoot!)); + } // Returns true if any entire path element is equal to dir. bool _hasDirectoryInPath(FileSystemEntity entity, String dir) { - String path = entity.absolute.path; - while (path.isNotEmpty && globals.fs.path.dirname(path) != path) { - if (globals.fs.path.basename(path) == dir) { + String pathStr = entity.absolute.path; + while (pathStr.isNotEmpty && toolContext.fs.path.dirname(pathStr) != pathStr) { + if (toolContext.fs.path.basename(pathStr) == dir) { return true; } - path = globals.fs.path.dirname(path); + pathStr = toolContext.fs.path.dirname(pathStr); } return false; } @@ -114,6 +131,9 @@ class IdeConfigCommand extends FlutterCommand { // Discovers and syncs with existing configuration files in the Flutter tree. void _handleTemplateUpdate() { + final FileSystem fs = toolContext.fs; + final Logger logger = toolContext.logger; + if (!_flutterRoot.existsSync()) { return; } @@ -121,10 +141,7 @@ class IdeConfigCommand extends FlutterCommand { final manifest = {}; final Iterable flutterFiles = _flutterRoot.listSync(recursive: true).whereType(); for (final srcFile in flutterFiles) { - final String relativePath = globals.fs.path.relative( - srcFile.path, - from: _flutterRoot.absolute.path, - ); + final String relativePath = fs.path.relative(srcFile.path, from: _flutterRoot.absolute.path); // Skip template files in both the ide_templates and templates // directories to avoid copying onto themselves. @@ -144,36 +161,36 @@ class IdeConfigCommand extends FlutterCommand { continue; } - final File finalDestinationFile = globals.fs.file( - globals.fs.path.absolute( + final File finalDestinationFile = fs.file( + fs.path.join( _templateDirectory.absolute.path, '$relativePath${Template.copyTemplateExtension}', ), ); - final String relativeDestination = globals.fs.path.relative( + final String relativeDestination = fs.path.relative( finalDestinationFile.path, from: _flutterRoot.absolute.path, ); if (finalDestinationFile.existsSync()) { if (_fileIsIdentical(srcFile, finalDestinationFile)) { - globals.printTrace(' $relativeDestination (identical)'); + logger.printTrace(' $relativeDestination (identical)'); manifest.add('$relativePath${Template.copyTemplateExtension}'); continue; } if (boolArg('overwrite')) { finalDestinationFile.deleteSync(); - globals.printStatus(' $relativeDestination (overwritten)'); + logger.printStatus(' $relativeDestination (overwritten)'); } else { - globals.printTrace(' $relativeDestination (existing - skipped)'); + logger.printTrace(' $relativeDestination (existing - skipped)'); manifest.add('$relativePath${Template.copyTemplateExtension}'); continue; } } else { - globals.printStatus(' $relativeDestination (added)'); + logger.printStatus(' $relativeDestination (added)'); } - final Directory finalDestinationDir = globals.fs.directory(finalDestinationFile.dirname); + final Directory finalDestinationDir = fs.directory(finalDestinationFile.dirname); if (!finalDestinationDir.existsSync()) { - globals.printTrace(" ${finalDestinationDir.path} doesn't exist, creating."); + logger.printTrace(" ${finalDestinationDir.path} doesn't exist, creating."); finalDestinationDir.createSync(recursive: true); } srcFile.copySync(finalDestinationFile.path); @@ -191,28 +208,28 @@ class IdeConfigCommand extends FlutterCommand { .listSync(recursive: true) .whereType(); for (final templateFile in templateFiles) { - final String relativePath = globals.fs.path.relative( + final String relativePath = fs.path.relative( templateFile.absolute.path, from: _templateDirectory.absolute.path, ); if (!manifest.contains(relativePath)) { templateFile.deleteSync(); - final String relativeDestination = globals.fs.path.relative( + final String relativeDestination = fs.path.relative( templateFile.path, from: _flutterRoot.absolute.path, ); - globals.printStatus(' $relativeDestination (removed)'); + logger.printStatus(' $relativeDestination (removed)'); } // If the directory is now empty, then remove it, and do the same for its parent, // until we escape to the template directory. - Directory parentDir = globals.fs.directory(templateFile.dirname); + Directory parentDir = fs.directory(templateFile.dirname); while (parentDir.listSync().isEmpty) { parentDir.deleteSync(); - globals.printTrace( - ' ${globals.fs.path.relative(parentDir.absolute.path)} (empty directory - removed)', + logger.printTrace( + ' ${fs.path.relative(parentDir.absolute.path)} (empty directory - removed)', ); - parentDir = globals.fs.directory(parentDir.dirname); - if (globals.fs.path.isWithin(_templateDirectory.absolute.path, parentDir.absolute.path)) { + parentDir = fs.directory(parentDir.dirname); + if (!fs.path.isWithin(_templateDirectory.absolute.path, parentDir.absolute.path)) { break; } } @@ -221,6 +238,9 @@ class IdeConfigCommand extends FlutterCommand { @override Future runCommand() async { + final FileSystem fs = toolContext.fs; + final Logger logger = toolContext.logger; + final List rest = argResults?.rest ?? []; if (rest.isNotEmpty) { throwToolExit('Currently, the only supported IDE is IntelliJ\n$usage', exitCode: 2); @@ -231,26 +251,25 @@ class IdeConfigCommand extends FlutterCommand { return FlutterCommandResult.success(); } - final String flutterRoot = globals.fs.path.absolute(Cache.flutterRoot!); - final String dirPath = globals.fs.path.normalize( - globals.fs.directory(globals.fs.path.absolute(Cache.flutterRoot!)).absolute.path, - ); + // TODO(bkonyi): Use instance getter once Cache is refactored (PR #190795). + final String flutterRoot = fs.path.absolute(Cache.flutterRoot!); + final String dirPath = fs.path.normalize(fs.directory(flutterRoot).absolute.path); - final String? error = _validateFlutterDir(dirPath, flutterRoot: flutterRoot); + final String? error = _validateFlutterDir(dirPath, fileSystem: fs, flutterRoot: flutterRoot); if (error != null) { throwToolExit(error); } - globals.printStatus('Updating IDE configuration for Flutter tree at $dirPath...'); + logger.printStatus('Updating IDE configuration for Flutter tree at $dirPath...'); var generatedCount = 0; - generatedCount += _renderTemplate(_ideName, dirPath, { + generatedCount += _renderTemplate(dirPath, { 'withRootModule': boolArg('with-root-module'), 'android': true, }); - globals.printStatus('Wrote $generatedCount files.'); - globals.printStatus(''); - globals.printStatus( + logger.printStatus('Wrote $generatedCount files.'); + logger.printStatus(''); + logger.printStatus( 'Your IntelliJ configuration is now up to date. It is prudent to ' 'restart IntelliJ, if running.', ); @@ -258,26 +277,24 @@ class IdeConfigCommand extends FlutterCommand { return FlutterCommandResult.success(); } - int _renderTemplate(String templateName, String dirPath, Map context) { + int _renderTemplate(String dirPath, Map context) { + final FileSystem fs = toolContext.fs; + final Logger logger = toolContext.logger; final template = Template( _templateDirectory, null, - fileSystem: globals.fs, - logger: globals.logger, - templateRenderer: globals.templateRenderer, - ); - return template.render( - globals.fs.directory(dirPath), - context, - overwriteExisting: boolArg('overwrite'), + fileSystem: fs, + logger: logger, + templateRenderer: _templateRenderer, ); + return template.render(fs.directory(dirPath), context, overwriteExisting: boolArg('overwrite')); } } /// Return null if the flutter root directory is a valid destination. Return a /// validation message if we should disallow the directory. -String? _validateFlutterDir(String dirPath, {String? flutterRoot}) { - final FileSystemEntityType type = globals.fs.typeSync(dirPath); +String? _validateFlutterDir(String dirPath, {required FileSystem fileSystem, String? flutterRoot}) { + final FileSystemEntityType type = fileSystem.typeSync(dirPath); // ignore: exhaustive_cases, https://github.com/dart-lang/linter/issues/3017 switch (type) { diff --git a/packages/flutter_tools/lib/src/commands/install.dart b/packages/flutter_tools/lib/src/commands/install.dart index 9bf1056f..94f87bff 100644 --- a/packages/flutter_tools/lib/src/commands/install.dart +++ b/packages/flutter_tools/lib/src/commands/install.dart @@ -7,12 +7,13 @@ import '../application_package.dart'; import '../base/common.dart'; import '../base/file_system.dart'; import '../base/io.dart'; +import '../base/logger.dart'; +import '../context/tool_context.dart'; import '../device.dart'; -import '../globals.dart' as globals; import '../runner/flutter_command.dart'; class InstallCommand extends FlutterCommand with DeviceBasedDevelopmentArtifacts { - InstallCommand({required bool verboseHelp}) { + InstallCommand({required ToolContext super.toolContext, required super.verboseHelp}) { addBuildModeFlags(verboseHelp: verboseHelp); requiresPubspecYaml(); usesApplicationBinaryOption(); @@ -26,6 +27,11 @@ class InstallCommand extends FlutterCommand with DeviceBasedDevelopmentArtifacts ); } + @override + ToolContext get toolContext => super.toolContext!; + + Logger get _logger => toolContext.logger; + @override final name = 'install'; @@ -45,7 +51,7 @@ class InstallCommand extends FlutterCommand with DeviceBasedDevelopmentArtifacts String? get _applicationBinaryPath => stringArg(FlutterOptions.kUseApplicationBinary); File? get _applicationBinary => - _applicationBinaryPath == null ? null : globals.fs.file(_applicationBinaryPath); + _applicationBinaryPath == null ? null : toolContext.fs.file(_applicationBinaryPath); @override Future validateCommand() async { @@ -84,39 +90,46 @@ class InstallCommand extends FlutterCommand with DeviceBasedDevelopmentArtifacts Future _uninstallApp(ApplicationPackage package, Device device) async { if (await device.isAppInstalled(package, userIdentifier: userIdentifier)) { - globals.printStatus('Uninstalling $package from $device...'); + _logger.printStatus('Uninstalling $package from $device...'); if (!await device.uninstallApp(package, userIdentifier: userIdentifier)) { - globals.printError('Uninstalling old version failed'); + _logger.printError('Uninstalling old version failed'); } } else { - globals.printStatus('$package not found on $device, skipping uninstall'); + _logger.printStatus('$package not found on $device, skipping uninstall'); } } Future _installApp(ApplicationPackage package, Device device) async { - globals.printStatus('Installing $package to $device...'); + _logger.printStatus('Installing $package to $device...'); - if (!await installApp(device, package, userIdentifier: userIdentifier)) { + if (!await installApp(device, package, logger: _logger, userIdentifier: userIdentifier)) { throwToolExit('Install failed'); } } } +/// Installs [package] to [device]. +/// +/// When [uninstall] is true and the package is already present on the device, +/// it uninstalls the existing version before installing. +/// +/// Returns true if installation succeeded, or false otherwise. Future installApp( Device device, ApplicationPackage package, { - String? userIdentifier, + required Logger logger, bool uninstall = true, + String? userIdentifier, }) async { try { if (uninstall && await device.isAppInstalled(package, userIdentifier: userIdentifier)) { - globals.printStatus('Uninstalling old version...'); + logger.printStatus('Uninstalling old version...'); if (!await device.uninstallApp(package, userIdentifier: userIdentifier)) { - globals.printWarning('Warning: uninstalling old version failed'); + logger.printWarning('Warning: uninstalling old version failed'); } } } on ProcessException catch (e) { - globals.printError('Error accessing device ${device.id}:\n${e.message}'); + logger.printError('Error accessing device ${device.id}:\n${e.message}'); } return device.installApp(package, userIdentifier: userIdentifier); diff --git a/packages/flutter_tools/lib/src/commands/logs.dart b/packages/flutter_tools/lib/src/commands/logs.dart index 34932d0b..f9a21c33 100644 --- a/packages/flutter_tools/lib/src/commands/logs.dart +++ b/packages/flutter_tools/lib/src/commands/logs.dart @@ -4,15 +4,25 @@ import 'dart:async'; +import '../android/android_device.dart'; import '../application_package.dart'; import '../base/common.dart'; import '../base/io.dart'; +import '../base/logger.dart'; +import '../context/tool_context.dart'; import '../device.dart'; -import '../globals.dart' as globals; import '../runner/flutter_command.dart'; +/// Show log output for running Flutter apps. class LogsCommand extends FlutterCommand { - LogsCommand({required this.sigint, required this.sigterm}) { + LogsCommand({ + required ToolContext super.toolContext, + ApplicationPackageFactory? applicationPackageFactory, + ProcessSignal? sigint, + ProcessSignal? sigterm, + }) : _sigint = sigint ?? ProcessSignal.sigint, + _sigterm = sigterm ?? ProcessSignal.sigterm { + applicationPackages = applicationPackageFactory; argParser.addFlag( 'clear', negatable: false, @@ -21,8 +31,15 @@ class LogsCommand extends FlutterCommand { ); usesDeviceTimeoutOption(); usesDeviceConnectionOption(); + usesAdbLogFilteringOption(hide: false); } + final ProcessSignal _sigint; + final ProcessSignal _sigterm; + + @override + ToolContext get toolContext => super.toolContext!; + @override final name = 'logs'; @@ -39,8 +56,6 @@ class LogsCommand extends FlutterCommand { Future> get requiredArtifacts async => const {}; Device? device; - final ProcessSignal sigint; - final ProcessSignal sigterm; @override Future verifyThenRunCommand(String? commandPath) async { @@ -53,6 +68,7 @@ class LogsCommand extends FlutterCommand { @override Future runCommand() async { + final Logger logger = toolContext.logger; final Device cachedDevice = device!; if (boolArg('clear')) { cachedDevice.clearLogs(); @@ -62,9 +78,16 @@ class LogsCommand extends FlutterCommand { await cachedDevice.targetPlatform, ); - final DeviceLogReader logReader = await cachedDevice.getLogReader(app: app); + final bool filtering = + argParser.options.containsKey('adb-log-filtering') && boolArg('adb-log-filtering'); + final DeviceLogReader logReader; + if (cachedDevice is AndroidDevice) { + logReader = await cachedDevice.getLogReader(app: app, adbLogFiltering: filtering); + } else { + logReader = await cachedDevice.getLogReader(app: app); + } - globals.printStatus('Showing $logReader logs:'); + logger.printStatus('Showing $logReader logs:'); final exitCompleter = Completer(); @@ -79,18 +102,18 @@ class LogsCommand extends FlutterCommand { // Start reading. final StreamSubscription subscription = logReader.logLines.listen( - (String message) => globals.printStatus(message, wrap: false), + (String message) => logger.printStatus(message, wrap: false), onDone: () => maybeComplete(), - onError: (dynamic error) => maybeComplete(error is int ? error : 1), + onError: (Object error) => maybeComplete(error is int ? error : 1), ); // When terminating, close down the log reader. - sigint.watch().listen((ProcessSignal signal) { + _sigint.watch().listen((ProcessSignal signal) { subscription.cancel(); maybeComplete(); - globals.printStatus(''); + logger.printStatus(''); }); - sigterm.watch().listen((ProcessSignal signal) { + _sigterm.watch().listen((ProcessSignal signal) { subscription.cancel(); maybeComplete(); }); diff --git a/packages/flutter_tools/lib/src/commands/packages.dart b/packages/flutter_tools/lib/src/commands/packages.dart index a34d124f..3b44d7f2 100644 --- a/packages/flutter_tools/lib/src/commands/packages.dart +++ b/packages/flutter_tools/lib/src/commands/packages.dart @@ -10,48 +10,71 @@ import 'package:pool/pool.dart'; import 'package:unified_analytics/unified_analytics.dart'; import '../base/common.dart'; +import '../base/file_system.dart'; +import '../base/logger.dart'; import '../base/os.dart'; import '../base/utils.dart'; import '../build_info.dart'; import '../build_system/build_system.dart'; import '../build_system/targets/localizations.dart'; import '../cache.dart'; +import '../context/tool_context.dart'; import '../dart/package_map.dart'; import '../dart/pub.dart'; import '../flutter_plugins.dart'; -import '../globals.dart' as globals; import '../package_graph.dart'; import '../plugins.dart'; import '../project.dart'; import '../runner/flutter_command.dart'; class PackagesCommand extends FlutterCommand { - PackagesCommand() { + PackagesCommand({ + required BuildSystem buildSystem, + required ToolContext toolContext, + super.verboseHelp, + }) : super(toolContext: toolContext) { addSubcommand( - PackagesGetCommand('get', "Get the current package's dependencies.", PubContext.pubGet), + PackagesGetCommand( + 'get', + "Get the current package's dependencies.", + PubContext.pubGet, + buildSystem: buildSystem, + toolContext: toolContext, + ), ); addSubcommand( PackagesGetCommand( 'upgrade', "Upgrade the current package's dependencies to latest versions.", PubContext.pubUpgrade, + buildSystem: buildSystem, + toolContext: toolContext, ), ); addSubcommand( - PackagesGetCommand('add', 'Add a dependency to pubspec.yaml.', PubContext.pubAdd), + PackagesGetCommand( + 'add', + 'Add a dependency to pubspec.yaml.', + PubContext.pubAdd, + buildSystem: buildSystem, + toolContext: toolContext, + ), ); addSubcommand( PackagesGetCommand( 'remove', 'Removes a dependency from the current package.', PubContext.pubRemove, + buildSystem: buildSystem, + toolContext: toolContext, ), ); - addSubcommand(PackagesTestCommand()); + addSubcommand(PackagesTestCommand(toolContext: toolContext)); addSubcommand( PackagesForwardCommand( 'publish', 'Publish the current package to pub.dartlang.org.', + toolContext: toolContext, requiresPubspec: true, ), ); @@ -59,32 +82,57 @@ class PackagesCommand extends FlutterCommand { PackagesForwardCommand( 'downgrade', 'Downgrade packages in a Flutter project.', + toolContext: toolContext, requiresPubspec: true, ), ); addSubcommand( - PackagesForwardCommand('deps', 'Print package dependencies.'), + PackagesForwardCommand('deps', 'Print package dependencies.', toolContext: toolContext), ); // path to package can be specified with --directory argument addSubcommand( - PackagesForwardCommand('run', 'Run an executable from a package.', requiresPubspec: true), + PackagesForwardCommand( + 'run', + 'Run an executable from a package.', + toolContext: toolContext, + requiresPubspec: true, + ), + ); + addSubcommand( + PackagesForwardCommand('cache', 'Work with the Pub system cache.', toolContext: toolContext), + ); + addSubcommand( + PackagesForwardCommand('version', 'Print Pub version.', toolContext: toolContext), + ); + addSubcommand( + PackagesForwardCommand( + 'uploader', + 'Manage uploaders for a package on pub.dev.', + toolContext: toolContext, + ), + ); + addSubcommand(PackagesForwardCommand('login', 'Log into pub.dev.', toolContext: toolContext)); + addSubcommand( + PackagesForwardCommand('logout', 'Log out of pub.dev.', toolContext: toolContext), + ); + addSubcommand( + PackagesForwardCommand('global', 'Work with Pub global packages.', toolContext: toolContext), ); - addSubcommand(PackagesForwardCommand('cache', 'Work with the Pub system cache.')); - addSubcommand(PackagesForwardCommand('version', 'Print Pub version.')); - addSubcommand(PackagesForwardCommand('uploader', 'Manage uploaders for a package on pub.dev.')); - addSubcommand(PackagesForwardCommand('login', 'Log into pub.dev.')); - addSubcommand(PackagesForwardCommand('logout', 'Log out of pub.dev.')); - addSubcommand(PackagesForwardCommand('global', 'Work with Pub global packages.')); addSubcommand( PackagesForwardCommand( 'outdated', 'Analyze dependencies to find which ones can be upgraded.', + toolContext: toolContext, requiresPubspec: true, ), ); addSubcommand( - PackagesForwardCommand('token', 'Manage authentication tokens for hosted pub repositories.'), + PackagesForwardCommand( + 'token', + 'Manage authentication tokens for hosted pub repositories.', + toolContext: toolContext, + ), ); - addSubcommand(PackagesPassthroughCommand()); + addSubcommand(PackagesPassthroughCommand(toolContext: toolContext)); } @override @@ -104,10 +152,16 @@ class PackagesCommand extends FlutterCommand { } class PackagesTestCommand extends FlutterCommand { - PackagesTestCommand() { + PackagesTestCommand({required ToolContext toolContext}) + : _toolContext = toolContext, + super(toolContext: toolContext) { requiresPubspecYaml(); } + final ToolContext _toolContext; + + late final Pub _pub = _createPub(_toolContext); + @override String get name => 'test'; @@ -128,18 +182,28 @@ class PackagesTestCommand extends FlutterCommand { @override Future runCommand() async { - await pub.batch(['run', 'test', ...argResults!.rest], context: PubContext.runTest); + await _pub.batch(['run', 'test', ...argResults!.rest], context: PubContext.runTest); return FlutterCommandResult.success(); } } class PackagesForwardCommand extends FlutterCommand { - PackagesForwardCommand(this._commandName, this._description, {bool requiresPubspec = false}) { + PackagesForwardCommand( + this._commandName, + this._description, { + required ToolContext toolContext, + bool requiresPubspec = false, + }) : _toolContext = toolContext, + super(toolContext: toolContext) { if (requiresPubspec) { requiresPubspecYaml(); } } + final ToolContext _toolContext; + + late final Pub _pub = _createPub(_toolContext); + PubContext context = PubContext.pubForward; @override @@ -166,27 +230,34 @@ class PackagesForwardCommand extends FlutterCommand { Future runCommand() async { final List subArgs = argResults!.rest.toList() ..removeWhere((String arg) => arg == '--'); - await pub.interactively( + await _pub.interactively( [_commandName, ...subArgs], - context: context, command: _commandName, + context: context, ); return FlutterCommandResult.success(); } } class PackagesPassthroughCommand extends FlutterCommand { + PackagesPassthroughCommand({required ToolContext toolContext}) + : _toolContext = toolContext, + super(toolContext: toolContext); + + final ToolContext _toolContext; + @override ArgParser argParser = ArgParser.allowAnything(); + late final Pub _pub = _createPub(_toolContext); + @override String get name => 'pub'; @override - String get description { - return 'Pass the remaining arguments to Dart\'s "pub" tool.\n' - 'This runs the "pub" tool in a Flutter context.'; - } + final String description = + 'Pass the remaining arguments to Dart\'s "pub" tool.\n' + 'This runs the "pub" tool in a Flutter context.'; @override String get invocation { @@ -197,14 +268,24 @@ class PackagesPassthroughCommand extends FlutterCommand { @override Future runCommand() async { - await pub.interactively(command: 'pub', argResults!.rest, context: _context); + await _pub.interactively(argResults!.rest, command: 'pub', context: _context); return FlutterCommandResult.success(); } } /// Represents the pub sub-commands that makes package-resolutions. class PackagesGetCommand extends FlutterCommand { - PackagesGetCommand(this._commandName, this._description, this._context); + PackagesGetCommand( + this._commandName, + this._description, + this._context, { + required this._buildSystem, + required ToolContext toolContext, + }) : _toolContext = toolContext, + super(toolContext: toolContext); + + final ToolContext _toolContext; + final BuildSystem _buildSystem; @override ArgParser argParser = ArgParser.allowAnything(); @@ -229,6 +310,8 @@ class PackagesGetCommand extends FlutterCommand { return '${runner!.executableName} pub $_commandName []'; } + late final Pub _pub = _createPub(_toolContext); + /// An [ArgParser] that accepts all options and flags that the /// /// `pub get` @@ -277,11 +360,14 @@ class PackagesGetCommand extends FlutterCommand { } on ArgParserException { // Let pub give the error message. } + final ToolContext(:FileSystem fs, :Logger logger, :FlutterProjectFactory projectFactory) = + _toolContext; + String? target; FlutterProject? rootProject; if (!isHelp) { - target = findProjectRoot(globals.fs, directoryOption); + target = findProjectRoot(fs, directoryOption); if (target == null) { if (directoryOption == null) { throwToolExit('Expected to find project root in current working directory.'); @@ -290,15 +376,15 @@ class PackagesGetCommand extends FlutterCommand { } } - rootProject = FlutterProject.fromDirectory(globals.fs.directory(target)); + rootProject = projectFactory.fromDirectory(fs.directory(target)); _rootProject = rootProject; } - final String? relativeTarget = target == null ? null : globals.fs.path.relative(target); + final String? relativeTarget = target == null ? null : fs.path.relative(target); final List subArgs = rest.toList()..removeWhere((String arg) => arg == '--'); final timer = Stopwatch()..start(); try { - await pub.interactively( + await _pub.interactively( [ name, ...subArgs, @@ -342,14 +428,14 @@ class PackagesGetCommand extends FlutterCommand { // tooling if needed. final PackageConfig packageConfig = await loadPackageConfigWithLogging( rootProject.packageConfig, - logger: globals.logger, + logger: logger, ); final PackageGraph graph = PackageGraph.load(rootProject); // Build a cache of all pubspec.yaml contents once, keyed by package root // URI. This avoids re-reading the same files for every workspace package // during post-processing. - final PubspecCache pubspecCache = await buildPubspecCache(packageConfig); + final PubspecCache pubspecCache = await buildPubspecCache(packageConfig, fileSystem: fs); // Process workspace root packages concurrently, capped to 64 to // saturate I/O without exhausting file descriptors or system resources. await Pool(64).forEach(graph.roots, (String workspaceRootName) async { @@ -357,26 +443,26 @@ class PackagesGetCommand extends FlutterCommand { assert(rootPackage != null); final Uri rootUri = rootPackage!.root; - final FlutterProject project = FlutterProject.fromDirectory(globals.fs.directory(rootUri)); + final FlutterProject project = projectFactory.fromDirectory(fs.directory(rootUri)); if (project.manifest.generateLocalizations) { final environment = Environment( - artifacts: globals.artifacts!, - logger: globals.logger, - cacheDir: globals.cache.getRoot(), - engineVersion: globals.flutterVersion.engineRevision, - fileSystem: globals.fs, - flutterRootDir: globals.fs.directory(Cache.flutterRoot), - outputDir: globals.fs.directory(getBuildDirectory()), - processManager: globals.processManager, - platform: globals.platform, + artifacts: _toolContext.artifacts, + logger: logger, + cacheDir: _toolContext.cache.getRoot(), + engineVersion: _toolContext.flutterVersion.engineRevision, + fileSystem: fs, + flutterRootDir: fs.directory(Cache.flutterRoot), + outputDir: fs.directory(getBuildDirectory(_toolContext.config, fs)), + processManager: _toolContext.processManager, + platform: _toolContext.platform, analytics: analytics, projectDir: project.directory, packageConfigPath: packageConfigPath(), generateDartPluginRegistry: true, ); // If localizations were enabled, but we are not using synthetic packages. - final BuildResult result = await globals.buildSystem.build( + final BuildResult result = await _buildSystem.build( const GenerateLocalizationsTarget(), environment, ); @@ -460,7 +546,7 @@ class PackagesGetCommand extends FlutterCommand { return []; } - return findPlugins(rootProject, throwOnError: false); + return findPlugins(rootProject, logger: _toolContext.logger, throwOnError: false); })(); late final String? _androidEmbeddingVersion = _rootProject?.android @@ -501,3 +587,12 @@ class PackagesGetCommand extends FlutterCommand { ); } } + +Pub _createPub(ToolContext toolContext) => Pub( + botDetector: toolContext.botDetector, + fileSystem: toolContext.fs, + logger: toolContext.logger, + platform: toolContext.platform, + processManager: toolContext.processManager, + stdio: toolContext.stdio, +); diff --git a/packages/flutter_tools/lib/src/commands/precache.dart b/packages/flutter_tools/lib/src/commands/precache.dart index 5f0c727b..85a8f8f3 100644 --- a/packages/flutter_tools/lib/src/commands/precache.dart +++ b/packages/flutter_tools/lib/src/commands/precache.dart @@ -15,14 +15,11 @@ import '../runner/flutter_command.dart'; class PrecacheCommand extends FlutterCommand { PrecacheCommand({ bool verboseHelp = false, - required Cache cache, - required Platform platform, - required Logger logger, - required FeatureFlags featureFlags, - }) : _cache = cache, - _platform = platform, - _logger = logger, - _featureFlags = featureFlags { + required this._cache, + required this._platform, + required this._logger, + required this._featureFlags, + }) { argParser.addFlag( 'all-platforms', abbr: 'a', diff --git a/packages/flutter_tools/lib/src/commands/run.dart b/packages/flutter_tools/lib/src/commands/run.dart index 2063b544..8ad1b20d 100644 --- a/packages/flutter_tools/lib/src/commands/run.dart +++ b/packages/flutter_tools/lib/src/commands/run.dart @@ -9,13 +9,23 @@ import 'package:unified_analytics/unified_analytics.dart' as analytics; import 'package:vm_service/vm_service.dart'; import '../android/android_device.dart'; +import '../android/android_engine_cli_flags.dart'; +import '../android/android_workflow.dart'; import '../base/common.dart'; import '../base/file_system.dart'; import '../base/io.dart'; +import '../base/logger.dart'; +import '../base/signals.dart'; +import '../base/terminal.dart'; +import '../base/time.dart'; import '../build_info.dart'; +import '../build_system/build_system.dart'; +import '../build_system/build_targets.dart'; +import '../context/android_context.dart'; +import '../context/apple_context.dart'; +import '../context/tool_context.dart'; import '../device.dart'; import '../features.dart'; -import '../globals.dart' as globals; import '../hook_runner.dart' show hookRunner; import '../ios/devices.dart'; import '../project.dart'; @@ -23,260 +33,100 @@ import '../resident_runner.dart'; import '../run_cold.dart'; import '../run_hot.dart'; import '../runner/flutter_command.dart'; -import '../runner/flutter_command_runner.dart'; import '../tracing.dart'; import '../web/compile.dart'; import '../web/devfs_config.dart'; -import '../web/web_constants.dart'; +import '../web/web_options.dart'; import '../web/web_runner.dart'; import 'daemon.dart'; /// Shared logic between `flutter run` and `flutter drive` commands. abstract class RunCommandBase extends FlutterCommand with DeviceBasedDevelopmentArtifacts { - RunCommandBase({required bool verboseHelp}) { - addBuildModeFlags(verboseHelp: verboseHelp, defaultToRelease: false); - usesDartDefineOption(); - usesWebDefineOption(); - usesFlavorOption(); - usesWebResourcesCdnFlag(); - addNativeNullAssertions(hide: !verboseHelp); - usesApplicationBinaryOption(); - argParser - ..addFlag( - 'trace-startup', - negatable: false, - help: - 'Trace application startup, then exit, saving the trace to a file. ' - 'By default, this will be saved in the "build" directory. If the ' - 'FLUTTER_TEST_OUTPUTS_DIR environment variable is set, the file ' - 'will be written there instead.', - ) - ..addFlag( - 'cache-startup-profile', - help: - 'Caches the CPU profile collected before the first frame for startup ' - 'analysis.', - ) - ..addFlag( - 'verbose-system-logs', - negatable: false, - help: 'Include verbose logging from the Flutter engine.', - ) - ..addFlag( - 'purge-persistent-cache', - negatable: false, - help: - 'Removes all existing persistent caches. This allows reproducing ' - 'shader compilation jank that normally only happens the first time ' - 'an app is run, or for reliable testing of compilation jank fixes ' - '(e.g. shader warm-up).', - ) - ..addOption('route', help: 'Which route to load when running the app.') - ..addOption( - 'vmservice-out-file', - help: - 'A file to write the attached vmservice URL to after an ' - 'application is started.', - valueHelp: 'project/example/out.txt', - hide: !verboseHelp, - ) - ..addFlag( - 'disable-service-auth-codes', - negatable: false, - hide: !verboseHelp, - help: - '(deprecated) Allow connections to the VM service without using authentication codes. ' - '(Not recommended! This can open your device to remote code execution attacks!)', - ) - ..addFlag( - 'disable-service-origin-check', - negatable: false, - hide: !verboseHelp, - help: - 'Allow connections to the VM service from any origin. ' - '(Not recommended. This can open your device to remote code execution attacks.)', - ) - ..addFlag( - 'start-paused', - defaultsTo: startPausedDefault, - help: 'Start in a paused mode and wait for a debugger to connect.', - ) - ..addOption( - 'dart-flags', - hide: !verboseHelp, - help: - 'Pass a list of comma separated flags to the Dart instance at ' - 'application startup. Flags passed through this option must be ' - 'present on the allowlist defined within the Flutter engine. If ' - 'a disallowed flag is encountered, the process will be ' - 'terminated immediately.\n\n' - 'This flag is not available on the stable channel and is only ' - 'applied in debug and profile modes. This option should only ' - 'be used for experiments and should not be used by typical users.', - ) - ..addFlag( - 'endless-trace-buffer', - negatable: false, - help: - 'Enable tracing to an infinite buffer, instead of a ring buffer. ' - 'This is useful when recording large traces. To use an endless buffer to ' - 'record startup traces, combine this with "--trace-startup".', - ) - ..addFlag( - 'trace-systrace', - negatable: false, - help: - 'Enable tracing to the system tracer. This is only useful on ' - 'platforms where such a tracer is available (Android, iOS, ' - 'macOS and Fuchsia).', - ) - ..addOption( - 'trace-to-file', - help: - 'Write the timeline trace to a file at the specified path. The ' - "file will be in Perfetto's proto format; it will be possible to " - "load the file into Perfetto's trace viewer.", - valueHelp: 'path/to/trace.binpb', - ) - ..addFlag( - 'profile-microtasks', - negatable: false, - help: - 'Enable collection of information about each microtask. ' - 'Information about completed microtasks will be written to the ' - '"Microtask" timeline stream. Information about queued microtasks ' - 'will be accessible from Dart / Flutter DevTools.', - hide: !verboseHelp, - ) - ..addFlag( - 'trace-skia', - negatable: false, - help: - 'Enable tracing of Skia code. This is useful when debugging ' - 'the raster thread (formerly known as the GPU thread). ' - 'By default, Flutter will not log Skia code, as it introduces significant ' - 'overhead that may affect recorded performance metrics in a misleading way.', - ) - ..addOption( - 'trace-allowlist', - hide: !verboseHelp, - help: - 'Filters out all trace events except those that are specified in ' - 'this comma separated list of allowed prefixes.', - valueHelp: 'foo,bar', - ) - ..addOption( - 'trace-skia-allowlist', - hide: !verboseHelp, - help: - 'Filters out all Skia trace events except those that are specified in ' - 'this comma separated list of allowed prefixes.', - valueHelp: 'skia.gpu,skia.shaders', - ) - ..addFlag( - 'enable-dart-profiling', - defaultsTo: true, - help: - 'Whether the Dart VM sampling CPU profiler is enabled. This flag ' - 'is only meaningful in debug and profile builds.', - ) - ..addFlag( - 'profile-startup', - negatable: false, - help: - 'Make the profiler discard new samples once the profiler sample ' - 'buffer is full. When this flag is not set, the profiler sample ' - 'buffer is used as a ring buffer, meaning that once it is full, ' - 'new samples start overwriting the oldest ones.', - hide: !verboseHelp, - ) - ..addFlag( - 'enable-software-rendering', - negatable: false, - help: - '(deprecated) Enable rendering using the Skia software backend. ' - 'This is useful when testing Flutter on emulators. By default, ' - 'Flutter will attempt to either use OpenGL or Vulkan and fall back ' - 'to software when neither is available. This option is not supported ' - 'when using the Impeller rendering engine.', - hide: !verboseHelp, - ) - ..addFlag( - 'skia-deterministic-rendering', - negatable: false, - help: - '(deprecated) When combined with "--enable-software-rendering", this should provide completely ' - 'deterministic (i.e. reproducible) Skia rendering. This is useful for testing purposes ' - '(e.g. when comparing screenshots). This option is not supported ' - 'when using the Impeller rendering engine.', - hide: !verboseHelp, - ) - ..addMultiOption( - 'dart-entrypoint-args', - abbr: 'a', - help: - 'Pass a list of arguments to the Dart entrypoint at application ' - 'startup. By default this is main(List args). Specify ' - 'this option multiple times each with one argument to pass ' - 'multiple arguments to the Dart entrypoint. Currently this is ' - 'only supported on desktop platforms.', - ) - ..addFlag( - 'uninstall-first', - hide: !verboseHelp, - help: - 'Uninstall previous versions of the app on the device ' - 'before reinstalling. Currently only supported on iOS.', - ) - ..addFlag( - FlutterOptions.kWebWasmFlag, - help: 'Compile to WebAssembly rather than JavaScript.\n$kWasmMoreInfo', - negatable: false, - ) - ..addFlag( - 'ios-profile-debugger', - negatable: false, - help: - 'Whether to attach the LLDB debugger when running in profile mode on a physical iOS device. Only available with Xcode 26.', - ); + RunCommandBase({ + required this.buildSystem, + required this.buildTargets, + required ToolContext super.toolContext, + required super.verboseHelp, + super.outputPreferences, + }) { + enableUsesTargetOption(); + enableUsesPubOption(); + enableUsesFatalWarnings(); + registerOptionBundles(const [ + BuildModeOptionsBundle(defaultToRelease: false), + VmServicePortOptionsBundle(), + DeviceDiscoveryOptionsBundle(includeDeviceUser: true), + DdsAndDevToolsOptionsBundle(), + AndroidGradleOptionsBundle(), + EngineRenderingFlagsBundle(includeTestFlag: true), + ]); usesWebOptions(verboseHelp: verboseHelp); - usesTargetOption(); - usesPortOptions(verboseHelp: verboseHelp); - usesIpv6Flag(verboseHelp: verboseHelp); - usesPubOption(); - usesTrackWidgetCreation(verboseHelp: verboseHelp); - usesDeviceUserOption(); - usesDeviceTimeoutOption(); - usesDeviceConnectionOption(); - addDdsOptions(verboseHelp: verboseHelp); - addDevToolsOptions(verboseHelp: verboseHelp); - addAndroidSpecificBuildOptions(hide: !verboseHelp); - usesFatalWarningsOption(verboseHelp: verboseHelp); - addEnableImpellerFlag(verboseHelp: verboseHelp); - addEnableFlutterGpuFlag(verboseHelp: verboseHelp); - addEnableVulkanValidationFlag(verboseHelp: verboseHelp); - addEnableEmbedderApiFlag(verboseHelp: verboseHelp); - addEnableHcppFlag(verboseHelp: verboseHelp); - addTestFlag(verboseHelp: verboseHelp); + CommonOptions.nativeNullAssertions.addTo(argParser, hideOverride: !verboseHelp); + argParser.addDescriptors(>[ + CommonOptions.dartDefines, + CommonOptions.dartDefineFromFile, + WebOptions.webDefines, + BuildInfoOptions.flavor, + WebOptions.webResourcesCdn, + CommonOptions.useApplicationBinary, + WebOptions.deprecatedJsInterop, + CommonOptions.target, + CommonOptions.pub, + BuildInfoOptions.trackWidgetCreation, + CommonOptions.fatalWarnings, + DebuggingOptionDescriptors.adbLogFiltering, + DebuggingOptionDescriptors.traceStartup, + DebuggingOptionDescriptors.cacheStartupProfile, + DebuggingOptionDescriptors.verboseSystemLogs, + DebuggingOptionDescriptors.purgePersistentCache, + DebuggingOptionDescriptors.route, + DebuggingOptionDescriptors.vmserviceOutFile, + DebuggingOptionDescriptors.disableServiceAuthCodes, + DebuggingOptionDescriptors.disableServiceOriginCheck, + DebuggingOptionDescriptors.startPaused(defaultsTo: startPausedDefault), + DebuggingOptionDescriptors.dartFlags, + DebuggingOptionDescriptors.endlessTraceBuffer, + DebuggingOptionDescriptors.traceSystrace, + DebuggingOptionDescriptors.traceToFile, + DebuggingOptionDescriptors.profileMicrotasks, + DebuggingOptionDescriptors.traceSkia, + DebuggingOptionDescriptors.traceAllowlist, + DebuggingOptionDescriptors.traceSkiaAllowlist, + DebuggingOptionDescriptors.enableDartProfiling, + DebuggingOptionDescriptors.profileStartup, + DebuggingOptionDescriptors.enableSoftwareRendering, + DebuggingOptionDescriptors.skiaDeterministicRendering, + DebuggingOptionDescriptors.dartEntrypointArgs, + DebuggingOptionDescriptors.uninstallFirst, + WebOptions.wasm, + DebuggingOptionDescriptors.iosProfileDebugger, + ], verboseHelp: verboseHelp); } - bool get traceStartup => boolArg('trace-startup'); - bool get traceSystrace => boolArg('trace-systrace'); - bool get enableDartProfiling => boolArg('enable-dart-profiling'); - bool get purgePersistentCache => boolArg('purge-persistent-cache'); - bool get disableServiceAuthCodes => boolArg('disable-service-auth-codes'); - bool get disableServiceOriginCheck => boolArg('disable-service-origin-check'); - bool get cacheStartupProfile => boolArg('cache-startup-profile'); + final BuildSystem buildSystem; + final BuildTargets buildTargets; + + @override + ToolContext get toolContext => super.toolContext!; + + bool get traceStartup => getValue(DebuggingOptionDescriptors.traceStartup); + bool get traceSystrace => getValue(DebuggingOptionDescriptors.traceSystrace); + bool get enableDartProfiling => getValue(DebuggingOptionDescriptors.enableDartProfiling); + bool get purgePersistentCache => getValue(DebuggingOptionDescriptors.purgePersistentCache); + bool get disableServiceAuthCodes => getValue(DebuggingOptionDescriptors.disableServiceAuthCodes); + bool get disableServiceOriginCheck => + getValue(DebuggingOptionDescriptors.disableServiceOriginCheck); + bool get cacheStartupProfile => getValue(DebuggingOptionDescriptors.cacheStartupProfile); bool get runningWithPrebuiltApplication => prebuiltApplicationBinaryPath != null; - String? get prebuiltApplicationBinaryPath => stringArg(FlutterOptions.kUseApplicationBinary); - bool get trackWidgetCreation => boolArg('track-widget-creation'); + String? get prebuiltApplicationBinaryPath => getValue(CommonOptions.useApplicationBinary); + bool get trackWidgetCreation => getValue(BuildInfoOptions.trackWidgetCreation); ImpellerStatus get enableImpeller => - ImpellerStatus.fromBool(argResults!['enable-impeller'] as bool?); - bool get enableFlutterGpu => (argResults!['enable-flutter-gpu'] as bool?) ?? false; - bool get enableVulkanValidation => boolArg('enable-vulkan-validation'); - bool get uninstallFirst => boolArg('uninstall-first'); - bool get enableEmbedderApi => boolArg('enable-embedder-api'); - bool get testFlag => boolArg('test-flag'); + ImpellerStatus.fromBool(getValue(DebuggingOptionDescriptors.enableImpeller)); + bool get enableFlutterGpu => getValue(DebuggingOptionDescriptors.enableFlutterGpu) ?? false; + bool get enableVulkanValidation => getValue(DebuggingOptionDescriptors.enableVulkanValidation); + bool get uninstallFirst => getValue(DebuggingOptionDescriptors.uninstallFirst); + bool get enableEmbedderApi => getValue(DebuggingOptionDescriptors.enableEmbedderApi); + bool get testFlag => getValue(DebuggingOptionDescriptors.testFlag); @override bool get refreshWirelessDevices => true; @@ -284,11 +134,11 @@ abstract class RunCommandBase extends FlutterCommand with DeviceBasedDevelopment /// Whether to start the application paused by default. bool get startPausedDefault; - String? get route => stringArg('route'); + String? get route => getValue(DebuggingOptionDescriptors.route); - String? get traceAllowlist => stringArg('trace-allowlist'); + String? get traceAllowlist => getValue(DebuggingOptionDescriptors.traceAllowlist); - bool get useWasm => boolArg(FlutterOptions.kWebWasmFlag); + bool get useWasm => getValue(WebOptions.wasm); // Keep in sync with the [TestCommand.webRenderer] getter. WebRendererMode get webRenderer { @@ -303,38 +153,50 @@ abstract class RunCommandBase extends FlutterCommand with DeviceBasedDevelopment @protected Future createDebuggingOptions({WebDevServerConfig? webDevServerConfig}) async { final BuildInfo buildInfo = await getBuildInfo(); - final int? webBrowserDebugPort = - featureFlags.isWebEnabled && argResults!.wasParsed('web-browser-debug-port') - ? int.parse(stringArg('web-browser-debug-port')!) + final int? webBrowserDebugPort = featureFlags.isWebEnabled + ? getValue(WebOptions.webBrowserDebugPort) : null; final List webBrowserFlags = featureFlags.isWebEnabled - ? stringsArg(FlutterOptions.kWebBrowserFlag) + ? getValue(WebOptions.webBrowserFlags) : const []; - final bool? webCrossOriginIsolation = argResults!.wasParsed('cross-origin-isolation') - ? boolArg('cross-origin-isolation') + final bool? webCrossOriginIsolation = wasParsed(WebOptions.crossOriginIsolation) + ? getValue(WebOptions.crossOriginIsolation) : null; - final bool? iosProfileDebugger = argResults!.wasParsed('ios-profile-debugger') - ? boolArg('ios-profile-debugger') + final bool? iosProfileDebugger = wasParsed(DebuggingOptionDescriptors.iosProfileDebugger) + ? getValue(DebuggingOptionDescriptors.iosProfileDebugger) : null; + + final webConfig = WebDebugConfig( + enableExposeUrl: featureFlags.isWebEnabled && getValue(WebOptions.webAllowExposeUrl), + useSseForDebugProxy: + featureFlags.isWebEnabled && getValue(WebOptions.webServerDebugProtocol) == 'sse', + useSseForDebugBackend: + featureFlags.isWebEnabled && getValue(WebOptions.webServerDebugBackendProtocol) == 'sse', + useSseForInjectedClient: + featureFlags.isWebEnabled && + getValue(WebOptions.webServerDebugInjectedClientProtocol) == 'sse', + runHeadless: featureFlags.isWebEnabled && getValue(WebOptions.webRunHeadless), + browserDebugPort: webBrowserDebugPort, + browserFlags: webBrowserFlags, + enableExpressionEvaluation: + !buildInfo.mode.isRelease && + featureFlags.isWebEnabled && + getValue(WebOptions.webEnableExpressionEvaluation), + launchUrl: !buildInfo.mode.isRelease && featureFlags.isWebEnabled + ? getValue(WebOptions.webLaunchUrl) + : null, + crossOriginIsolation: webCrossOriginIsolation, + renderer: webRenderer, + useWasm: useWasm, + devServerConfig: webDevServerConfig, + ); + if (buildInfo.mode.isRelease) { return DebuggingOptions.disabled( buildInfo, - dartEntrypointArgs: stringsArg('dart-entrypoint-args'), - webUseSseForDebugProxy: - featureFlags.isWebEnabled && stringArg('web-server-debug-protocol') == 'sse', - webUseSseForDebugBackend: - featureFlags.isWebEnabled && stringArg('web-server-debug-backend-protocol') == 'sse', - webUseSseForInjectedClient: - featureFlags.isWebEnabled && - stringArg('web-server-debug-injected-client-protocol') == 'sse', - webEnableExposeUrl: featureFlags.isWebEnabled && boolArg('web-allow-expose-url'), - webRunHeadless: featureFlags.isWebEnabled && boolArg('web-run-headless'), - webBrowserDebugPort: webBrowserDebugPort, - webBrowserFlags: webBrowserFlags, - webCrossOriginIsolation: webCrossOriginIsolation, - webRenderer: webRenderer, - webUseWasm: useWasm, + dartEntrypointArgs: getValue(DebuggingOptionDescriptors.dartEntrypointArgs), + webConfig: webConfig, enableImpeller: enableImpeller, enableFlutterGpu: enableFlutterGpu, enableVulkanValidation: enableVulkanValidation, @@ -343,8 +205,7 @@ abstract class RunCommandBase extends FlutterCommand with DeviceBasedDevelopment enableEmbedderApi: enableEmbedderApi, usingCISystem: usingCISystem, debugLogsDirectoryPath: debugLogsDirectoryPath, - webDevServerConfig: webDevServerConfig, - enableHcpp: enableHcpp, + enableHcpp: explicitEnableHcpp, testFlag: testFlag, iosProfileDebugger: iosProfileDebugger, traceSystrace: traceSystrace, @@ -352,67 +213,55 @@ abstract class RunCommandBase extends FlutterCommand with DeviceBasedDevelopment } else { return DebuggingOptions.enabled( buildInfo, - startPaused: boolArg('start-paused'), - disableServiceAuthCodes: boolArg('disable-service-auth-codes'), - disableServiceOriginCheck: boolArg('disable-service-origin-check'), + startPaused: getValue(DebuggingOptionDescriptors.startPausedOption), + disableServiceAuthCodes: disableServiceAuthCodes, + disableServiceOriginCheck: disableServiceOriginCheck, cacheStartupProfile: cacheStartupProfile, enableDds: enableDds, - dartEntrypointArgs: stringsArg('dart-entrypoint-args'), - dartFlags: stringArg('dart-flags') ?? '', - useTestFonts: argParser.options.containsKey('use-test-fonts') && boolArg('use-test-fonts'), + adbLogFiltering: + hasOption(DebuggingOptionDescriptors.adbLogFiltering) && + getValue(DebuggingOptionDescriptors.adbLogFiltering), + dartEntrypointArgs: getValue(DebuggingOptionDescriptors.dartEntrypointArgs), + dartFlags: getValue(DebuggingOptionDescriptors.dartFlags) ?? '', + useTestFonts: + hasOption(DebuggingOptionDescriptors.useTestFonts) && + getValue(DebuggingOptionDescriptors.useTestFonts), enableSoftwareRendering: - argParser.options.containsKey('enable-software-rendering') && - boolArg('enable-software-rendering'), + hasOption(DebuggingOptionDescriptors.enableSoftwareRendering) && + getValue(DebuggingOptionDescriptors.enableSoftwareRendering), skiaDeterministicRendering: - argParser.options.containsKey('skia-deterministic-rendering') && - boolArg('skia-deterministic-rendering'), - traceSkia: boolArg('trace-skia'), + hasOption(DebuggingOptionDescriptors.skiaDeterministicRendering) && + getValue(DebuggingOptionDescriptors.skiaDeterministicRendering), + traceSkia: getValue(DebuggingOptionDescriptors.traceSkia), traceAllowlist: traceAllowlist, - traceSkiaAllowlist: stringArg('trace-skia-allowlist'), - traceSystrace: boolArg('trace-systrace'), - traceToFile: stringArg('trace-to-file'), - endlessTraceBuffer: boolArg('endless-trace-buffer'), - profileMicrotasks: boolArg('profile-microtasks'), + traceSkiaAllowlist: getValue(DebuggingOptionDescriptors.traceSkiaAllowlist), + traceSystrace: traceSystrace, + traceToFile: getValue(DebuggingOptionDescriptors.traceToFile), + endlessTraceBuffer: getValue(DebuggingOptionDescriptors.endlessTraceBuffer), + profileMicrotasks: getValue(DebuggingOptionDescriptors.profileMicrotasks), purgePersistentCache: purgePersistentCache, deviceVmServicePort: deviceVmservicePort, hostVmServicePort: hostVmservicePort, disablePortPublication: await disablePortPublication, ddsPort: ddsPort, devToolsServerAddress: devToolsServerAddress, - verboseSystemLogs: boolArg('verbose-system-logs'), - webUseSseForDebugProxy: - featureFlags.isWebEnabled && stringArg('web-server-debug-protocol') == 'sse', - webUseSseForDebugBackend: - featureFlags.isWebEnabled && stringArg('web-server-debug-backend-protocol') == 'sse', - webUseSseForInjectedClient: - featureFlags.isWebEnabled && - stringArg('web-server-debug-injected-client-protocol') == 'sse', - webEnableExposeUrl: featureFlags.isWebEnabled && boolArg('web-allow-expose-url'), - webRunHeadless: featureFlags.isWebEnabled && boolArg('web-run-headless'), - webBrowserDebugPort: webBrowserDebugPort, - webBrowserFlags: webBrowserFlags, - webEnableExpressionEvaluation: - featureFlags.isWebEnabled && boolArg('web-enable-expression-evaluation'), - webLaunchUrl: featureFlags.isWebEnabled ? stringArg('web-launch-url') : null, - webCrossOriginIsolation: webCrossOriginIsolation, - webRenderer: webRenderer, - webUseWasm: useWasm, - vmserviceOutFile: stringArg('vmservice-out-file'), - nativeNullAssertions: boolArg('native-null-assertions'), + verboseSystemLogs: getValue(DebuggingOptionDescriptors.verboseSystemLogs), + webConfig: webConfig, + vmserviceOutFile: getValue(DebuggingOptionDescriptors.vmserviceOutFile), + nativeNullAssertions: getValue(CommonOptions.nativeNullAssertions), enableImpeller: enableImpeller, enableFlutterGpu: enableFlutterGpu, enableVulkanValidation: enableVulkanValidation, uninstallFirst: uninstallFirst, enableDartProfiling: enableDartProfiling, - profileStartup: boolArg('profile-startup'), + profileStartup: getValue(DebuggingOptionDescriptors.profileStartup), enableEmbedderApi: enableEmbedderApi, usingCISystem: usingCISystem, debugLogsDirectoryPath: debugLogsDirectoryPath, - enableDevTools: boolArg(FlutterCommand.kEnableDevTools), - ipv6: boolArg(FlutterCommand.ipv6Flag), - printDtd: boolArg(FlutterGlobalOptions.kPrintDtd, global: true), - enableHcpp: enableHcpp, - webDevServerConfig: webDevServerConfig, + enableDevTools: getValue(DebuggingOptionDescriptors.enableDevTools), + ipv6: getValue(DebuggingOptionDescriptors.ipv6), + printDtd: getValue(CommonOptions.printDtd), + enableHcpp: explicitEnableHcpp, testFlag: testFlag, iosProfileDebugger: iosProfileDebugger, ); @@ -420,21 +269,21 @@ abstract class RunCommandBase extends FlutterCommand with DeviceBasedDevelopment } Future webDevServerConfigCore() async { + final ToolContext(:FileSystem fs, :Logger logger) = toolContext; final WebDevServerConfig fileConfig = await WebDevServerConfig.loadFromFile( - fileSystem: globals.fs, - logger: globals.logger, + fileSystem: fs, + logger: logger, ); - final String? webPortArg = stringArg('web-port'); - final int? webPort = webPortArg != null ? int.tryParse(webPortArg) : null; + final int? webPort = getValue(WebOptions.webPort); // Determine HTTPS config with CLI > file precedence final HttpsConfig? httpsConfig = HttpsConfig.parse( - stringArg('web-tls-cert-path') ?? fileConfig.https?.certPath, - stringArg('web-tls-cert-key-path') ?? fileConfig.https?.certKeyPath, + getValue(WebOptions.webTlsCertPath) ?? fileConfig.https?.certPath, + getValue(WebOptions.webTlsCertKeyPath) ?? fileConfig.https?.certKeyPath, ); - final String? baseHref = stringArg('base-href') ?? fileConfig.baseHref; + final String? baseHref = getValue(WebOptions.baseHref) ?? fileConfig.baseHref; if (baseHref != null && !(baseHref.startsWith('/') && baseHref.endsWith('/'))) { throwToolExit( 'Received a --base-href value of "$baseHref"\n' @@ -443,7 +292,7 @@ abstract class RunCommandBase extends FlutterCommand with DeviceBasedDevelopment } final WebDevServerConfig webDevServerConfig = fileConfig.copyWith( - host: stringArg('web-hostname'), + host: getValue(WebOptions.webHostname), port: webPort, https: httpsConfig, headers: extractWebHeaders(), @@ -451,94 +300,117 @@ abstract class RunCommandBase extends FlutterCommand with DeviceBasedDevelopment ); return webDevServerConfig; } + + @protected + void validatePrebuiltAndroidApplicationFlags() { + // First, verify the build mode. + if (getBuildMode() != BuildMode.release) { + return; + } + + // Then, verify an Android prebuilt application is being run. + final String? applicationBinary = hasOption(CommonOptions.useApplicationBinary) + ? getValue(CommonOptions.useApplicationBinary) + : null; + if (applicationBinary != null && applicationBinary.toLowerCase().endsWith('.apk')) { + final Iterable intentFlags = AndroidEngineCliFlags.allFlags.where( + (String flag) => argParser.options.containsKey(flag) && argResults?.wasParsed(flag) == true, + ); + + if (intentFlags.isNotEmpty) { + throwToolExit( + 'Running a prebuilt APK with --${FlutterOptions.kUseApplicationBinary} in release mode with flags used to configure the Flutter Android engine ' + '(${intentFlags.map((String flag) => '--$flag').join(', ')}) is no longer supported. Define the required flags via the Android manifest instead. See ' + 'https://docs.flutter.dev/release/breaking-changes/restrict-command-line-flags-prebuilt-android-release-binaries for more details.', + ); + } + } + } } class RunCommand extends RunCommandBase { - RunCommand({bool verboseHelp = false}) : super(verboseHelp: verboseHelp) { + RunCommand({ + required this.appleContext, + required super.buildSystem, + required super.buildTargets, + required super.toolContext, + this._androidContext, + this._androidWorkflow, + this.deviceManager, + super.verboseHelp = false, + }) { requiresPubspecYaml(); - usesFilesystemOptions(hide: !verboseHelp); - usesExtraDartFlagOptions(verboseHelp: verboseHelp); - usesFrontendServerStarterPathOption(verboseHelp: verboseHelp); - addEnableExperimentation(hide: !verboseHelp); - usesInitializeFromDillOption(hide: !verboseHelp); - usesNativeAssetsOption(hide: !verboseHelp); - - // By default, the app should to publish the VM service port over mDNS. - // This will allow subsequent "flutter attach" commands to connect to the VM - // without needing to know the port. - addPublishPort(verboseHelp: verboseHelp); - addIgnoreDeprecationOption(); - addMachineOutputFlag(verboseHelp: verboseHelp); - argParser - ..addFlag( - 'await-first-frame-when-tracing', - defaultsTo: true, - help: - 'Whether to wait for the first frame when tracing startup ("--trace-startup"), ' - 'or just dump the trace as soon as the application is running. The first frame ' - 'is detected by looking for a Timeline event with the name ' - '"${Tracing.firstUsefulFrameEventName}". ' - "By default, the widgets library's binding takes care of sending this event.", - ) - ..addFlag( - 'use-test-fonts', - help: - 'Enable (and default to) the "Ahem" font. This is a special font ' - 'used in tests to remove any dependencies on the font metrics. It ' - 'is enabled when you use "flutter test". Set this flag when running ' - 'a test using "flutter run" for debugging purposes. This flag is ' - 'only available when running in debug mode.', - ) - ..addFlag('build', defaultsTo: true, help: 'If necessary, build the app before running.') - ..addOption('project-root', hide: !verboseHelp, help: 'Specify the project root directory.') - ..addFlag( - 'hot', - defaultsTo: kHotReloadDefault, - help: - 'Run with support for hot reloading. Only available for debug mode. Not available with "--trace-startup".', - ) - ..addFlag( - 'resident', - defaultsTo: true, - hide: !verboseHelp, - help: - 'Stay resident after launching the application. Not available with "--trace-startup".', - ) - ..addOption( - 'pid-file', - help: - 'Specify a file to write the process ID to. ' - 'You can send SIGUSR1 to trigger a hot reload ' - 'and SIGUSR2 to trigger a hot restart. ' - 'The file is created when the signal handlers ' - 'are hooked and deleted when they are removed.', - ) - ..addFlag( - 'report-ready', - help: - 'Print "ready" to the console after handling a keyboard command.\n' - 'This is primarily useful for tests and other automation, but consider ' - 'using "--machine" instead.', - hide: !verboseHelp, - ) - ..addFlag( - 'benchmark', - negatable: false, - hide: !verboseHelp, - help: - 'Enable a benchmarking mode. This will run the given application, ' - 'measure the startup time and the app restart time, write the ' - 'results out to "refresh_benchmark.json", and exit. This flag is ' - 'intended for use in generating automated flutter benchmarks.', - ); + registerOptionBundles(const [ + FilesystemOptionsBundle(), + ResidentCompilerOptionsBundle( + includeFrontendServerStarterPath: true, + includeExtraDartFlags: true, + ), + ]); + argParser.addDescriptors(const >[ + CommonOptions.enableExperiment, + DebuggingOptionDescriptors.publishPort, + BuildInfoOptions.ignoreDeprecation, + CommonOptions.machine, + DebuggingOptionDescriptors.useTestFonts, + _awaitFirstFrameWhenTracing, + DebuggingOptionDescriptors.build, + DebuggingOptionDescriptors.projectRoot, + _hot, + _resident, + DebuggingOptionDescriptors.pidFile, + DebuggingOptionDescriptors.reportReady, + _benchmark, + ], verboseHelp: verboseHelp); } + static const _awaitFirstFrameWhenTracing = FlagOptionDescriptor( + name: 'await-first-frame-when-tracing', + defaultsTo: true, + help: + 'Whether to wait for the first frame when tracing startup ("--trace-startup"), ' + 'or just dump the trace as soon as the application is running. The first frame ' + 'is detected by looking for a Timeline event with the name ' + '"${Tracing.firstUsefulFrameEventName}". ' + "By default, the widgets library's binding takes care of sending this event.", + ); + + static const _hot = FlagOptionDescriptor( + name: 'hot', + defaultsTo: kHotReloadDefault, + help: 'Run with support for hot reloading. Only available for debug mode. Not available with "--trace-startup".', + ); + + static const _resident = FlagOptionDescriptor( + name: 'resident', + defaultsTo: true, + verboseOnly: true, + help: 'Stay resident after launching the application. Not available with "--trace-startup".', + ); + + static const _benchmark = FlagOptionDescriptor( + name: 'benchmark', + negatable: false, + verboseOnly: true, + help: + 'Enable a benchmarking mode. This will run the given application, ' + 'measure the startup time and the app restart time, write the ' + 'results out to "refresh_benchmark.json", and exit. This flag is ' + 'intended for use in generating automated flutter benchmarks.', + ); + + final AndroidContext? _androidContext; + final AndroidWorkflow? _androidWorkflow; + final AppleContext appleContext; + final DeviceManager? deviceManager; + @override final name = 'run'; @override - DeprecationBehavior get deprecationBehavior => - boolArg('ignore-deprecation') ? DeprecationBehavior.ignore : _deviceDeprecationBehavior; + DeprecationBehavior get deprecationBehavior => getValue(BuildInfoOptions.ignoreDeprecation) + ? DeprecationBehavior.ignore + : _deviceDeprecationBehavior; DeprecationBehavior _deviceDeprecationBehavior = DeprecationBehavior.none; @override @@ -562,7 +434,7 @@ class RunCommand extends RunCommandBase { return null; } - String? get userIdentifier => stringArg(FlutterOptions.kDeviceUser); + String? get userIdentifier => getValue(CommonOptions.deviceUser); @override bool get startPausedDefault => false; @@ -647,20 +519,23 @@ class RunCommand extends RunCommandBase { String? androidEmbeddingVersion; final hostLanguage = []; + final ToolContext(:FileSystem fs, :FlutterProjectFactory projectFactory) = toolContext; if (anyAndroidDevices) { - final AndroidProject androidProject = FlutterProject.current().android; + final AndroidProject androidProject = projectFactory + .fromDirectory(fs.currentDirectory) + .android; if (androidProject.existsSync()) { hostLanguage.add(androidProject.isKotlin ? 'kotlin' : 'java'); androidEmbeddingVersion = androidProject.getEmbeddingVersion().toString().split('.').last; } } if (anyIOSDevices) { - final IosProject iosProject = FlutterProject.current().ios; + final IosProject iosProject = projectFactory.fromDirectory(fs.currentDirectory).ios; if (iosProject.exists) { final Iterable swiftFiles = iosProject.hostAppRoot .listSync(recursive: true, followLinks: false) .whereType() - .where((File file) => globals.fs.path.extension(file.path) == '.swift'); + .where((File file) => fs.path.extension(file.path) == '.swift'); hostLanguage.add(swiftFiles.isNotEmpty ? 'swift' : 'objc'); } } @@ -678,7 +553,11 @@ class RunCommand extends RunCommandBase { runEnableImpeller: enableImpeller.asBool, runIOSInterfaceType: iOSInterfaceType, runIsTest: targetFile.endsWith('_test.dart'), - runEnableHcpp: enableHcpp, + // Best-effort estimate from the main manifest; does not account for build-type + // or flavor overlay manifests (e.g. EnableHcpp set only in src/debug/). + runEnableHcpp: anyAndroidDevices && project.android.existsSync() + ? (explicitEnableHcpp ?? project.android.computeHcppEnabled(ifAbsent: enableHcpp)) + : null, ); })(); @@ -693,19 +572,21 @@ class RunCommand extends RunCommandBase { } bool shouldUseHotMode(BuildInfo buildInfo) { - final bool hotArg = boolArg('hot'); + final bool hotArg = getValue(_hot); final bool shouldUseHotMode = hotArg && !traceStartup; return buildInfo.isDebug && shouldUseHotMode; } - bool get stayResident => boolArg('resident'); - bool get awaitFirstFrameWhenTracing => boolArg('await-first-frame-when-tracing'); + bool get stayResident => getValue(_resident); + bool get awaitFirstFrameWhenTracing => getValue(_awaitFirstFrameWhenTracing); @override Future validateCommand() async { - // When running with a prebuilt application, no command validation is - // necessary. - if (!runningWithPrebuiltApplication) { + if (runningWithPrebuiltApplication) { + // For Android prebuilt applications run in release mode, validate that engine configuration flags + // are not passed. + validatePrebuiltAndroidApplicationFlags(); + } else { await super.validateCommand(); } @@ -715,7 +596,7 @@ class RunCommand extends RunCommandBase { } final WebDevServerConfig? webDevServerConfig = await getWebDevServerConfig(); final webMode = webDevServerConfig != null; - if (globals.deviceManager!.hasSpecifiedAllDevices && runningWithPrebuiltApplication) { + if ((deviceManager?.hasSpecifiedAllDevices ?? false) && runningWithPrebuiltApplication) { throwToolExit( 'Using "-d all" with "--${FlutterOptions.kUseApplicationBinary}" is not supported', ); @@ -740,17 +621,32 @@ class RunCommand extends RunCommandBase { throwToolExit('Skwasm renderer requires --wasm'); } - final String? flavor = stringArg('flavor'); + final Logger logger = toolContext.logger; + final String? flavor = getValue(BuildInfoOptions.flavor); final bool flavorsSupportedOnEveryDevice = devices!.every( (Device device) => device.supportsFlavors, ); if (flavor != null && !flavorsSupportedOnEveryDevice) { - globals.printWarning( + logger.printWarning( '--flavor is only supported for Android, Linux, macOS, iOS, and Windows devices. ' 'Flavor-related features may not function properly and could ' 'behave differently in a future release.', ); } + + if (wasParsed(DebuggingOptionDescriptors.build)) { + if (getValue(DebuggingOptionDescriptors.build)) { + logger.printWarning( + 'The "--build" flag is deprecated and will be removed in a future release. ' + 'Building is the default behavior, so this flag can be safely removed.', + ); + } else { + logger.printWarning( + 'The "--no-build" flag is deprecated and will be removed in a future release. ' + 'To use a prebuilt application, pass "--${FlutterOptions.kUseApplicationBinary}".', + ); + } + } } @visibleForTesting @@ -760,6 +656,8 @@ class RunCommand extends RunCommandBase { required String? applicationBinaryPath, required FlutterProject flutterProject, }) async { + final FileSystem fs = toolContext.fs; + final WebDevServerConfig? webDevServerConfig = await getWebDevServerConfig(); final webMode = webDevServerConfig != null; final DebuggingOptions debuggingOptions = await createDebuggingOptions( @@ -771,17 +669,18 @@ class RunCommand extends RunCommandBase { flutterDevices, target: targetFile, debuggingOptions: debuggingOptions, - benchmarkMode: boolArg('benchmark'), - applicationBinary: applicationBinaryPath == null - ? null - : globals.fs.file(applicationBinaryPath), - projectRootPath: stringArg('project-root'), - dillOutputPath: stringArg('output-dill'), + benchmarkMode: getValue(_benchmark), + applicationBinary: applicationBinaryPath == null ? null : fs.file(applicationBinaryPath), + projectRootPath: getValue(DebuggingOptionDescriptors.projectRoot), + dillOutputPath: getValue(BuildInfoOptions.outputDill), stayResident: stayResident, - analytics: globals.analytics, - nativeAssetsYamlFile: stringArg(FlutterOptions.kNativeAssetsYamlFile), + analytics: this.analytics, + nativeAssetsYamlFile: getValue(BuildInfoOptions.nativeAssetsYamlFile), dartBuilder: hookRunner, - logger: globals.logger, + buildSystem: buildSystem, + buildTargets: buildTargets, + toolContext: toolContext, + xcode: appleContext.xcode, ); } else if (webMode) { return webRunnerFactory!.createWebRunner( @@ -790,13 +689,10 @@ class RunCommand extends RunCommandBase { flutterProject: flutterProject, debuggingOptions: debuggingOptions, stayResident: stayResident, - fileSystem: globals.fs, - analytics: globals.analytics, - logger: globals.logger, - terminal: globals.terminal, - platform: globals.platform, - outputPreferences: globals.outputPreferences, - systemClock: globals.systemClock, + analytics: this.analytics, + buildSystem: buildSystem, + buildTargets: buildTargets, + toolContext: toolContext, webDefines: extractWebDefines(), ); } @@ -806,21 +702,44 @@ class RunCommand extends RunCommandBase { debuggingOptions: debuggingOptions, traceStartup: traceStartup, awaitFirstFrameWhenTracing: awaitFirstFrameWhenTracing, - applicationBinary: applicationBinaryPath == null - ? null - : globals.fs.file(applicationBinaryPath), + applicationBinary: applicationBinaryPath == null ? null : fs.file(applicationBinaryPath), stayResident: stayResident, dartBuilder: hookRunner, + analytics: this.analytics, + buildSystem: buildSystem, + buildTargets: buildTargets, + toolContext: toolContext, + xcode: appleContext.xcode, ); } @visibleForTesting Daemon createMachineDaemon() { - return Daemon.createMachineDaemon(); + return Daemon.createMachineDaemon( + analytics: this.analytics, + buildSystem: buildSystem, + buildTargets: buildTargets, + featureFlags: featureFlags, + toolContext: toolContext, + xcode: appleContext.xcode, + androidSdk: _androidContext?.androidSdk, + androidWorkflow: _androidWorkflow, + deviceManager: deviceManager, + java: _androidContext?.java, + ); } @override Future runCommand() async { + final ToolContext( + :FileSystem fs, + :Logger logger, + :Signals signals, + :SystemClock systemClock, + :Terminal terminal, + ) = toolContext; + final ProcessInfo processInfo = this.processInfo; + final BuildInfo buildInfo = await getBuildInfo(); // Enable hot mode by default if `--no-hot` was not passed and we are in // debug mode. @@ -841,25 +760,23 @@ class RunCommand extends RunCommandBase { try { app = await daemon.appDomain.startApp( devices!.first, - globals.fs.currentDirectory.path, + fs.currentDirectory.path, targetFile, route, debuggingOptions, hotMode, webDefines: extractWebDefines(), - applicationBinary: applicationBinaryPath == null - ? null - : globals.fs.file(applicationBinaryPath), + applicationBinary: applicationBinaryPath == null ? null : fs.file(applicationBinaryPath), trackWidgetCreation: trackWidgetCreation, - projectRootPath: stringArg('project-root'), - packagesFilePath: globalResults![FlutterGlobalOptions.kPackagesOption] as String?, - dillOutputPath: stringArg('output-dill'), + projectRootPath: getValue(DebuggingOptionDescriptors.projectRoot), + packagesFilePath: getValue(CommonOptions.packages), + dillOutputPath: getValue(BuildInfoOptions.outputDill), userIdentifier: userIdentifier, ); } on Exception catch (error) { throwToolExit(error.toString()); } - final DateTime appStartedTime = globals.systemClock.now(); + final DateTime appStartedTime = systemClock.now(); final int result = await app.runner.waitForAppToFinish(); if (result != 0) { throwToolExit(null, exitCode: result); @@ -870,14 +787,14 @@ class RunCommand extends RunCommandBase { endTimeOverride: appStartedTime, ); } - globals.terminal.usesTerminalUi = true; + terminal.usesTerminalUi = true; final BuildMode buildMode = getBuildMode(); for (final Device device in devices!) { if (!await device.supportsRuntimeMode(buildMode)) { throwToolExit( '${buildMode.uppercaseFriendlyName}' - 'mode is not supported by ${device.displayName}.', + ' mode is not supported by ${device.displayName}.', ); } if (hotMode) { @@ -894,10 +811,10 @@ class RunCommand extends RunCommandBase { for (final Device device in devices!) await FlutterDevice.create( device, - target: targetFile, buildInfo: buildInfo, + target: targetFile, + toolContext: toolContext, userIdentifier: userIdentifier, - platform: globals.platform, ), ]; @@ -919,17 +836,17 @@ class RunCommand extends RunCommandBase { // This callback can't throw. unawaited( appStartedTimeRecorder.future.then((_) { - appStartedTime = globals.systemClock.now(); + appStartedTime = systemClock.now(); if (stayResident) { handler = TerminalHandler( runner, - logger: globals.logger, - terminal: globals.terminal, - signals: globals.signals, - processInfo: globals.processInfo, - reportReady: boolArg('report-ready'), - pidFile: stringArg('pid-file'), + logger: logger, + terminal: terminal, + signals: signals, + processInfo: processInfo, + reportReady: getValue(DebuggingOptionDescriptors.reportReady), + pidFile: getValue(DebuggingOptionDescriptors.pidFile), ) ..registerSignalHandlers() ..setupTerminal(); @@ -956,7 +873,7 @@ class RunCommand extends RunCommandBase { // However we exited from the runner, ensure the terminal has line mode // and echo mode enabled before we return the user to the shell. try { - globals.terminal.singleCharMode = false; + terminal.singleCharMode = false; } on StdinException { // Do nothing, if the STDIN handle is no longer available, there is nothing actionable for us to do at this point } diff --git a/packages/flutter_tools/lib/src/commands/screenshot.dart b/packages/flutter_tools/lib/src/commands/screenshot.dart index 44c20038..a9a574b6 100644 --- a/packages/flutter_tools/lib/src/commands/screenshot.dart +++ b/packages/flutter_tools/lib/src/commands/screenshot.dart @@ -7,9 +7,10 @@ import 'package:vm_service/vm_service.dart' as vm_service; import '../base/common.dart'; import '../base/file_system.dart'; +import '../base/logger.dart'; +import '../context/tool_context.dart'; import '../convert.dart'; import '../device.dart'; -import '../globals.dart' as globals; import '../runner/flutter_command.dart'; import '../vmservice.dart'; @@ -19,8 +20,10 @@ const _kVmServiceUrl = 'vm-service-url'; const _kDeviceType = 'device'; const _kSkiaType = 'skia'; +/// The `flutter screenshot` command, which captures a screenshot from a connected device. class ScreenshotCommand extends FlutterCommand { - ScreenshotCommand({required this.fs}) { + ScreenshotCommand({required super.toolContext, VMServiceConnector? vmServiceConnector}) + : _vmServiceConnector = vmServiceConnector ?? connectToVmService { argParser.addOption( _kOut, abbr: 'o', @@ -54,7 +57,12 @@ class ScreenshotCommand extends FlutterCommand { usesDeviceConnectionOption(); } - final FileSystem fs; + final VMServiceConnector _vmServiceConnector; + + FileSystem get fs => toolContext.fs; + + @override + ToolContext get toolContext => super.toolContext!; @override String get name => 'screenshot'; @@ -66,7 +74,7 @@ class ScreenshotCommand extends FlutterCommand { final String category = FlutterCommandCategory.tools; @override - bool get refreshWirelessDevices => true; + bool get refreshWirelessDevices => argResults == null || stringArg(_kType) == _kDeviceType; @override final aliases = ['pic']; @@ -104,6 +112,7 @@ class ScreenshotCommand extends FlutterCommand { @override Future runCommand() async { + final FileSystem fs = toolContext.fs; File? outputFile; if (argResults?.wasParsed(_kOut) ?? false) { outputFile = fs.file(stringArg(_kOut)); @@ -121,7 +130,9 @@ class ScreenshotCommand extends FlutterCommand { } Future runScreenshot(File? outputFile) async { - outputFile ??= globals.fsUtils.getUniqueFile(fs.currentDirectory, 'flutter', 'png'); + final FileSystem fs = toolContext.fs; + final FileSystemUtils fileSystemUtils = toolContext.fileSystemUtils; + outputFile ??= fileSystemUtils.getUniqueFile(fs.currentDirectory, 'flutter', 'png'); try { await device!.takeScreenshot(outputFile); @@ -144,20 +155,20 @@ class ScreenshotCommand extends FlutterCommand { } Future runSkia(File? outputFile) async { + final FileSystem fs = toolContext.fs; + final FileSystemUtils fileSystemUtils = toolContext.fileSystemUtils; + final Logger logger = toolContext.logger; final Uri vmServiceUrl = Uri.parse(stringArg(_kVmServiceUrl)!); - final FlutterVmService vmService = await connectToVmService( - vmServiceUrl, - logger: globals.logger, - ); + final FlutterVmService vmService = await _vmServiceConnector(vmServiceUrl, logger: logger); final vm_service.Response? skp = await vmService.screenshotSkp(); if (skp == null) { - globals.printError( + logger.printError( 'The Skia picture request failed, probably because the device was ' 'disconnected', ); return false; } - outputFile ??= globals.fsUtils.getUniqueFile(fs.currentDirectory, 'flutter', 'skp'); + outputFile ??= fileSystemUtils.getUniqueFile(fs.currentDirectory, 'flutter', 'skp'); final IOSink sink = outputFile.openWrite(); sink.add(base64.decode(skp.json?['skp'] as String)); await sink.close(); @@ -189,9 +200,9 @@ class ScreenshotCommand extends FlutterCommand { } void _showOutputFileInfo(File outputFile) { - final int sizeKB = (outputFile.lengthSync()) ~/ 1024; - globals.printStatus( - 'Screenshot written to ${fs.path.relative(outputFile.path)} (${sizeKB}kB).', - ); + final FileSystem fs = toolContext.fs; + final Logger logger = toolContext.logger; + final int sizeKB = outputFile.lengthSync() ~/ 1024; + logger.printStatus('Screenshot written to ${fs.path.relative(outputFile.path)} (${sizeKB}kB).'); } } diff --git a/packages/flutter_tools/lib/src/commands/shell_completion.dart b/packages/flutter_tools/lib/src/commands/shell_completion.dart index cb20d035..f70676cb 100644 --- a/packages/flutter_tools/lib/src/commands/shell_completion.dart +++ b/packages/flutter_tools/lib/src/commands/shell_completion.dart @@ -6,11 +6,12 @@ import 'package:completion/completion.dart'; import '../base/common.dart'; import '../base/file_system.dart'; -import '../globals.dart' as globals; +import '../base/io.dart'; +import '../context/tool_context.dart'; import '../runner/flutter_command.dart'; class ShellCompletionCommand extends FlutterCommand { - ShellCompletionCommand() { + ShellCompletionCommand({required super.toolContext}) { argParser.addFlag( 'overwrite', help: @@ -18,6 +19,9 @@ class ShellCompletionCommand extends FlutterCommand { ); } + @override + ToolContext get toolContext => super.toolContext!; + @override final name = 'bash-completion'; @@ -44,6 +48,9 @@ class ShellCompletionCommand extends FlutterCommand { @override Future runCommand() async { + final FileSystem fs = toolContext.fs; + final Stdio stdio = toolContext.stdio; + final List rest = argResults?.rest ?? []; if (rest.length > 1) { throwToolExit('Too many arguments given to bash-completion command.', exitCode: 1); @@ -51,11 +58,11 @@ class ShellCompletionCommand extends FlutterCommand { if (rest.isEmpty || rest.first == '-') { final String script = generateCompletionScript(['flutter']); - globals.stdio.stdoutWrite(script); + stdio.stdoutWrite(script); return FlutterCommandResult.warning(); } - final File outputFile = globals.fs.file(rest.first); + final File outputFile = fs.file(rest.first); if (outputFile.existsSync() && !boolArg('overwrite')) { throwToolExit( 'Output file ${outputFile.path} already exists, will not overwrite. ' diff --git a/packages/flutter_tools/lib/src/commands/symbolize.dart b/packages/flutter_tools/lib/src/commands/symbolize.dart index 54bc92d5..2c450bde 100644 --- a/packages/flutter_tools/lib/src/commands/symbolize.dart +++ b/packages/flutter_tools/lib/src/commands/symbolize.dart @@ -12,6 +12,7 @@ import '../base/common.dart'; import '../base/file_system.dart'; import '../base/io.dart'; import '../base/utils.dart'; +import '../context/tool_context.dart'; import '../convert.dart'; import '../runner/flutter_command.dart'; @@ -25,12 +26,9 @@ const rootLoadingUnitId = 1; /// over stdout. class SymbolizeCommand extends FlutterCommand { SymbolizeCommand({ - required Stdio stdio, - required FileSystem fileSystem, - DwarfSymbolizationService dwarfSymbolizationService = const DwarfSymbolizationService(), - }) : _stdio = stdio, - _fileSystem = fileSystem, - _dwarfSymbolizationService = dwarfSymbolizationService { + required ToolContext super.toolContext, + this._dwarfSymbolizationService = const DwarfSymbolizationService(), + }) : _toolContext = toolContext { argParser.addOption( 'debug-info', abbr: 'd', @@ -58,10 +56,12 @@ class SymbolizeCommand extends FlutterCommand { ); } - final Stdio _stdio; - final FileSystem _fileSystem; + final ToolContext _toolContext; final DwarfSymbolizationService _dwarfSymbolizationService; + @override + ToolContext get toolContext => _toolContext; + @override String get description => 'Symbolize a stack trace from an AOT-compiled Flutter app.'; @@ -75,7 +75,8 @@ class SymbolizeCommand extends FlutterCommand { bool get shouldUpdateCache => false; File _handleDSYM(String fileName) { - final FileSystemEntityType type = _fileSystem.typeSync(fileName); + final FileSystem fs = _toolContext.fs; + final FileSystemEntityType type = fs.typeSync(fileName); final bool isDSYM = fileName.endsWith('.dSYM'); if (type == FileSystemEntityType.notFound) { throw FileNotFoundException(fileName); @@ -84,7 +85,7 @@ class SymbolizeCommand extends FlutterCommand { if (!isDSYM) { throw StateError('$fileName is a directory, not a file'); } - final Directory dwarfDir = _fileSystem + final Directory dwarfDir = fs .directory(fileName) .childDirectory('Contents') .childDirectory('Resources') @@ -95,7 +96,7 @@ class SymbolizeCommand extends FlutterCommand { if (isDSYM) { throw StateError('$fileName is not a dSYM package directory'); } - return _fileSystem.file(fileName); + return fs.file(fileName); } Map _unitDebugInfoPathMap() { @@ -158,7 +159,7 @@ class SymbolizeCommand extends FlutterCommand { ); } if ((argResults?.wasParsed('input') ?? false) && - !await _fileSystem.isFile(stringArg('input')!)) { + !await _toolContext.fs.isFile(stringArg('input')!)) { throwToolExit('${stringArg('input')} does not exist.'); } return super.validateCommand(); @@ -166,24 +167,27 @@ class SymbolizeCommand extends FlutterCommand { @override Future runCommand() async { + final FileSystem fs = _toolContext.fs; + final Stdio stdio = _toolContext.stdio; + // Configure output to either specified file or stdout. late final IOSink output; if (argResults?.wasParsed('output') ?? false) { - final File outputFile = _fileSystem.file(stringArg('output')); + final File outputFile = fs.file(stringArg('output')); if (!outputFile.parent.existsSync()) { outputFile.parent.createSync(recursive: true); } output = outputFile.openWrite(); } else { final outputController = StreamController>(); - outputController.stream.transformWithCallSite(utf8.decoder).listen(_stdio.stdoutWrite); + outputController.stream.transformWithCallSite(utf8.decoder).listen(stdio.stdoutWrite); output = IOSink(outputController); } // Configure input from either specified file or stdin. final Stream> input = (argResults?.wasParsed('input') ?? false) - ? _fileSystem.file(stringArg('input')).openRead() - : _stdio.stdin; + ? fs.file(stringArg('input')).openRead() + : stdio.stdin; final unitSymbols = { for (final MapEntry entry in _unitDebugInfoPathMap().entries) @@ -316,12 +320,13 @@ class DwarfSymbolizationService { }, onDone: onDone.complete, onError: onDone.completeError, + cancelOnError: true, ); try { await onDone.future; await output.close(); - } on Exception catch (err) { + } on Object catch (err) { throwToolExit('Failed to symbolize stack trace:\n $err'); } } diff --git a/packages/flutter_tools/lib/src/commands/test.dart b/packages/flutter_tools/lib/src/commands/test.dart index 1523a4d8..ba2f8900 100644 --- a/packages/flutter_tools/lib/src/commands/test.dart +++ b/packages/flutter_tools/lib/src/commands/test.dart @@ -4,19 +4,24 @@ import 'package:meta/meta.dart'; import 'package:package_config/package_config_types.dart'; +import 'package:process/process.dart'; +import '../artifacts.dart'; import '../asset.dart'; import '../base/common.dart'; +import '../base/config.dart'; import '../base/file_system.dart'; +import '../base/io.dart'; +import '../base/logger.dart'; +import '../base/platform.dart'; import '../build_info.dart'; import '../bundle_builder.dart'; +import '../context/tool_context.dart'; import '../devfs.dart'; import '../device.dart'; -import '../globals.dart' as globals; import '../native_assets.dart'; import '../project.dart'; import '../runner/flutter_command.dart'; -import '../runner/flutter_command_runner.dart'; import '../test/coverage_collector.dart'; import '../test/event_printer.dart'; import '../test/runner.dart'; @@ -25,6 +30,7 @@ import '../test/test_wrapper.dart'; import '../test/watcher.dart'; import '../web/compile.dart'; import '../web/web_constants.dart'; +import '../web/web_options.dart'; /// The name of the directory where Integration Tests are placed. /// @@ -63,267 +69,323 @@ const _kIntegrationTestDirectory = 'integration_test'; /// - https://flutter.dev/to/integration-testing class TestCommand extends FlutterCommand with DeviceBasedDevelopmentArtifacts { TestCommand({ - bool verboseHelp = false, + required ToolContext toolContext, + this.nativeAssetsBuilder, + FlutterTestRunner? testRunner, this.testWrapper = const TestWrapper(), - this.testRunner = const FlutterTestRunner(), this.verbose = false, - this.nativeAssetsBuilder, - }) { + super.verboseHelp = false, + }) : _toolContext = toolContext, + _testRunner = testRunner ?? FlutterTestRunner(toolContext: toolContext), + super(toolContext: toolContext) { requiresPubspecYaml(); - usesPubOption(); - usesFrontendServerStarterPathOption(verboseHelp: verboseHelp); - usesTrackWidgetCreation(verboseHelp: verboseHelp); - addEnableExperimentation(hide: !verboseHelp); - usesDartDefineOption(); - usesDeviceUserOption(); - usesFlavorOption(); - addEnableImpellerFlag(verboseHelp: verboseHelp); - addMachineOutputFlag(verboseHelp: verboseHelp); - addEnableFlutterGpuFlag(verboseHelp: verboseHelp); - addEnableHcppFlag(verboseHelp: verboseHelp); - - argParser - ..addFlag( - 'experimental-faster-testing', - negatable: false, - hide: !verboseHelp, - help: 'Run each test in a separate lightweight Flutter Engine to speed up testing.', - ) - ..addMultiOption( - 'name', - help: 'A regular expression matching substrings of the names of tests to run.', - valueHelp: 'regexp', - splitCommas: false, - ) - ..addMultiOption( - 'plain-name', - help: 'A plain-text substring of the names of tests to run.', - valueHelp: 'substring', - splitCommas: false, - ) - ..addMultiOption( - 'tags', - abbr: 't', - help: - 'Run only tests associated with the specified tags. See: https://pub.dev/packages/test#tagging-tests', - splitCommas: false, - ) - ..addMultiOption( - 'exclude-tags', - abbr: 'x', - help: - 'Run only tests that do not have the specified tags. See: https://pub.dev/packages/test#tagging-tests', - splitCommas: false, - ) - ..addFlag( - 'start-paused', - negatable: false, - help: - 'Start in a paused mode and wait for a debugger to connect.\n' - 'You must specify a single test file to run, explicitly.\n' - 'Instructions for connecting with a debugger are printed to the ' - 'console once the test has started.', - ) - ..addFlag('fail-fast', help: 'Stop running tests after the first failure.') - ..addFlag('run-skipped', help: 'Run skipped tests instead of skipping them.') - ..addFlag( - 'disable-service-auth-codes', - negatable: false, - hide: !verboseHelp, - help: - '(deprecated) Allow connections to the VM service without using authentication codes. ' - '(Not recommended! This can open your device to remote code execution attacks!)', - ) - ..addFlag( - 'disable-service-origin-check', - negatable: false, - hide: !verboseHelp, - help: - 'Allow connections to the VM service from any origin. ' - '(Not recommended. This can open your device to remote code execution attacks.)', - ) - ..addFlag('coverage', negatable: false, help: 'Whether to collect coverage information.') - ..addFlag( - 'merge-coverage', - negatable: false, - help: - 'Whether to merge coverage data with "coverage/lcov.base.info".\n' - 'Implies collecting coverage data. (Requires lcov.)', - ) - ..addFlag( - 'branch-coverage', - negatable: false, - help: - 'Whether to collect branch coverage information. ' - 'Implies collecting coverage data.', - ) - ..addFlag( - 'ipv6', - negatable: false, - hide: !verboseHelp, - help: 'Whether to use IPv6 for the test harness server socket.', - ) - ..addOption( - 'coverage-path', - defaultsTo: 'coverage/lcov.info', - help: 'Where to store coverage information (if coverage is enabled).', - ) - ..addMultiOption( - 'coverage-package', - help: - 'A regular expression matching packages names ' - 'to include in the coverage report (if coverage is enabled). ' - 'If unset, matches the current package name.', - valueHelp: 'package-name-regexp', - splitCommas: false, - ) - ..addFlag( - 'update-goldens', - negatable: false, - help: - 'Whether "matchesGoldenFile()" calls within your test methods should ' // flutter_ignore: golden_tag (see analyze.dart) - 'update the golden files rather than test for an existing match.', - ) - ..addOption( - 'concurrency', - abbr: 'j', - help: - 'The number of concurrent test processes to run. This will be ignored ' - 'when running integration tests.', - valueHelp: 'jobs', - ) - ..addFlag( - 'test-assets', - defaultsTo: true, - help: - 'Whether to build the assets bundle for testing. ' - 'This takes additional time before running the tests. ' - 'Consider using "--no-test-assets" if assets are not required.', - ) - // --platform is not supported to be used by Flutter developers. It only - // exists to test the Flutter framework itself and may be removed entirely - // in the future. Developers should either use plain `flutter test`, or - // `package:integration_test` instead. - ..addOption( - 'platform', - allowed: const ['tester', 'chrome'], - hide: !verboseHelp, - defaultsTo: 'tester', - help: 'Selects the test backend.', - allowedHelp: { - 'tester': 'Run tests using the VM-based test environment.', - 'chrome': - '(deprecated) Run tests using the Google Chrome web browser. ' - 'This value is intended for testing the Flutter framework ' - 'itself and may be removed at any time.', - }, - ) - ..addOption( - 'test-randomize-ordering-seed', - help: - 'The seed to randomize the execution order of test cases within test files. ' - 'Must be a 32bit unsigned integer or the string "random", ' - 'which indicates that a seed should be selected randomly. ' - 'By default, tests run in the order they are declared.', - ) - ..addOption( - 'total-shards', - help: - 'Tests can be sharded with the "--total-shards" and "--shard-index" ' - 'arguments, allowing you to split up your test suites and run ' - 'them separately.', - ) - ..addOption( - 'shard-index', - help: - 'Tests can be sharded with the "--total-shards" and "--shard-index" ' - 'arguments, allowing you to split up your test suites and run ' - 'them separately.', - ) - ..addFlag( - 'enable-vmservice', - hide: !verboseHelp, - help: - 'Enables the VM service without "--start-paused". This flag is ' - 'intended for use with tests that will use "dart:developer" to ' - 'interact with the VM service at runtime.\n' - 'This flag is ignored if "--start-paused" or coverage are requested, as ' - 'the VM service will be enabled in those cases regardless.', - ) - ..addOption( - 'reporter', - abbr: 'r', - help: - 'Set how to print test results. If unset, value will default to either compact or expanded.', - allowed: ['compact', 'expanded', 'failures-only', 'github', 'json', 'silent'], - allowedHelp: { - 'compact': 'A single line, updated continuously (the default).', - 'expanded': - 'A separate line for each update. May be preferred when logging to a file or in continuous integration.', - 'failures-only': 'A separate line for failing tests, with no output for passing tests.', - 'github': - 'A custom reporter for GitHub Actions (the default reporter when running on GitHub Actions).', - 'json': 'A machine-readable format. See: https://dart.dev/go/test-docs/json_reporter.md', - 'silent': - 'A reporter with no output. May be useful when only the exit code is meaningful.', - }, - ) - ..addOption( - 'file-reporter', - help: - 'Enable an additional reporter writing test results to a file.\n' - 'Should be in the form :, ' - 'Example: "json:reports/tests.json".', - ) - ..addOption( - 'timeout', - help: - 'The default timeout for individual tests, specified either in ' - 'seconds (e.g. "60s"), as a multiplier of the default test timeout ' - '(e.g. "2x"), or as the string "none" to disable test timeouts ' - 'entirely. This value does not apply to the default test suite ' - 'loading timeout.', - ) - ..addFlag( - 'ignore-timeouts', - help: - 'Ignore all timeouts. Useful when testing a big application ' - 'that requires a longer time to compile (e.g. running integration ' - 'tests for a Flutter app).', - negatable: false, - ) - ..addFlag( - FlutterOptions.kWebWasmFlag, - help: 'Compile to WebAssembly rather than JavaScript.\n$kWasmMoreInfo', - negatable: false, - ) - ..addFlag( - 'cross-origin-isolation', - help: - 'Adds the Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy ' - 'headers to the web server. These headers are required for using APIs like ' - 'SharedArrayBuffer. This is on by default for the "skwasm" web renderer, ' - 'and this flag can be used to override the default. To disable this for the ' - 'skwasm renderer, use "--no-cross-origin-isolation".', - hide: !verboseHelp, - ) - ..addFlag( - 'uninstall', - defaultsTo: true, - help: - 'Whether to uninstall the app after running integration tests. ' - 'Set "--no-uninstall" to keep the app installed on the device.', - ); - - addDdsOptions(verboseHelp: verboseHelp); - usesFatalWarningsOption(verboseHelp: verboseHelp); + enableUsesPubOption(); + enableUsesFatalWarnings(); + registerOptionBundles(const [ + DdsAndDevToolsOptionsBundle(includeDevTools: false), + ]); + + argParser.addDescriptors(const >[ + CommonOptions.pub, + BuildInfoOptions.frontendServerStarterPath, + BuildInfoOptions.trackWidgetCreation, + CommonOptions.enableExperiment, + CommonOptions.dartDefines, + CommonOptions.dartDefineFromFile, + CommonOptions.deviceUser, + BuildInfoOptions.flavor, + DebuggingOptionDescriptors.enableImpeller, + CommonOptions.machine, + DebuggingOptionDescriptors.enableFlutterGpu, + DebuggingOptionDescriptors.enableHcpp, + WebOptions.deprecatedJsInterop, + CommonOptions.fatalWarnings, + _experimentalFasterTesting, + _name, + _plainName, + _tags, + _excludeTags, + _preset, + _startPaused, + _failFast, + _runSkipped, + DebuggingOptionDescriptors.disableServiceAuthCodes, + DebuggingOptionDescriptors.disableServiceOriginCheck, + _coverage, + _mergeCoverage, + _branchCoverage, + _ipv6, + _coveragePath, + _coveragePackage, + _updateGoldens, + _concurrency, + _testAssets, + _platform, + _testRandomizeOrderingSeed, + _totalShards, + _shardIndex, + _enableVmService, + _reporter, + _fileReporter, + _timeout, + _ignoreTimeouts, + _wasm, + WebOptions.crossOriginIsolation, + _uninstall, + ], verboseHelp: verboseHelp); } + static const _experimentalFasterTesting = FlagOptionDescriptor( + name: 'experimental-faster-testing', + negatable: false, + verboseOnly: true, + help: 'Run each test in a separate lightweight Flutter Engine to speed up testing.', + ); + + static const _name = MultiOptionDescriptor( + name: 'name', + help: 'A regular expression matching substrings of the names of tests to run.', + valueHelp: 'regexp', + splitCommas: false, + ); + + static const _plainName = MultiOptionDescriptor( + name: 'plain-name', + help: 'A plain-text substring of the names of tests to run.', + valueHelp: 'substring', + splitCommas: false, + ); + + static const _tags = MultiOptionDescriptor( + name: 'tags', + abbr: 't', + help: 'Run only tests associated with the specified tags. See: https://pub.dev/packages/test#tagging-tests', + splitCommas: false, + ); + + static const _excludeTags = MultiOptionDescriptor( + name: 'exclude-tags', + abbr: 'x', + help: 'Run only tests that do not have the specified tags. See: https://pub.dev/packages/test#tagging-tests', + splitCommas: false, + ); + + static const _preset = MultiOptionDescriptor( + name: 'preset', + abbr: 'P', + help: 'The configuration preset(s) to use. Presets are defined in "dart_test.yaml".', + splitCommas: false, + ); + + static const _startPaused = FlagOptionDescriptor( + name: 'start-paused', + negatable: false, + help: + 'Start in a paused mode and wait for a debugger to connect.\n' + 'You must specify a single test file to run, explicitly.\n' + 'Instructions for connecting with a debugger are printed to the ' + 'console once the test has started.', + ); + + static const _failFast = FlagOptionDescriptor( + name: 'fail-fast', + help: 'Stop running tests after the first failure.', + ); + + static const _runSkipped = FlagOptionDescriptor( + name: 'run-skipped', + help: 'Run skipped tests instead of skipping them.', + ); + + static const _coverage = FlagOptionDescriptor( + name: 'coverage', + negatable: false, + help: 'Whether to collect coverage information.', + ); + + static const _mergeCoverage = FlagOptionDescriptor( + name: 'merge-coverage', + negatable: false, + help: + 'Whether to merge coverage data with "coverage/lcov.base.info".\n' + 'Implies collecting coverage data. (Requires lcov.)', + ); + + static const _branchCoverage = FlagOptionDescriptor( + name: 'branch-coverage', + negatable: false, + help: + 'Whether to collect branch coverage information. ' + 'Implies collecting coverage data.', + ); + + static const _ipv6 = FlagOptionDescriptor( + name: 'ipv6', + negatable: false, + verboseOnly: true, + help: 'Whether to use IPv6 for the test harness server socket.', + ); + + static const _coveragePath = DefaultedStringOptionDescriptor( + name: 'coverage-path', + defaultsTo: 'coverage/lcov.info', + help: 'Where to store coverage information (if coverage is enabled).', + ); + + static const _coveragePackage = MultiOptionDescriptor( + name: 'coverage-package', + help: + 'A regular expression matching packages names ' + 'to include in the coverage report (if coverage is enabled). ' + 'If unset, matches the current package name.', + valueHelp: 'package-name-regexp', + splitCommas: false, + ); + + static const _updateGoldens = FlagOptionDescriptor( + name: 'update-goldens', + negatable: false, + help: + 'Whether "matchesGoldenFile()" calls within your test methods should ' // ignore: golden_test_tags + 'update the golden files rather than test for an existing match.', + ); + + static const _concurrency = IntOptionDescriptor( + name: 'concurrency', + abbr: 'j', + help: + 'The number of concurrent test processes to run. This will be ignored ' + 'when running integration tests.', + valueHelp: 'jobs', + ); + + static const _testAssets = FlagOptionDescriptor( + name: 'test-assets', + defaultsTo: true, + help: + 'Whether to build the assets bundle for testing. ' + 'This takes additional time before running the tests. ' + 'Consider using "--no-test-assets" if assets are not required.', + ); + + // --platform is not supported to be used by Flutter developers. It only + // exists to test the Flutter framework itself and may be removed entirely + // in the future. Developers should either use plain `flutter test`, or + // `package:integration_test` instead. + static const _platform = DefaultedStringOptionDescriptor( + name: 'platform', + allowed: ['tester', 'chrome'], + verboseOnly: true, + defaultsTo: 'tester', + help: 'Selects the test backend.', + allowedHelp: { + 'tester': 'Run tests using the VM-based test environment.', + 'chrome': + '(deprecated) Run tests using the Google Chrome web browser. ' + 'This value is intended for testing the Flutter framework ' + 'itself and may be removed at any time.', + }, + ); + + static const _testRandomizeOrderingSeed = StringOptionDescriptor( + name: 'test-randomize-ordering-seed', + help: + 'The seed to randomize the execution order of test cases within test files. ' + 'Must be a 32bit unsigned integer or the string "random", ' + 'which indicates that a seed should be selected randomly. ' + 'By default, tests run in the order they are declared.', + ); + + static const _totalShards = IntOptionDescriptor( + name: 'total-shards', + help: + 'Tests can be sharded with the "--total-shards" and "--shard-index" ' + 'arguments, allowing you to split up your test suites and run ' + 'them separately.', + ); + + static const _shardIndex = IntOptionDescriptor( + name: 'shard-index', + help: + 'Tests can be sharded with the "--total-shards" and "--shard-index" ' + 'arguments, allowing you to split up your test suites and run ' + 'them separately.', + ); + + static const _enableVmService = FlagOptionDescriptor( + name: 'enable-vmservice', + verboseOnly: true, + help: + 'Enables the VM service without "--start-paused". This flag is ' + 'intended for use with tests that will use "dart:developer" to ' + 'interact with the VM service at runtime.\n' + 'This flag is ignored if "--start-paused" or coverage are requested, as ' + 'the VM service will be enabled in those cases regardless.', + ); + + static const _reporter = StringOptionDescriptor( + name: 'reporter', + abbr: 'r', + help: 'Set how to print test results. If unset, value will default to either compact or expanded.', + allowed: ['compact', 'expanded', 'failures-only', 'github', 'json', 'silent'], + allowedHelp: { + 'compact': 'A single line, updated continuously (the default).', + 'expanded': 'A separate line for each update. May be preferred when logging to a file or in continuous integration.', + 'failures-only': 'A separate line for failing tests, with no output for passing tests.', + 'github': 'A custom reporter for GitHub Actions (the default reporter when running on GitHub Actions).', + 'json': 'A machine-readable format. See: https://dart.dev/go/test-docs/json_reporter.md', + 'silent': 'A reporter with no output. May be useful when only the exit code is meaningful.', + }, + ); + + static const _fileReporter = StringOptionDescriptor( + name: 'file-reporter', + help: + 'Enable an additional reporter writing test results to a file.\n' + 'Should be in the form :, ' + 'Example: "json:reports/tests.json".', + ); + + static const _timeout = StringOptionDescriptor( + name: 'timeout', + help: + 'The default timeout for individual tests, specified either in ' + 'seconds (e.g. "60s"), as a multiplier of the default test timeout ' + '(e.g. "2x"), or as the string "none" to disable test timeouts ' + 'entirely. This value does not apply to the default test suite ' + 'loading timeout.', + ); + + static const _ignoreTimeouts = FlagOptionDescriptor( + name: 'ignore-timeouts', + negatable: false, + help: + 'Ignore all timeouts. Useful when testing a big application ' + 'that requires a longer time to compile (e.g. running integration ' + 'tests for a Flutter app).', + ); + + static const _wasm = FlagOptionDescriptor( + name: FlutterOptions.kWebWasmFlag, + negatable: false, + help: 'Compile to WebAssembly rather than JavaScript.\n$kWasmMoreInfo', + ); + + static const _uninstall = FlagOptionDescriptor( + name: 'uninstall', + defaultsTo: true, + help: + 'Whether to uninstall the app after running integration tests. ' + 'Set "--no-uninstall" to keep the app installed on the device.', + ); + + final ToolContext _toolContext; + /// The interface for starting and configuring the tester. final TestWrapper testWrapper; /// Interface for running the tester process. - final FlutterTestRunner testRunner; + final FlutterTestRunner _testRunner; final TestCompilerNativeAssetsBuilder? nativeAssetsBuilder; @@ -335,8 +397,8 @@ class TestCommand extends FlutterCommand with DeviceBasedDevelopmentArtifacts { final _testFileUris = {}; - bool get isWeb => stringArg('platform') == 'chrome'; - bool get useWasm => boolArg(FlutterOptions.kWebWasmFlag); + bool get isWeb => getValue(_platform) == 'chrome'; + bool get useWasm => getValue(_wasm); @override Future> get requiredArtifacts async { @@ -361,15 +423,17 @@ class TestCommand extends FlutterCommand with DeviceBasedDevelopmentArtifacts { @override Future verifyThenRunCommand(String? commandPath) { + final ToolContext(:FileSystem fs, :Logger logger) = _toolContext; + final List testUris = argResults!.rest.map(_parseTestArgument).toList(); if (testUris.isEmpty) { // We don't scan the entire package, only the test/ subdirectory, so that // files with names like "hit_test.dart" don't get run. - final Directory testDir = globals.fs.directory('test'); + final Directory testDir = fs.directory('test'); if (!testDir.existsSync()) { throwToolExit('Test directory "${testDir.path}" not found.'); } - _testFileUris.addAll(_findTests(testDir).map(Uri.file)); + _testFileUris.addAll(_findTests(testDir, fs).map(Uri.file)); if (_testFileUris.isEmpty) { throwToolExit( 'Test directory "${testDir.path}" does not appear to contain any test files.\n' @@ -381,10 +445,10 @@ class TestCommand extends FlutterCommand with DeviceBasedDevelopmentArtifacts { // Test files may have query strings to support name/line/col: // flutter test test/foo.dart?name=a&line=1 String testPath = uri.replace(query: '').toFilePath(); - testPath = globals.fs.path.absolute(testPath); - testPath = globals.fs.path.normalize(testPath); - if (globals.fs.isDirectorySync(testPath)) { - _testFileUris.addAll(_findTests(globals.fs.directory(testPath)).map(Uri.file)); + testPath = fs.path.absolute(testPath); + testPath = fs.path.normalize(testPath); + if (fs.isDirectorySync(testPath)) { + _testFileUris.addAll(_findTests(fs.directory(testPath), fs).map(Uri.file)); } else { _testFileUris.add(Uri.file(testPath).replace(query: uri.query)); } @@ -397,11 +461,12 @@ class TestCommand extends FlutterCommand with DeviceBasedDevelopmentArtifacts { .map((Uri uri) => uri.replace(query: '').toFilePath()) .toList(); _isIntegrationTest = _shouldRunAsIntegrationTests( - globals.fs.currentDirectory.absolute.path, + fs.currentDirectory.absolute.path, testFilePaths, + fs, ); - globals.printTrace( + logger.printTrace( 'Found ${_testFileUris.length} files which will be executed as ' '${_isIntegrationTest ? 'Integration' : 'Widget'} Tests.', ); @@ -418,7 +483,14 @@ class TestCommand extends FlutterCommand with DeviceBasedDevelopmentArtifacts { @override Future runCommand() async { - if (!globals.fs.isFileSync('pubspec.yaml')) { + final ToolContext( + :FileSystem fs, + :Logger logger, + :FlutterProjectFactory projectFactory, + :Stdio stdio, + ) = _toolContext; + + if (!fs.isFileSync('pubspec.yaml')) { throwToolExit( 'Error: No pubspec.yaml file found in the current working directory.\n' 'Run this command from the root of your project. Test files must be ' @@ -426,21 +498,16 @@ class TestCommand extends FlutterCommand with DeviceBasedDevelopmentArtifacts { 'directory (or one of its subdirectories).', ); } - final FlutterProject flutterProject = FlutterProject.current(); - final bool buildTestAssets = boolArg('test-assets'); - final List names = stringsArg('name'); - final List plainNames = stringsArg('plain-name'); - final List tags = stringsArg('tags'); - final List excludeTags = stringsArg('exclude-tags'); + final FlutterProject flutterProject = projectFactory.fromDirectory(fs.currentDirectory); + final bool buildTestAssets = getValue(_testAssets); final BuildInfo buildInfo = await getBuildInfo( forcedBuildMode: BuildMode.debug, forcedUseLocalCanvasKit: true, - forcedWebEnableHotReload: true, ); TestTimeRecorder? testTimeRecorder; if (verbose) { - testTimeRecorder = TestTimeRecorder(globals.logger); + testTimeRecorder = TestTimeRecorder(logger); } if (buildInfo.packageConfig['test_api'] == null) { @@ -454,24 +521,24 @@ class TestCommand extends FlutterCommand with DeviceBasedDevelopmentArtifacts { ); } - bool experimentalFasterTesting = boolArg('experimental-faster-testing'); + bool experimentalFasterTesting = getValue(_experimentalFasterTesting); if (experimentalFasterTesting) { if (_isIntegrationTest || isWeb) { experimentalFasterTesting = false; - globals.printStatus( + logger.printStatus( '--experimental-faster-testing was parsed but will be ignored. This ' 'option is not supported when running integration tests or web tests.', ); } else if (_testFileUris.length == 1) { experimentalFasterTesting = false; - globals.printStatus( + logger.printStatus( '--experimental-faster-testing was parsed but will be ignored. This ' 'option should not be used when running a single test file.', ); } } - final bool startPaused = boolArg('start-paused'); + final bool startPaused = getValue(_startPaused); if (startPaused && _testFileUris.length != 1) { throwToolExit( 'When using --start-paused, you must specify a single test file to run.', @@ -482,28 +549,32 @@ class TestCommand extends FlutterCommand with DeviceBasedDevelopmentArtifacts { final debuggingOptions = DebuggingOptions.enabled( buildInfo, startPaused: startPaused, - disableServiceAuthCodes: boolArg('disable-service-auth-codes'), - disableServiceOriginCheck: boolArg('disable-service-origin-check'), + disableServiceAuthCodes: getValue(DebuggingOptionDescriptors.disableServiceAuthCodes), + disableServiceOriginCheck: getValue(DebuggingOptionDescriptors.disableServiceOriginCheck), // On iOS >=14, keeping this enabled will leave a prompt on the screen. disablePortPublication: true, enableDds: enableDds, usingCISystem: usingCISystem, - enableImpeller: ImpellerStatus.fromBool(argResults!['enable-impeller'] as bool?), - enableFlutterGpu: (argResults!['enable-flutter-gpu'] as bool?) ?? false, + enableImpeller: ImpellerStatus.fromBool(getValue(DebuggingOptionDescriptors.enableImpeller)), + enableFlutterGpu: getValue(DebuggingOptionDescriptors.enableFlutterGpu) ?? false, debugLogsDirectoryPath: debugLogsDirectoryPath, - webRenderer: webRenderer, - webCrossOriginIsolation: argResults!.wasParsed('cross-origin-isolation') - ? boolArg('cross-origin-isolation') - : null, - printDtd: boolArg(FlutterGlobalOptions.kPrintDtd, global: true), - webUseWasm: useWasm, - enableHcpp: boolArg('enable-hcpp'), - uninstallApp: boolArg('uninstall'), + webConfig: WebDebugConfig( + renderer: webRenderer, + crossOriginIsolation: getValue(WebOptions.crossOriginIsolation), + useWasm: useWasm, + ), + printDtd: getValue(CommonOptions.printDtd), + enableHcpp: explicitEnableHcpp, + uninstallApp: getValue(_uninstall), ); - final Uri? nativeAssetsJson = _isIntegrationTest - ? null // Don't build for host when running integration tests. - : await nativeAssetsBuilder?.build(buildInfo); + final (:Uri? nativeAssetsManifest, :FlutterHookResult? flutterHookResult) = _isIntegrationTest + ? ( + nativeAssetsManifest: null, + flutterHookResult: null, + ) // Don't build for host when running integration tests. + : (await nativeAssetsBuilder?.buildWithHookResult(buildInfo) ?? + (nativeAssetsManifest: null, flutterHookResult: null)); String? testAssetPath; if (buildTestAssets) { await _buildTestAsset( @@ -511,26 +582,24 @@ class TestCommand extends FlutterCommand with DeviceBasedDevelopmentArtifacts { impellerStatus: debuggingOptions.enableImpeller, buildMode: debuggingOptions.buildInfo.mode, packageConfigPath: buildInfo.packageConfigPath, + flutterHookResult: flutterHookResult, ); } - if (buildTestAssets || nativeAssetsJson != null) { - testAssetPath = globals.fs.path.join( - flutterProject.directory.path, - 'build', - 'unit_test_assets', - ); + if (buildTestAssets || nativeAssetsManifest != null) { + testAssetPath = fs.path.join(flutterProject.directory.path, 'build', 'unit_test_assets'); } - if (nativeAssetsJson != null) { - final Directory testAssetDirectory = globals.fs.directory(testAssetPath); + if (nativeAssetsManifest != null) { + final Directory testAssetDirectory = fs.directory(testAssetPath); if (!testAssetDirectory.existsSync()) { await testAssetDirectory.create(recursive: true); } - final File nativeAssetsManifest = testAssetDirectory.childFile('NativeAssetsManifest.json'); - await globals.fs.file(nativeAssetsJson).copy(nativeAssetsManifest.path); + final File copiedNativeAssetsManifest = testAssetDirectory.childFile( + 'NativeAssetsManifest.json', + ); + await fs.file(nativeAssetsManifest).copy(copiedNativeAssetsManifest.path); } - final String? concurrencyString = stringArg('concurrency'); - int? jobs = concurrencyString == null ? null : int.tryParse(concurrencyString); + int? jobs = getValue(_concurrency); if (jobs != null && (jobs <= 0 || !jobs.isFinite)) { throwToolExit( 'Could not parse -j/--concurrency argument. It must be an integer greater than zero.', @@ -538,8 +607,8 @@ class TestCommand extends FlutterCommand with DeviceBasedDevelopmentArtifacts { } if (_isIntegrationTest || isWeb) { - if (argResults!.wasParsed('concurrency')) { - globals.printStatus( + if (wasParsed(_concurrency)) { + logger.printStatus( '-j/--concurrency was parsed but will be ignored, this option is not ' 'supported when running Integration Tests or web tests.', ); @@ -548,22 +617,22 @@ class TestCommand extends FlutterCommand with DeviceBasedDevelopmentArtifacts { // on the connected device concurrently, which is not supported. jobs = 1; } else if (experimentalFasterTesting) { - if (argResults!.wasParsed('concurrency')) { - globals.printStatus( + if (wasParsed(_concurrency)) { + logger.printStatus( '-j/--concurrency was parsed but will be ignored. This option is not ' 'compatible with --experimental-faster-testing.', ); } } - final int? shardIndex = int.tryParse(stringArg('shard-index') ?? ''); + final int? shardIndex = getValue(_shardIndex); if (shardIndex != null && (shardIndex < 0 || !shardIndex.isFinite)) { throwToolExit( 'Could not parse --shard-index=$shardIndex argument. It must be an integer greater than -1.', ); } - final int? totalShards = int.tryParse(stringArg('total-shards') ?? ''); + final int? totalShards = getValue(_totalShards); if (totalShards != null && (totalShards <= 0 || !totalShards.isFinite)) { throwToolExit( 'Could not parse --total-shards=$totalShards argument. It must be an integer greater than zero.', @@ -577,45 +646,46 @@ class TestCommand extends FlutterCommand with DeviceBasedDevelopmentArtifacts { throwToolExit('If you set --shard-index you need to also set --total-shards.'); } - final bool enableVmService = boolArg('enable-vmservice'); + final bool enableVmService = getValue(_enableVmService); if (experimentalFasterTesting && enableVmService) { - globals.printStatus( + logger.printStatus( '--enable-vmservice was parsed but will be ignored. This option is not ' 'compatible with --experimental-faster-testing.', ); } - final bool ipv6 = boolArg('ipv6'); + final bool ipv6 = getValue(_ipv6); if (experimentalFasterTesting && ipv6) { // [ipv6] is set when the user desires for the test harness server to use // IPv6, but a test harness server will not be started at all when // [experimentalFasterTesting] is set. - globals.printStatus( + logger.printStatus( '--ipv6 was parsed but will be ignored. This option is not compatible ' 'with --experimental-faster-testing.', ); } CoverageCollector? collector; - if (boolArg('coverage') || boolArg('merge-coverage') || boolArg('branch-coverage')) { + if (getValue(_coverage) || getValue(_mergeCoverage) || getValue(_branchCoverage)) { final Set packagesToInclude = _getCoveragePackages( - stringsArg('coverage-package'), + getValue(_coveragePackage), flutterProject, buildInfo.packageConfig, ); collector = CoverageCollector( - verbose: !outputMachineFormat, + branchCoverage: getValue(_branchCoverage), libraryNames: packagesToInclude, packagesPath: buildInfo.packageConfigPath, resolver: await CoverageCollector.getResolver(buildInfo.packageConfigPath), testTimeRecorder: testTimeRecorder, - branchCoverage: boolArg('branch-coverage'), + toolContext: _toolContext, + verbose: !outputMachineFormat, ); } TestWatcher? watcher; if (outputMachineFormat) { - watcher = EventPrinter(parent: collector, out: globals.stdio.stdout); + watcher = EventPrinter(parent: collector, out: stdio.stdout); } else if (collector != null) { watcher = collector; } @@ -658,73 +728,58 @@ class TestCommand extends FlutterCommand with DeviceBasedDevelopmentArtifacts { ); } - if (stringArg('flavor') != null && !integrationTestDevice.supportsFlavors) { + if (getValue(BuildInfoOptions.flavor) != null && !integrationTestDevice.supportsFlavors) { throwToolExit('--flavor is only supported for Android, Linux, macOS, and iOS devices.'); } } + final filterSpec = TestFilterSpecification( + names: getValue(_name), + plainNames: getValue(_plainName), + tags: getValue(_tags), + excludeTags: getValue(_excludeTags), + presets: getValue(_preset), + runSkipped: getValue(_runSkipped), + shardIndex: shardIndex, + totalShards: totalShards, + ); + + final executionSpec = TestExecutionSpecification( + debuggingOptions: debuggingOptions, + buildInfo: buildInfo, + filter: filterSpec, + watcher: watcher, + enableVmService: collector != null || startPaused || enableVmService, + machine: outputMachineFormat, + updateGoldens: getValue(_updateGoldens), + concurrency: jobs, + testAssetDirectory: testAssetPath, + flutterProject: flutterProject, + web: isWeb, + randomSeed: getValue(_testRandomizeOrderingSeed), + reporter: getValue(_reporter), + fileReporter: getValue(_fileReporter), + timeout: getValue(_timeout), + ignoreTimeouts: getValue(_ignoreTimeouts), + failFast: getValue(_failFast), + integrationTestDevice: integrationTestDevice, + integrationTestUserIdentifier: getValue(CommonOptions.deviceUser), + testTimeRecorder: testTimeRecorder, + nativeAssetsBuilder: nativeAssetsBuilder, + ); + final Stopwatch? testRunnerTimeRecorderStopwatch = testTimeRecorder?.start( TestTimePhases.TestRunner, ); final int result; if (experimentalFasterTesting) { assert(!isWeb && !_isIntegrationTest && _testFileUris.length > 1); - result = await testRunner.runTestsBySpawningLightweightEngines( + result = await _testRunner.runTestsBySpawningLightweightEngines( _testFileUris.toList(), - debuggingOptions: debuggingOptions, - names: names, - plainNames: plainNames, - tags: tags, - excludeTags: excludeTags, - machine: outputMachineFormat, - updateGoldens: boolArg('update-goldens'), - concurrency: jobs, - testAssetDirectory: testAssetPath, - flutterProject: flutterProject, - randomSeed: stringArg('test-randomize-ordering-seed'), - reporter: stringArg('reporter'), - fileReporter: stringArg('file-reporter'), - timeout: stringArg('timeout'), - ignoreTimeouts: boolArg('ignore-timeouts'), - failFast: boolArg('fail-fast'), - runSkipped: boolArg('run-skipped'), - shardIndex: shardIndex, - totalShards: totalShards, - testTimeRecorder: testTimeRecorder, - nativeAssetsBuilder: nativeAssetsBuilder, + executionSpec, ); } else { - result = await testRunner.runTests( - testWrapper, - _testFileUris.toList(), - debuggingOptions: debuggingOptions, - names: names, - plainNames: plainNames, - tags: tags, - excludeTags: excludeTags, - watcher: watcher, - enableVmService: collector != null || startPaused || enableVmService, - machine: outputMachineFormat, - updateGoldens: boolArg('update-goldens'), - concurrency: jobs, - testAssetDirectory: testAssetPath, - flutterProject: flutterProject, - web: isWeb, - randomSeed: stringArg('test-randomize-ordering-seed'), - reporter: stringArg('reporter'), - fileReporter: stringArg('file-reporter'), - timeout: stringArg('timeout'), - ignoreTimeouts: boolArg('ignore-timeouts'), - failFast: boolArg('fail-fast'), - runSkipped: boolArg('run-skipped'), - shardIndex: shardIndex, - totalShards: totalShards, - integrationTestDevice: integrationTestDevice, - integrationTestUserIdentifier: stringArg(FlutterOptions.kDeviceUser), - testTimeRecorder: testTimeRecorder, - nativeAssetsBuilder: nativeAssetsBuilder, - buildInfo: buildInfo, - ); + result = await _testRunner.runTests(testWrapper, _testFileUris.toList(), executionSpec); } testTimeRecorder?.stop(TestTimePhases.TestRunner, testRunnerTimeRecorderStopwatch!); @@ -733,8 +788,8 @@ class TestCommand extends FlutterCommand with DeviceBasedDevelopmentArtifacts { TestTimePhases.CoverageDataCollect, ); final bool collectionResult = await collector.collectCoverageData( - stringArg('coverage-path'), - mergeCoverageData: boolArg('merge-coverage'), + getValue(_coveragePath), + mergeCoverageData: getValue(_mergeCoverage), ); testTimeRecorder?.stop(TestTimePhases.CoverageDataCollect, collectTimeRecorderStopwatch!); if (!collectionResult) { @@ -781,16 +836,27 @@ class TestCommand extends FlutterCommand with DeviceBasedDevelopmentArtifacts { /// Parses a test file/directory target passed as an argument and returns it /// as an absolute `file:///` [Uri] with optional querystring for name/line/col. Uri _parseTestArgument(String arg) { + final ToolContext(:FileSystem fs, :Platform platform) = _toolContext; + // We can't parse Windows paths as URIs if they have query strings, so // parse the file and query parts separately. final int queryStart = arg.indexOf('?'); String filePart = queryStart == -1 ? arg : arg.substring(0, queryStart); final String queryPart = queryStart == -1 ? '' : arg.substring(queryStart + 1); - filePart = globals.fs.path.absolute(filePart); - filePart = globals.fs.path.normalize(filePart); + filePart = fs.path.absolute(filePart); + filePart = fs.path.normalize(filePart); - return Uri.file(filePart).replace(query: queryPart.isEmpty ? null : queryPart); + try { + return Uri.file( + filePart, + windows: platform.isWindows, + ).replace(query: queryPart.isEmpty ? null : queryPart); + } on ArgumentError catch (e) { + throwToolExit('Invalid test path "$arg": ${e.message}'); + } on FormatException catch (e) { + throwToolExit('Invalid test path "$arg": ${e.message}'); + } } Future _buildTestAsset({ @@ -798,9 +864,19 @@ class TestCommand extends FlutterCommand with DeviceBasedDevelopmentArtifacts { required ImpellerStatus impellerStatus, required BuildMode buildMode, required String packageConfigPath, + FlutterHookResult? flutterHookResult, }) async { + final ToolContext( + :Artifacts artifacts, + :Config config, + :FileSystem fs, + :Logger logger, + :Platform platform, + :ProcessManager processManager, + ) = _toolContext; final AssetBundle assetBundle = AssetBundleFactory.instance.createBundle(); final int build = await assetBundle.build( + flutterHookResult: flutterHookResult, packageConfigPath: packageConfigPath, flavor: flavor, includeAssetsFromDevDependencies: true, @@ -809,22 +885,23 @@ class TestCommand extends FlutterCommand with DeviceBasedDevelopmentArtifacts { if (build != 0) { throwToolExit('Error: Failed to build asset bundle'); } - if (_needsRebuild(assetBundle.entries, flavor)) { + if (_needsRebuild(assetBundle.entries, flavor, flutterHookResult)) { await writeBundle( - globals.fs.directory(globals.fs.path.join('build', 'unit_test_assets')), + fs.directory(fs.path.join(getBuildDirectory(config, fs), 'unit_test_assets')), assetBundle.entries, targetPlatform: TargetPlatform.tester, impellerStatus: impellerStatus, - processManager: globals.processManager, - fileSystem: globals.fs, - artifacts: globals.artifacts!, - logger: globals.logger, - projectDir: globals.fs.currentDirectory, + processManager: processManager, + fileSystem: fs, + artifacts: artifacts, + logger: logger, + projectDir: fs.currentDirectory, buildMode: buildMode, + platform: platform, ); - final File cachedFlavorFile = globals.fs.file( - globals.fs.path.join('build', 'test_cache', 'flavor.txt'), + final File cachedFlavorFile = fs.file( + fs.path.join(getBuildDirectory(config, fs), 'test_cache', 'flavor.txt'), ); if (cachedFlavorFile.existsSync()) { await cachedFlavorFile.delete(); @@ -836,34 +913,51 @@ class TestCommand extends FlutterCommand with DeviceBasedDevelopmentArtifacts { } } - bool _needsRebuild(Map entries, String? flavor) { + bool _needsRebuild( + Map entries, + String? flavor, + FlutterHookResult? flutterHookResult, + ) { // TODO(andrewkolos): This logic might fail in the future if we change the // schema of the contents of the asset manifest file and the user does not // perform a `flutter clean` after upgrading. // See https://github.com/flutter/flutter/issues/128563. - final File manifest = globals.fs.file( - globals.fs.path.join('build', 'unit_test_assets', 'AssetManifest.bin'), + final ToolContext(:Config config, :FileSystem fs) = _toolContext; + final String unitTestAssetsDir = fs.path.join( + getBuildDirectory(config, fs), + 'unit_test_assets', ); + final File manifest = fs.file(fs.path.join(unitTestAssetsDir, 'AssetManifest.bin')); if (!manifest.existsSync()) { return true; } final DateTime lastModified = manifest.lastModifiedSync(); - final File pub = globals.fs.file('pubspec.yaml'); - if (pub.lastModifiedSync().isAfter(lastModified)) { + final File pub = fs.file('pubspec.yaml'); + if (pub.existsSync() && pub.lastModifiedSync().isAfter(lastModified)) { return true; } - final Iterable files = entries.values - .map((AssetBundleEntry asset) => asset.content) - .whereType(); - for (final entry in files) { - if (entry.isModifiedAfter(lastModified)) { + for (final MapEntry entry in entries.entries) { + final DevFSContent content = entry.value.content; + if (content is DevFSFileContent) { + if (!fs.file(fs.path.join(unitTestAssetsDir, entry.key)).existsSync()) { + return true; + } + if (content.isModifiedAfter(lastModified)) { + return true; + } + } + } + + for (final Uri dependency in flutterHookResult?.dependencies ?? const []) { + final FileStat stat = fs.statSync(dependency.toFilePath()); + if (stat.type == FileSystemEntityType.notFound || stat.modified.isAfter(lastModified)) { return true; } } - final File cachedFlavorFile = globals.fs.file( - globals.fs.path.join('build', 'test_cache', 'flavor.txt'), + final File cachedFlavorFile = fs.file( + fs.path.join(getBuildDirectory(config, fs), 'test_cache', 'flavor.txt'), ); final String? cachedFlavor = cachedFlavorFile.existsSync() ? cachedFlavorFile.readAsStringSync() @@ -878,14 +972,14 @@ class TestCommand extends FlutterCommand with DeviceBasedDevelopmentArtifacts { /// Searches [directory] and returns files that end with `_test.dart` as /// absolute paths. -Iterable _findTests(Directory directory) { +Iterable _findTests(Directory directory, FileSystem fs) { return directory .listSync(recursive: true, followLinks: false) .where( (FileSystemEntity entity) => - entity.path.endsWith('_test.dart') && globals.fs.isFileSync(entity.path), + entity.path.endsWith('_test.dart') && fs.isFileSync(entity.path), ) - .map((FileSystemEntity entity) => globals.fs.path.absolute(entity.path)); + .map((FileSystemEntity entity) => fs.path.absolute(entity.path)); } /// Returns true if there are files that are Integration Tests. @@ -895,8 +989,8 @@ Iterable _findTests(Directory directory) { /// /// Throws an exception if there are both Integration Tests and Widget Tests /// found in [testFiles]. -bool _shouldRunAsIntegrationTests(String currentDirectory, List testFiles) { - final String integrationTestDirectory = globals.fs.path.join( +bool _shouldRunAsIntegrationTests(String currentDirectory, List testFiles, FileSystem fs) { + final String integrationTestDirectory = fs.path.join( currentDirectory, _kIntegrationTestDirectory, ); diff --git a/packages/flutter_tools/lib/src/commands/update_packages.dart b/packages/flutter_tools/lib/src/commands/update_packages.dart index b56b4f80..64844ada 100644 --- a/packages/flutter_tools/lib/src/commands/update_packages.dart +++ b/packages/flutter_tools/lib/src/commands/update_packages.dart @@ -5,18 +5,20 @@ import 'dart:async'; import 'dart:collection'; +import 'package:meta/meta.dart'; import 'package:pub_semver/pub_semver.dart'; import 'package:pubspec_parse/pubspec_parse.dart'; import 'package:yaml/yaml.dart'; import 'package:yaml_edit/yaml_edit.dart'; import '../base/common.dart'; -import '../base/context.dart'; import '../base/file_system.dart'; +import '../base/logger.dart'; import '../base/net.dart'; +import '../base/platform.dart'; import '../cache.dart'; +import '../context/tool_context.dart'; import '../dart/pub.dart'; -import '../globals.dart' as globals; import '../project.dart'; import '../runner/flutter_command.dart'; import '../update_packages_pins.dart'; @@ -30,8 +32,14 @@ const _pubspecName = 'pubspec.yaml'; typedef _ProjectDeps = ({FlutterProject project, ResolvedDependencies deps}); +/// A command to update internal dependencies across Flutter packages. class UpdatePackagesCommand extends FlutterCommand { - UpdatePackagesCommand({required bool verboseHelp}) { + UpdatePackagesCommand({ + required ToolContext super.toolContext, + required super.verboseHelp, + @visibleForTesting this._httpClientFactory, + @visibleForTesting Pub? pub, + }) : _injectedPub = pub { argParser ..addFlag( _keyForceUpgrade, @@ -49,8 +57,7 @@ class UpdatePackagesCommand extends FlutterCommand { ) ..addMultiOption( _keyCherryPick, - help: - 'Attempt to update only the specified package. To be specified as [pub package name]:[pub package version],[pub package2 name]:[pub package2 version].', + help: 'Attempt to update only the specified package. To be specified as [pub package name]:[pub package version],[pub package2 name]:[pub package2 version].', ) ..addFlag( _keyOffline, @@ -73,6 +80,12 @@ class UpdatePackagesCommand extends FlutterCommand { ); } + final HttpClientFactory? _httpClientFactory; + final Pub? _injectedPub; + + @override + ToolContext get toolContext => super.toolContext!; + final _keyForceUpgrade = 'force-upgrade'; final _keyUpdateHashes = 'update-hashes'; final _keyCherryPick = 'cherry-pick'; @@ -99,40 +112,47 @@ class UpdatePackagesCommand extends FlutterCommand { @override final hidden = true; - // Lazy-initialize the net utilities with values from the context. + late final Pub _pub = + _injectedPub ?? + Pub( + botDetector: toolContext.botDetector, + fileSystem: toolContext.fs, + logger: toolContext.logger, + platform: toolContext.platform, + processManager: toolContext.processManager, + ); + late final _net = Net( - httpClientFactory: context.get(), - logger: globals.logger, - platform: globals.platform, + httpClientFactory: _httpClientFactory, + logger: toolContext.logger, + platform: toolContext.platform, ); Future _downloadCoverageData() async { + final ToolContext(:FileSystem fs, :Platform platform) = toolContext; final String urlBase = - globals.platform.environment[kFlutterStorageBaseUrl] ?? 'https://storage.googleapis.com'; + platform.environment[kFlutterStorageBaseUrl] ?? 'https://storage.googleapis.com'; final Uri coverageUri = Uri.parse('$urlBase/flutter_infra_release/flutter/coverage/lcov.info'); final List? data = await _net.fetchUrl(coverageUri, maxAttempts: 3); if (data == null) { throwToolExit('Failed to fetch coverage data from $coverageUri'); } - final String coverageDir = globals.fs.path.join( - Cache.flutterRoot!, - 'packages/flutter/coverage', - ); - globals.fs.file(globals.fs.path.join(coverageDir, 'lcov.base.info')) + final String coverageDir = fs.path.join(Cache.flutterRoot!, 'packages/flutter/coverage'); + fs.file(fs.path.join(coverageDir, 'lcov.base.info')) ..createSync(recursive: true) ..writeAsBytesSync(data, flush: true); - globals.fs.file(globals.fs.path.join(coverageDir, 'lcov.info')) + fs.file(fs.path.join(coverageDir, 'lcov.info')) ..createSync(recursive: true) ..writeAsBytesSync(data, flush: true); } @override Future runCommand() async { + final ToolContext(:FileSystem fs, :Logger logger, :FlutterProjectFactory projectFactory) = + toolContext; // Add the root directory to the list of packages, to capture the workspace // `pubspec.yaml`. - final Directory rootDirectory = globals.fs.directory( - globals.fs.path.absolute(Cache.flutterRoot!), - ); + final Directory rootDirectory = fs.directory(fs.path.absolute(Cache.flutterRoot!)); final bool forceUpgrade = boolArg(_keyForceUpgrade); final bool updateHashes = boolArg(_keyUpdateHashes); @@ -165,10 +185,10 @@ class UpdatePackagesCommand extends FlutterCommand { // pubspec.yamls in the repo (including via transitive dependencies), and // find the latest version of each that can be used while keeping each // such package fixed at a single version across all the pubspec.yamls. - globals.printStatus('Upgrading packages...'); + logger.printStatus('Upgrading packages...'); } - final FlutterProject rootProject = FlutterProject.fromDirectory(rootDirectory); - final FlutterProject toolProject = FlutterProject.fromDirectory( + final FlutterProject rootProject = projectFactory.fromDirectory(rootDirectory); + final FlutterProject toolProject = projectFactory.fromDirectory( rootDirectory.childDirectory('packages').childDirectory('flutter_tools'), ); @@ -214,10 +234,21 @@ class UpdatePackagesCommand extends FlutterCommand { ); for (final (:project, :deps) in toolDeps) { _updatePubspec(project.directory, deps); + // When flutter_tools contains sub-packages in its workspace (under packages/), + // also update their pubspecs with the resolved dependencies so that shared + // package constraints (e.g. meta) remain in sync across the workspace. + final Directory subpackagesDir = project.directory.childDirectory('packages'); + if (subpackagesDir.existsSync()) { + for (final FileSystemEntity entity in subpackagesDir.listSync()) { + if (entity is Directory && entity.childFile(_pubspecName).existsSync()) { + _updatePubspec(entity, deps); + } + } + } } } } - globals.printStatus('Running pub get only...'); + logger.printStatus('Running pub get only...'); if (updateHashes || forceUpgrade || cherryPicks.isNotEmpty) { _writeHashesToPubspecs(packages); } @@ -231,7 +262,7 @@ class UpdatePackagesCommand extends FlutterCommand { // Manually do a pub get for packages not part of the workspace. // See https://github.com/flutter/flutter/pull/170364. await _pubGet(toolProject, false); - await _pubGet(FlutterProject.fromDirectory(hooksUserDefineIntegrationTestDirectory), false); + await _pubGet(projectFactory.fromDirectory(hooksUserDefineIntegrationTestDirectory), false); await _downloadCoverageData(); @@ -239,7 +270,7 @@ class UpdatePackagesCommand extends FlutterCommand { } Future _pubGet(FlutterProject project, bool enforceLockfile) async => - pub.get(context: PubContext.pubGet, project: project, enforceLockfile: enforceLockfile); + _pub.get(context: PubContext.pubGet, project: project, enforceLockfile: enforceLockfile); Future> _upgrade({ required bool forceUpgrade, @@ -248,12 +279,14 @@ class UpdatePackagesCommand extends FlutterCommand { required List projects, List? pinned, }) async { + final ToolContext(:FileSystem fs, :Logger logger, :FlutterProjectFactory projectFactory) = + toolContext; final Map pinnedDeps; if (forceUpgrade) { - globals.printStatus('Upgrading packages versions...'); + logger.printStatus('Upgrading packages versions...'); pinnedDeps = {...kManuallyPinnedDependencies}; } else if (cherryPicks.isNotEmpty) { - globals.printStatus('Pinning packages "$cherryPicks"...'); + logger.printStatus('Pinning packages "$cherryPicks"...'); pinnedDeps = { for (final PackageVersion cherryPick in cherryPicks) cherryPick.package: cherryPick.version, }; @@ -265,22 +298,41 @@ class UpdatePackagesCommand extends FlutterCommand { for (final (:package, :version) in pinned ?? []) package: version, }); - final Directory tempDir = globals.fs.systemTempDirectory.createTempSync( - 'flutter_upgrade_packages.', - ); + final Directory tempDir = fs.systemTempDirectory.createTempSync('flutter_upgrade_packages.'); final deps = <_ProjectDeps>[]; for (final project in projects) { final Directory projectTempDir = tempDir.childDirectory( - globals.fs.path.relative(project.directory.path, from: Cache.flutterRoot), + fs.path.relative(project.directory.path, from: Cache.flutterRoot), ); final File tempPubspec = projectTempDir.childFile(project.pubspecFile.basename) ..createSync(recursive: true); - globals.printStatus('Writing to temp pubspec at $tempPubspec'); + logger.printStatus('Writing to temp pubspec at $tempPubspec'); final String pubspecContents = project.pubspecFile.readAsStringSync(); final yamlEditor = YamlEditor(pubspecContents); final ResolvedDependencies oldDeps = _fetchDeps(yamlEditor); final workspacePath = ['workspace']; - if (yamlEditor.parseAt(workspacePath, orElse: () => wrapAsYamlNode(null)).value != null) { + final YamlNode workspaceNode = yamlEditor.parseAt( + workspacePath, + orElse: () => wrapAsYamlNode(null), + ); + final workspaceMembers = {}; + if (workspaceNode is YamlList) { + for (final Object? member in workspaceNode) { + if (member is String) { + String memberName = fs.path.basename(member); + final File memberPubspec = fs.file( + fs.path.join(project.directory.path, member, _pubspecName), + ); + if (memberPubspec.existsSync()) { + try { + memberName = Pubspec.parse(memberPubspec.readAsStringSync()).name; + } on Exception { + // Fall back to basename if parsing fails. + } + } + workspaceMembers.add(memberName); + } + } yamlEditor.remove(workspacePath); } final RelaxMode relaxMode = switch (cherryPicks.isNotEmpty) { @@ -288,12 +340,13 @@ class UpdatePackagesCommand extends FlutterCommand { false => relaxToAny ? RelaxMode.any : RelaxMode.caret, }; _relaxDeps(yamlEditor, relaxMode, pinnedDeps); + _removePathAndWorkspaceDependencies(yamlEditor, project.directory, workspaceMembers); tempPubspec.writeAsStringSync(yamlEditor.toString()); - globals.printStatus('Upgrade in $projectTempDir (for project: ${project.manifest.appName})'); - await pub.interactively( + logger.printStatus('Upgrade in $projectTempDir (for project: ${project.manifest.appName})'); + await _pub.interactively( ['upgrade', '--tighten', '-C', projectTempDir.path], context: PubContext.updatePackages, - project: FlutterProject.fromDirectory(projectTempDir), + project: projectFactory.fromDirectory(projectTempDir), command: 'update', ); @@ -309,6 +362,53 @@ class UpdatePackagesCommand extends FlutterCommand { return deps; } + /// Removes local path dependencies and workspace member dependencies from the + /// temporary pubspec before running `pub upgrade --tighten`. + /// + /// `_upgrade` runs `dart pub upgrade` inside an isolated temporary directory + /// where only the project's root `pubspec.yaml` is written. Workspace members + /// and local subpackages (e.g. `flutter_tools_core`) do not exist in the + /// temporary directory. Removing them from the temporary pubspec prevents + /// version solving failures caused by missing local paths or attempting to + /// resolve unreleased workspace subpackages from pub.dev. The actual path + /// dependencies and workspace definitions in the repository are preserved and + /// not affected by this removal. + void _removePathAndWorkspaceDependencies( + YamlEditor yamlEditor, + Directory projectDirectory, + Set workspaceMembers, + ) { + final Directory subpackagesDir = projectDirectory.childDirectory('packages'); + for (final dependencyType in [ + 'dependencies', + 'dev_dependencies', + 'dependency_overrides', + ]) { + final YamlNode node = yamlEditor.parseAt([ + dependencyType, + ], orElse: () => wrapAsYamlNode(null)); + if (node is! YamlMap) { + continue; + } + final toRemove = []; + for (final MapEntry dep in node.entries) { + final Object? value = dep.value; + final packageName = dep.key! as String; + final bool isPathDependency = value is Map && value.containsKey('path'); + final bool isWorkspaceMember = + workspaceMembers.contains(packageName) || + (subpackagesDir.existsSync() && + subpackagesDir.childDirectory(packageName).childFile(_pubspecName).existsSync()); + if (isPathDependency || (value == null && isWorkspaceMember)) { + toRemove.add(packageName); + } + } + for (final packageName in toRemove) { + yamlEditor.remove([dependencyType, packageName]); + } + } + } + void _relaxDeps(YamlEditor yamlEditor, RelaxMode relaxMode, Map fixedDeps) { ResolvedDependencies().forEach( yamlEditor: yamlEditor, @@ -361,9 +461,10 @@ class UpdatePackagesCommand extends FlutterCommand { } void _verifyPubspecs(List packages) { - globals.printStatus('Verifying pubspecs...'); + final Logger logger = toolContext.logger; + logger.printStatus('Verifying pubspecs...'); for (final directory in packages) { - globals.printTrace('Reading pubspec.yaml from ${directory.path}'); + logger.printTrace('Reading pubspec.yaml from ${directory.path}'); final String pubspecString = directory.childFile(_pubspecName).readAsStringSync(); _checkHash(pubspecString, directory); } @@ -431,9 +532,10 @@ class UpdatePackagesCommand extends FlutterCommand { } void _writeHashesToPubspecs(List packages) { - globals.printStatus('Writing hashes to pubspecs...'); + final Logger logger = toolContext.logger; + logger.printStatus('Writing hashes to pubspecs...'); for (final directory in packages) { - globals.printTrace('Reading pubspec.yaml from ${directory.path}'); + logger.printTrace('Reading pubspec.yaml from ${directory.path}'); final File pubspecFile = directory.childFile(_pubspecName); String pubspec = pubspecFile.readAsStringSync(); final String actualChecksum = _computeChecksum(pubspec); @@ -449,7 +551,7 @@ class UpdatePackagesCommand extends FlutterCommand { } pubspecFile.writeAsStringSync(pubspec); } - globals.printStatus('All pubspecs are now up to date.'); + logger.printStatus('All pubspecs are now up to date.'); } String _computeChecksum(String pubspecString) { diff --git a/packages/flutter_tools/lib/src/commands/upgrade.dart b/packages/flutter_tools/lib/src/commands/upgrade.dart index 86581367..e3ef35b5 100644 --- a/packages/flutter_tools/lib/src/commands/upgrade.dart +++ b/packages/flutter_tools/lib/src/commands/upgrade.dart @@ -5,16 +5,18 @@ import 'package:meta/meta.dart'; import '../base/common.dart'; +import '../base/file_system.dart'; import '../base/io.dart'; +import '../base/logger.dart'; import '../base/os.dart'; +import '../base/platform.dart'; import '../base/process.dart'; -import '../base/time.dart'; import '../base/utils.dart'; import '../cache.dart'; +import '../context/tool_context.dart'; import '../dart/pub.dart'; -import '../globals.dart' as globals; +import '../git.dart'; import '../persistent_tool_state.dart'; -import '../project.dart'; import '../runner/flutter_command.dart'; import '../version.dart'; import 'channel.dart'; @@ -23,56 +25,63 @@ import 'channel.dart'; const _flutterInstallDocs = 'https://flutter.dev/setup'; class UpgradeCommand extends FlutterCommand { - UpgradeCommand({required bool verboseHelp, UpgradeCommandRunner? commandRunner}) - : _commandRunner = commandRunner ?? UpgradeCommandRunner() { + UpgradeCommand({ + required ToolContext toolContext, + required bool verboseHelp, + @visibleForTesting UpgradeCommandRunner? commandRunner, + }) : _toolContext = toolContext, + _commandRunner = commandRunner ?? UpgradeCommandRunner(toolContext: toolContext), + super(toolContext: toolContext, verboseHelp: verboseHelp) { argParser ..addFlag( 'force', abbr: 'f', - help: 'Force upgrade the flutter branch, potentially discarding local changes.', - negatable: false, + help: + 'Force upgrade the flutter branch, potentially discarding local changes ' + 'to the git repository. Try this if upgrade fails with "Your flutter ' + 'checkout has local changes".', ) ..addFlag( 'continue', hide: !verboseHelp, + negatable: false, help: - 'Trigger the second half of the upgrade flow. This should not be invoked ' - 'manually. It is used re-entrantly by the standard upgrade command after ' - 'the new version of Flutter is available, to hand off the upgrade process ' - 'from the old version to the new version.', + 'Continues the upgrade flow starting with the new version of the ' + 'Flutter tool. Must be used with "--continue-started-at".', ) ..addOption( 'continue-started-at', hide: !verboseHelp, help: - 'If "--continue" is provided, an ISO 8601 timestamp of the time that the ' - 'initial upgrade command was started. This should not be invoked manually.', + 'The timestamp at which the top-level upgrade was started in ' + 'ISO-8601 format.', + ) + ..addFlag( + 'verify-only', + help: + 'Verifies for any new version of flutter available, without ' + 'upgrading to it.', ) ..addOption( 'working-directory', hide: !verboseHelp, help: - 'Override the upgrade working directory. ' - 'This is only intended to enable integration testing of the tool itself.', - // Also notably, this will override the FakeFlutterVersion if any is set! - ) - ..addFlag( - 'verify-only', - help: 'Checks for any new Flutter updates, without actually fetching them.', - negatable: false, + 'Override the working directory for the upgrade. Useful for testing ' + 'upgrade flows without modifying the current flutter repo.', ); } final UpgradeCommandRunner _commandRunner; + final ToolContext _toolContext; @override final name = 'upgrade'; @override - final description = 'Upgrade your copy of Flutter.'; + final description = 'Upgrade your Flutter development environment.'; @override - final String category = FlutterCommandCategory.sdk; + String get category => FlutterCommandCategory.sdk; @override bool get shouldUpdateCache => false; @@ -93,22 +102,27 @@ class UpgradeCommand extends FlutterCommand { @override Future runCommand() { - _commandRunner.workingDirectory = stringArg('working-directory') ?? Cache.flutterRoot!; + final String? workingDirectory = stringArg('working-directory'); + _commandRunner.workingDirectory = workingDirectory ?? Cache.flutterRoot!; + final Git git = _toolContext.git; + final Platform platform = _toolContext.platform; + return _commandRunner.runCommand( _parsePhaseFromContinueArg(), force: boolArg('force'), - testFlow: stringArg('working-directory') != null, + testFlow: workingDirectory != null, gitTagVersion: GitTagVersion.determine( - globals.platform, - git: globals.git, + platform, + git: git, workingDirectory: _commandRunner.workingDirectory, ), - flutterVersion: stringArg('working-directory') == null - ? globals.flutterVersion + flutterVersion: workingDirectory == null + ? _toolContext.flutterVersion : FlutterVersion( flutterRoot: _commandRunner.workingDirectory!, - fs: globals.fs, - git: globals.git, + fs: _toolContext.fs, + git: git, + platform: platform, ), verifyOnly: boolArg('verify-only'), ); @@ -136,10 +150,11 @@ final class _SecondHalf implements UpgradePhase { @visibleForTesting class UpgradeCommandRunner { - String? workingDirectory; // set in runCommand() above + UpgradeCommandRunner({required this._toolContext}); - @visibleForTesting - SystemClock clock = const SystemClock(); + final ToolContext _toolContext; + + String? workingDirectory; // set in runCommand() above Future runCommand( UpgradePhase phase, { @@ -152,7 +167,7 @@ class UpgradeCommandRunner { switch (phase) { case _FirstHalf(): await _runCommandFirstHalf( - startedAt: clock.now(), + startedAt: _toolContext.systemClock.now(), force: force, gitTagVersion: gitTagVersion, flutterVersion: flutterVersion, @@ -162,8 +177,8 @@ class UpgradeCommandRunner { case _SecondHalf(:final DateTime? upgradeStartedAt): await _runCommandSecondHalf(flutterVersion); if (upgradeStartedAt != null) { - final Duration execution = clock.now().difference(upgradeStartedAt); - globals.printStatus('Took ${getElapsedAsMinutesOrSeconds(execution)}'); + final Duration execution = _toolContext.systemClock.now().difference(upgradeStartedAt); + _toolContext.logger.printStatus('Took ${getElapsedAsMinutesOrSeconds(execution)}'); } } return FlutterCommandResult.success(); @@ -177,29 +192,30 @@ class UpgradeCommandRunner { required bool testFlow, required bool verifyOnly, }) async { + final Logger logger = _toolContext.logger; final FlutterVersion upstreamVersion = await fetchLatestVersion(localVersion: flutterVersion); // It's possible for a given framework revision to have multiple tags (i.e., due to a release // rollback). Verify the upstream version tag isn't newer than the current tag. if (flutterVersion.frameworkRevision == upstreamVersion.frameworkRevision && flutterVersion.gitTagVersion.gitTag.compareTo(upstreamVersion.gitTagVersion.gitTag) >= 0) { - globals.printStatus('Flutter is already up to date on channel ${flutterVersion.channel}'); - globals.printStatus('$flutterVersion'); + logger.printStatus('Flutter is already up to date on channel ${flutterVersion.channel}'); + logger.printStatus('$flutterVersion'); return; } else if (verifyOnly) { - globals.printStatus( + logger.printStatus( 'A new version of Flutter is available on channel ${flutterVersion.channel}\n', ); - globals.printStatus( + logger.printStatus( 'The latest version: ${upstreamVersion.frameworkVersion} (revision ${upstreamVersion.frameworkRevisionShort})', emphasis: true, ); - globals.printStatus( + logger.printStatus( 'Your current version: ${flutterVersion.frameworkVersion} (revision ${flutterVersion.frameworkRevisionShort})\n', ); - globals.printStatus('To upgrade now, run "flutter upgrade".'); + logger.printStatus('To upgrade now, run "flutter upgrade".'); if (flutterVersion.channel == 'stable') { - globals.printStatus('\nSee the announcement and release notes:'); - globals.printStatus('https://docs.flutter.dev/release/release-notes'); + logger.printStatus('\nSee the announcement and release notes:'); + logger.printStatus('https://docs.flutter.dev/release/release-notes'); } return; } @@ -234,8 +250,13 @@ class UpgradeCommandRunner { ); } recordState(flutterVersion); - await ChannelCommand.upgradeChannel(flutterVersion); - globals.printStatus( + await ChannelCommand.upgradeChannel( + flutterVersion, + logger: logger, + git: _toolContext.git, + cache: _toolContext.cache, + ); + logger.printStatus( 'Upgrading Flutter to ${upstreamVersion.frameworkVersion} from ${flutterVersion.frameworkVersion} in $workingDirectory...', ); await attemptReset(upstreamVersion.frameworkRevision); @@ -253,14 +274,17 @@ class UpgradeCommandRunner { if (channel == null) { return; } - globals.persistentToolState!.updateLastActiveVersion(flutterVersion.frameworkRevision, channel); + _toolContext.persistentToolState.updateLastActiveVersion( + flutterVersion.frameworkRevision, + channel, + ); } @visibleForTesting Future flutterUpgradeContinue({required DateTime startedAt}) async { - final int code = await globals.processUtils.stream( + final int code = await _toolContext.processUtils.stream( [ - globals.fs.path.join(workingDirectory ?? Cache.flutterRoot!, 'bin', 'flutter'), + _toolContext.fs.path.join(workingDirectory ?? Cache.flutterRoot!, 'bin', 'flutter'), 'upgrade', '--continue', '--continue-started-at', @@ -268,7 +292,7 @@ class UpgradeCommandRunner { '--no-version-check', ], allowReentrantFlutter: true, - environment: Map.of(globals.platform.environment), + environment: Map.of(_toolContext.platform.environment), ); if (code != 0) { throwToolExit(null, exitCode: code); @@ -278,16 +302,22 @@ class UpgradeCommandRunner { // This method should only be called if the upgrade command is invoked // re-entrantly with the `--continue` flag Future _runCommandSecondHalf(FlutterVersion flutterVersion) async { + final PersistentToolState persistentToolState = _toolContext.persistentToolState; // Make sure the welcome message re-display is delayed until the end. - final PersistentToolState persistentToolState = globals.persistentToolState!; persistentToolState.setShouldRedisplayWelcomeMessage(false); - await precacheArtifacts(workingDirectory); + await precacheArtifacts( + workingDirectory: workingDirectory, + fileSystem: _toolContext.fs, + logger: _toolContext.logger, + platform: _toolContext.platform, + processUtils: _toolContext.processUtils, + ); await updatePackages(flutterVersion); await runDoctor(); // Force the welcome message to re-display following the upgrade. persistentToolState.setShouldRedisplayWelcomeMessage(true); - if (globals.flutterVersion.channel == 'master' || globals.flutterVersion.channel == 'main') { - globals.printStatus( + if (flutterVersion.channel == 'master' || flutterVersion.channel == 'main') { + _toolContext.logger.printStatus( '\n' 'This channel is intended for Flutter contributors. ' 'This channel is not as thoroughly tested as the "beta" and "stable" channels. ' @@ -308,7 +338,7 @@ class UpgradeCommandRunner { @visibleForTesting Future hasUncommittedChanges(FlutterVersion version) async { try { - final RunResult result = await globals.git.run( + final RunResult result = await _toolContext.git.run( ['status', '-s'], throwOnError: true, workingDirectory: workingDirectory, @@ -355,16 +385,13 @@ class UpgradeCommandRunner { /// Exits tool if HEAD isn't pointing to a branch, or there is no upstream. @visibleForTesting Future fetchLatestVersion({required FlutterVersion localVersion}) async { + final Git git = _toolContext.git; String revision; try { // Fetch upstream branch's commits and tags - await globals.git.run( - ['fetch', '--tags'], - throwOnError: true, - workingDirectory: workingDirectory, - ); + await git.run(['fetch', '--tags'], throwOnError: true, workingDirectory: workingDirectory); // Get the latest commit revision of the upstream - final RunResult result = await globals.git.run( + final RunResult result = await git.run( ['rev-parse', '--verify', kGitTrackingUpstream], throwOnError: true, workingDirectory: workingDirectory, @@ -393,7 +420,7 @@ class UpgradeCommandRunner { // an upstream. Check whether this upstream is "standard". final VersionCheckError? error = VersionUpstreamValidator( version: localVersion, - platform: globals.platform, + platform: _toolContext.platform, ).run(); if (error != null) { throwToolExit( @@ -406,8 +433,9 @@ class UpgradeCommandRunner { return FlutterVersion.fromRevision( flutterRoot: workingDirectory!, frameworkRevision: revision, - fs: globals.fs, - git: globals.git, + fs: _toolContext.fs, + git: git, + platform: _toolContext.platform, ); } @@ -419,7 +447,7 @@ class UpgradeCommandRunner { @visibleForTesting Future attemptReset(String newRevision) async { try { - await globals.git.run( + await _toolContext.git.run( ['reset', '--hard', newRevision], throwOnError: true, workingDirectory: workingDirectory, @@ -432,14 +460,23 @@ class UpgradeCommandRunner { /// Update the user's packages. @protected Future updatePackages(FlutterVersion flutterVersion) async { - globals.printStatus(''); - globals.printStatus(flutterVersion.toString()); - final String? projectRoot = findProjectRoot(globals.fs); + final Logger logger = _toolContext.logger; + final FileSystem fs = _toolContext.fs; + logger.printStatus(''); + logger.printStatus(flutterVersion.toString()); + final String? projectRoot = findProjectRoot(fs); if (projectRoot != null) { - globals.printStatus(''); - await pub.get( + logger.printStatus(''); + await Pub( + botDetector: _toolContext.botDetector, + fileSystem: fs, + logger: logger, + platform: _toolContext.platform, + processManager: _toolContext.processManager, + stdio: _toolContext.stdio, + ).get( context: PubContext.pubUpgrade, - project: FlutterProject.fromDirectory(globals.fs.directory(projectRoot)), + project: _toolContext.projectFactory.fromDirectory(fs.directory(projectRoot)), upgrade: true, ); } @@ -448,12 +485,13 @@ class UpgradeCommandRunner { /// Run flutter doctor in case requirements have changed. @protected Future runDoctor() async { - globals.printStatus(''); - globals.printStatus('Running flutter doctor...'); - await globals.processUtils.stream( - [globals.fs.path.join('bin', 'flutter'), '--no-version-check', 'doctor'], - workingDirectory: workingDirectory, + final Logger logger = _toolContext.logger; + logger.printStatus(''); + logger.printStatus('Running flutter doctor...'); + await _toolContext.processUtils.stream( + [_toolContext.fs.path.join('bin', 'flutter'), '--no-version-check', 'doctor'], allowReentrantFlutter: true, + workingDirectory: workingDirectory, ); } } @@ -463,13 +501,24 @@ class UpgradeCommandRunner { /// Check for and download any engine and pkg/ updates. We run the 'flutter' /// shell script reentrantly here so that it will download the updated /// Dart and so forth if necessary. -Future precacheArtifacts([String? workingDirectory]) async { - globals.printStatus(''); - globals.printStatus('Upgrading engine...'); - final int code = await globals.processUtils.stream( - [globals.fs.path.join('bin', 'flutter'), '--no-color', '--no-version-check', 'precache'], +Future precacheArtifacts({ + required FileSystem fileSystem, + required Logger logger, + required Platform platform, + required ProcessUtils processUtils, + String? workingDirectory, +}) async { + logger.printStatus(''); + logger.printStatus('Upgrading engine...'); + final int code = await processUtils.stream( + [ + fileSystem.path.join('bin', 'flutter'), + '--no-color', + '--no-version-check', + 'precache', + ], allowReentrantFlutter: true, - environment: Map.of(globals.platform.environment), + environment: Map.of(platform.environment), workingDirectory: workingDirectory, ); if (code != 0) { diff --git a/packages/flutter_tools/lib/src/commands/validate_project.dart b/packages/flutter_tools/lib/src/commands/validate_project.dart index 37500539..142fc4aa 100644 --- a/packages/flutter_tools/lib/src/commands/validate_project.dart +++ b/packages/flutter_tools/lib/src/commands/validate_project.dart @@ -13,29 +13,31 @@ import '../runner/flutter_command.dart'; class ValidateProject { ValidateProject({ + required this.allProjectValidators, required this.fileSystem, required this.logger, - required this.allProjectValidators, - required this.userPath, required this.processManager, - this.verbose = false, + required this._projectFactory, + required this.userPath, this.machine = false, + this.verbose = false, }); + final List allProjectValidators; final FileSystem fileSystem; final Logger logger; - final bool verbose; - final bool machine; - final String userPath; - final List allProjectValidators; final ProcessManager processManager; + final FlutterProjectFactory _projectFactory; + final String userPath; + final bool machine; + final bool verbose; Future run() async { final Directory workingDirectory = userPath.isEmpty ? fileSystem.currentDirectory : fileSystem.directory(userPath); - final FlutterProject project = FlutterProject.fromDirectory(workingDirectory); + final FlutterProject project = _projectFactory.fromDirectory(workingDirectory); final results = >>{}; var hasCrash = false; diff --git a/packages/flutter_tools/lib/src/commands/widget_preview.dart b/packages/flutter_tools/lib/src/commands/widget_preview.dart index 28021da2..ec5cbc55 100644 --- a/packages/flutter_tools/lib/src/commands/widget_preview.dart +++ b/packages/flutter_tools/lib/src/commands/widget_preview.dart @@ -20,13 +20,14 @@ import '../base/platform.dart'; import '../base/process.dart'; import '../base/terminal.dart'; import '../build_info.dart'; -import '../bundle.dart' as bundle; +import '../build_system/build_system.dart'; +import '../build_system/build_targets.dart'; import '../cache.dart'; +import '../context/tool_context.dart'; import '../convert.dart'; import '../dart/analysis.dart'; import '../device.dart'; import '../features.dart'; -import '../globals.dart' as globals; import '../isolated/resident_web_runner.dart'; import '../migrations/widget_preview_gitignore_migration.dart'; import '../project.dart'; @@ -42,45 +43,42 @@ import '../widget_preview/preview_code_generator.dart'; import '../widget_preview/preview_detector.dart'; import '../widget_preview/preview_manifest.dart'; import '../widget_preview/preview_pubspec_builder.dart'; +import '../widget_preview/utils.dart'; import 'create_base.dart'; +typedef ResidentRunnerFactory = ResidentRunner Function( + FlutterDevice device, { + required DebuggingOptions debuggingOptions, + required FlutterProject flutterProject, + required String projectRootPath, + required String target, +}); + class WidgetPreviewCommand extends FlutterCommand { WidgetPreviewCommand({ - required bool verboseHelp, - required Logger logger, - required FileSystem fs, - required FlutterProjectFactory projectFactory, - required Cache cache, - required Platform platform, - required ShutdownHooks shutdownHooks, - required OperatingSystemUtils os, - required ProcessManager processManager, - required Artifacts artifacts, - required Terminal terminal, - @visibleForTesting WidgetPreviewDtdServices? dtdServicesOverride, + required BuildSystem buildSystem, + required BuildTargets buildTargets, + required super.toolContext, @visibleForTesting Future Function()? analysisServerFactoryOverride, + @visibleForTesting WidgetPreviewDtdServices? dtdServicesOverride, + @visibleForTesting ResidentRunnerFactory? residentRunnerFactoryOverride, + bool verboseHelp = false, }) { addSubcommand( WidgetPreviewStartCommand( - verbose: verboseHelp, - logger: logger, - fs: fs, - projectFactory: projectFactory, - cache: cache, - platform: platform, - shutdownHooks: shutdownHooks, - os: os, - processManager: processManager, - artifacts: artifacts, - dtdServicesOverride: dtdServicesOverride, + buildSystem: buildSystem, + buildTargets: buildTargets, + toolContext: toolContext, analysisServerFactoryOverride: analysisServerFactoryOverride, - terminal: terminal, + dtdServicesOverride: dtdServicesOverride, + residentRunnerFactoryOverride: residentRunnerFactoryOverride, + verbose: verboseHelp, ), ); - addSubcommand( - WidgetPreviewCleanCommand(logger: logger, fs: fs, projectFactory: projectFactory), - ); + addSubcommand(WidgetPreviewCleanCommand(toolContext: toolContext)); } + @override + ToolContext get toolContext => super.toolContext!; @override String get description => 'Manage the widget preview environment.'; @@ -97,9 +95,14 @@ class WidgetPreviewCommand extends FlutterCommand { } abstract base class WidgetPreviewSubCommandBase extends FlutterCommand { - FileSystem get fs; - Logger get logger; - FlutterProjectFactory get projectFactory; + WidgetPreviewSubCommandBase({required super.toolContext}); + + @override + ToolContext get toolContext => super.toolContext!; + + FileSystem get fs => toolContext.fs; + Logger get logger => toolContext.logger; + FlutterProjectFactory get projectFactory => toolContext.projectFactory; FlutterProject getRootProject() { final ArgResults results = argResults!; @@ -114,7 +117,13 @@ abstract base class WidgetPreviewSubCommandBase extends FlutterCommand { } else { projectDir = fs.currentDirectory; } - return validateFlutterProjectForPreview(projectDir); + final FlutterProject project = validateFlutterProjectForPreview(projectDir); + final FlutterProject? workspaceRoot = project.workspaceRoot; + if (workspaceRoot != null) { + logger.printTrace('Found workspace root at ${workspaceRoot.directory.path}'); + return workspaceRoot; + } + return project; } FlutterProject validateFlutterProjectForPreview(Directory directory) { @@ -127,22 +136,26 @@ abstract base class WidgetPreviewSubCommandBase extends FlutterCommand { } } +/// The type of reload operation queued to run once [WidgetPreviewStartCommand._reloadMutex] +/// is available. +enum _PendingReload { + /// Perform a hot reload (`restart(fullRestart: false)`). + hotReload, + + /// Perform a full hot restart (`restart(fullRestart: true)`). + hotRestart, +} + final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with CreateBase { WidgetPreviewStartCommand({ - this.verbose = false, - required Logger logger, - required this.fs, - required this.projectFactory, - required this.cache, - required this.platform, - required this.shutdownHooks, - required this.os, - required this.processManager, - required this.artifacts, - required this.terminal, - @visibleForTesting WidgetPreviewDtdServices? dtdServicesOverride, + required this._buildSystem, + required this._buildTargets, + required super.toolContext, @visibleForTesting Future Function()? analysisServerFactoryOverride, - }) : _logger = logger { + @visibleForTesting WidgetPreviewDtdServices? dtdServicesOverride, + @visibleForTesting this._residentRunnerFactoryOverride, + this.verbose = false, + }) { if (dtdServicesOverride != null) { _dtdService = dtdServicesOverride; } @@ -159,8 +172,7 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C ) ..addOption( kDtdUrl, - help: - 'The address of an existing Dart Tooling Daemon instance to be used by the Flutter CLI.', + help: 'The address of an existing Dart Tooling Daemon instance to be used by the Flutter CLI.', hide: !verbose, ) ..addFlag( @@ -220,29 +232,26 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C final bool verbose; - @override - final FileSystem fs; - - @override - WidgetPreviewMachineAwareLogger get logger => _logger as WidgetPreviewMachineAwareLogger; - final Logger _logger; + final BuildSystem _buildSystem; + final BuildTargets _buildTargets; @override - final FlutterProjectFactory projectFactory; + WidgetPreviewMachineAwareLogger get logger => + toolContext.logger as WidgetPreviewMachineAwareLogger; - final Cache cache; + Cache get cache => toolContext.cache; - final Platform platform; + Platform get platform => toolContext.platform; - final ShutdownHooks shutdownHooks; + ShutdownHooks get shutdownHooks => toolContext.shutdownHooks; - final OperatingSystemUtils os; + OperatingSystemUtils get os => toolContext.os; - final ProcessManager processManager; + ProcessManager get processManager => toolContext.processManager; - final Artifacts artifacts; + Artifacts get artifacts => toolContext.artifacts; - final Terminal terminal; + Terminal get terminal => toolContext.terminal; late final previewAnalytics = WidgetPreviewAnalytics(analytics: analytics); @@ -257,6 +266,7 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C ); late final _previewDetector = PreviewDetector( + artifacts: artifacts, platform: platform, previewAnalytics: previewAnalytics, project: rootProject, @@ -264,6 +274,7 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C fs: fs, onChangeDetected: onLegacyChangeDetected, onPubspecChangeDetected: _onPubspecChangeDetected, + onNonDartFileChangeDetected: _onNonDartFileChangeDetected, ); late final _lspPreviewDetector = LspPreviewDetector( @@ -274,6 +285,7 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C fs: fs, onChangeDetected: onChangeDetected, onPubspecChangeDetected: _onPubspecChangeDetected, + onNonDartFileChangeDetected: _onNonDartFileChangeDetected, shutdownHooks: shutdownHooks, dtd: _dtdService, processManager: processManager, @@ -284,6 +296,7 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C ); late final Future Function()? _analysisServerFactoryOverride; + final ResidentRunnerFactory? _residentRunnerFactoryOverride; late final PreviewCodeGenerator _previewCodeGenerator; late final _previewManifest = PreviewManifest( @@ -307,17 +320,27 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C /// The currently running instance of the widget preview scaffold. ResidentRunner? _widgetPreviewApp; - /// The location of the widget_preview_scaffold for the current execution of the command. - /// - /// This is only meant for testing as there's no simple mapping from the target project to the - /// scaffold project. - // TODO(bkonyi): remove once https://github.com/flutter/flutter/issues/179036 is resolved. @visibleForTesting - static late Directory widgetPreviewScaffold; + ResidentRunner? get widgetPreviewApp => _widgetPreviewApp; + /// Serializes initial preview scaffold startup and subsequent hot reload / hot + /// restart operations so reloads cannot execute before the debug connection is + /// established or overlap with an in-flight reload. + final _reloadMutex = PreviewDetectorMutex(); + + /// Tracks a coalesced reload or restart request waiting to acquire + /// [_reloadMutex]. + /// + /// When non-null, a task is already queued on [_reloadMutex] to execute the + /// pending reload once the current startup or in-flight reload completes. + /// Additional requests that arrive before that queued task acquires the lock + /// update this field in place (upgrading [_PendingReload.hotReload] to + /// [_PendingReload.hotRestart] if a full restart is requested) without + /// queueing duplicate tasks on [_reloadMutex]. + _PendingReload? _pendingReload; @override Future runCommand() async { - assert(_logger is WidgetPreviewMachineAwareLogger); + assert(toolContext.logger is WidgetPreviewMachineAwareLogger); // Start the timer tracking how long it takes to launch the preview environment. previewAnalytics.initializeLaunchStopwatch(); @@ -326,7 +349,7 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C await WidgetPreviewGitignoreMigration(rootProject, logger).migrate(); final String? customPreviewScaffoldOutput = stringArg(kWidgetPreviewScaffoldOutputDir); - widgetPreviewScaffold = customPreviewScaffoldOutput != null + final Directory widgetPreviewScaffold = customPreviewScaffoldOutput != null ? fs.directory(customPreviewScaffoldOutput) : rootProject.widgetPreviewScaffold; @@ -334,9 +357,7 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C // generate one. final bool generateScaffoldProject = customPreviewScaffoldOutput != null || _previewManifest.shouldGenerateProject(); - // TODO(bkonyi): can this be moved? widgetPreviewScaffold.createSync(recursive: true); - fs.currentDirectory = widgetPreviewScaffold; if (generateScaffoldProject) { // WARNING: this log message is used by test/integration.shard/widget_preview_test.dart @@ -400,7 +421,11 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C final bool legacyDetection = boolArg('legacy-preview-detection'); shutdownHooks.addShutdownHook(() async { - await _widgetPreviewApp?.exitApp(); + // Clear the runner reference before exiting so any queued or late reload + // callbacks become no-ops during shutdown. + final ResidentRunner? app = _widgetPreviewApp; + _widgetPreviewApp = null; + await app?.exitApp(); if (legacyDetection) { await _previewDetector.dispose(); } else { @@ -416,19 +441,19 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C await _lspPreviewDetector.initialize(); - // Wait for the initial analysis to complete to ensure the analysis server - // has registered the widget preview RPC methods. - await _lspPreviewDetector.analysisServer?.waitForAnalysis(); - - _previewCodeGenerator.populateDtdConnectionInfo( - dtdUri: _dtdService.dtdUri!, - widgetPreviewServiceName: _dtdService.widgetPreviewService, - widgetPreviewScaffoldStreamName: _dtdService.widgetPreviewScaffoldStream, - projectRootPath: rootProject.directory.absolute.path, - ); - final FlutterWidgetPreviews originalPreviews; try { + // Wait for the initial analysis to complete to ensure the analysis server + // has registered the widget preview RPC methods. + await _lspPreviewDetector.waitForAnalysis(); + + _previewCodeGenerator.populateDtdConnectionInfo( + dtdUri: _dtdService.dtdUri!, + widgetPreviewServiceName: _dtdService.widgetPreviewService, + widgetPreviewScaffoldStreamName: _dtdService.widgetPreviewScaffoldStream, + projectRootPath: rootProject.directory.absolute.path, + ); + originalPreviews = await _dtdService.getFlutterWidgetPreviews(); } on Exception catch (e) { throwToolExit( @@ -469,15 +494,44 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C } } + Future _triggerReload(_PendingReload requested) async { + // Record the requested reload type. If a task is already queued waiting for + // [_reloadMutex], coalesce this request into the pending one (upgrading a + // queued hot reload to a full hot restart if needed, and never downgrading + // a queued hot restart) and return immediately rather than queueing + // duplicate reload tasks on the mutex. + final _PendingReload? queued = _pendingReload; + _pendingReload = queued == .hotRestart ? .hotRestart : requested; + if (queued != null) { + return; + } + await _reloadMutex.runGuarded(() async { + // Consume the coalesced request now that we hold [_reloadMutex]. Any new + // requests that arrive while `ResidentRunner.restart` is in flight will + // see `_pendingReload == null` and queue a single follow-up task on the + // mutex. + final _PendingReload? pending = _pendingReload; + _pendingReload = null; + if (pending == null || shutdownHooks.isShuttingDown) { + return; + } + try { + await _widgetPreviewApp?.restart(fullRestart: pending == .hotRestart); + } on Object catch (e, st) { + logger.printTrace('Error during widget preview reload: $e\n$st'); + } + }); + } + void onLegacyChangeDetected(PreviewDependencyGraph previews) { _previewCodeGenerator.populatePreviewsInGeneratedPreviewScaffold(previews); logger.printStatus('Triggering reload based on change to preview set: $previews'); - _widgetPreviewApp?.restart(); + unawaited(_triggerReload(.hotReload)); } void onHotRestartRequest() { logger.printStatus('Triggering restart based on request from preview environment.'); - _widgetPreviewApp?.restart(fullRestart: true); + unawaited(_triggerReload(.hotRestart)); } Future _onPubspecChangeDetected(String path) async { @@ -486,13 +540,20 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C rootProject: project, updatedPubspecPath: path, ); - await _widgetPreviewApp?.restart(fullRestart: true); + await _triggerReload(.hotRestart); } void onChangeDetected(FlutterWidgetPreviews update) { _previewCodeGenerator.populatePreviewsInGeneratedPreviewScaffoldLsp(update); logger.printStatus('Triggering reload based on update to script: ${update.scriptUris}'); - _widgetPreviewApp?.restart(); + unawaited(_triggerReload(.hotReload)); + } + + void _onNonDartFileChangeDetected(String path) { + if (_widgetPreviewApp?.assetBundle.needsBuild() ?? false) { + logger.printStatus('Triggering reload based on update to asset: $path'); + unawaited(_triggerReload(.hotReload)); + } } /// Configures the Dart Tooling Daemon connection. @@ -519,132 +580,153 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C Future runPreviewEnvironment({required FlutterProject widgetPreviewScaffoldProject}) async { try { - // In the rare case that Flutter Web is disabled, the device manager will not return any web - // devices which will cause us to crash. - if (!featureFlags.isWebEnabled) { - throwToolExit( - 'Widget Previews requires Flutter Web to be enabled. Please run ' - "'flutter config --enable-web' to enable Flutter Web and try again.", - ); - } - final Device device; - if (boolArg(kWebServer)) { - final List devices; - try { - // The web-server device is hidden by default, make it visible before trying to look it up. - WebServerDevice.showWebServerDevice = true; - devices = await deviceManager!.getDevicesById(WebServerDevice.kWebServerDeviceId); - } finally { - // Reset the flag to false to avoid affecting other commands. - WebServerDevice.showWebServerDevice = false; + // Hold [_reloadMutex] during startup so any reload or restart requests + // triggered while the preview scaffold is launching and establishing its + // debug connection are gated until the previewer is ready. + await _reloadMutex.runGuarded(() async { + // In the rare case that Flutter Web is disabled, the device manager will not return any web + // devices which will cause us to crash. + if (!featureFlags.isWebEnabled) { + throwToolExit( + 'Widget Previews requires Flutter Web to be enabled. Please run ' + "'flutter config --enable-web' to enable Flutter Web and try again.", + ); } - assert(devices.length == 1); - device = devices.single; - } else { - // Since the only target supported by the widget preview scaffold is the web - // device, only a single web device should be returned. - final List devices = await deviceManager!.getDevices( - filter: DeviceDiscoveryFilter( - supportFilter: DeviceDiscoverySupportFilter.excludeDevicesUnsupportedByFlutterOrProject( - flutterProject: widgetPreviewScaffoldProject, + final Device device; + if (boolArg(kWebServer)) { + final List devices; + try { + // The web-server device is hidden by default, make it visible before trying to look it up. + WebServerDevice.showWebServerDevice = true; + devices = await deviceManager!.getDevicesById(WebServerDevice.kWebServerDeviceId); + } finally { + // Reset the flag to false to avoid affecting other commands. + WebServerDevice.showWebServerDevice = false; + } + assert(devices.length == 1); + device = devices.single; + } else { + // Since the only target supported by the widget preview scaffold is the web + // device, only a single web device should be returned. + final List devices = await deviceManager!.getDevices( + filter: DeviceDiscoveryFilter( + supportFilter: + DeviceDiscoverySupportFilter.excludeDevicesUnsupportedByFlutterOrProject( + flutterProject: widgetPreviewScaffoldProject, + ), + deviceConnectionInterface: DeviceConnectionInterface.attached, ), - deviceConnectionInterface: DeviceConnectionInterface.attached, - ), - ); - - if (devices.isEmpty) { - throwToolExit(kBrowserNotFoundErrorMessage); - } - if (devices.length > 1) { - // Prefer Google Chrome as the target browser. - device = - devices.firstWhereOrNull((device) => device is GoogleChromeDevice) ?? devices.first; - - logger.printTrace( - 'Detected ${devices.length} web devices (${devices.map((e) => e.displayName).join(', ')}). ' - 'Defaulting to ${device.displayName}.', ); - } else { - device = devices.single; + + if (devices.isEmpty) { + throwToolExit(kBrowserNotFoundErrorMessage); + } + if (devices.length > 1) { + // Prefer Google Chrome as the target browser. + device = + devices.firstWhereOrNull((device) => device is GoogleChromeDevice) ?? devices.first; + + logger.printTrace( + 'Detected ${devices.length} web devices (${devices.map((e) => e.displayName).join(', ')}). ' + 'Defaulting to ${device.displayName}.', + ); + } else { + device = devices.single; + } } - } - // WARNING: this log message is used by test/integration.shard/widget_preview_test.dart - logger.printStatus('Launching the Widget Preview Scaffold on ${device.displayName}...'); - - final debuggingOptions = DebuggingOptions.enabled( - BuildInfo( - BuildMode.debug, - null, - treeShakeIcons: false, - packageConfigPath: widgetPreviewScaffoldProject.packageConfig.path, - packageConfig: PackageConfig.parseBytes( - widgetPreviewScaffoldProject.packageConfig.readAsBytesSync(), - widgetPreviewScaffoldProject.packageConfig.uri, + // WARNING: this log message is used by test/integration.shard/widget_preview_test.dart + logger.printStatus('Launching the Widget Preview Scaffold on ${device.displayName}...'); + + final debuggingOptions = DebuggingOptions.enabled( + BuildInfo( + BuildMode.debug, + null, + treeShakeIcons: false, + packageConfigPath: widgetPreviewScaffoldProject.packageConfig.path, + packageConfig: PackageConfig.parseBytes( + widgetPreviewScaffoldProject.packageConfig.readAsBytesSync(), + widgetPreviewScaffoldProject.packageConfig.uri, + ), + trackWidgetCreation: true, + // Don't try and download canvaskit from the CDN. + useLocalCanvasKit: true, + includeUnsupportedPlatformLibraryStubs: true, ), - trackWidgetCreation: true, - // Don't try and download canvaskit from the CDN. - useLocalCanvasKit: true, - webEnableHotReload: true, - includeUnsupportedPlatformLibraryStubs: true, - ), - webEnableExposeUrl: false, - webEnableExpressionEvaluation: true, - webRunHeadless: boolArg(kHeadless), - devToolsServerAddress: devToolsServerAddress, - ); - final String target = bundle.defaultMainPath; - final FlutterDevice flutterDevice = await FlutterDevice.create( - device, - target: target, - buildInfo: debuggingOptions.buildInfo, - platform: platform, - ); - - if (boolArg(kLaunchPreviewer)) { - final appStarted = Completer(); - final connectionInfo = Completer(); - _widgetPreviewApp = ResidentWebRunner( - flutterDevice, - target: target, - debuggingOptions: debuggingOptions, - analytics: analytics, - flutterProject: widgetPreviewScaffoldProject, - fileSystem: fs, - logger: logger, - terminal: globals.terminal, - platform: platform, - outputPreferences: globals.outputPreferences, - systemClock: globals.systemClock, - // Explicitly provide the project root path rather than relying on the current directory - // as the current directory exists within $TMP. At least on MacOS, when setting the - // current directory to the widget_preview_scaffold project created under - // `/var/folders/...`, the underlying chdir call actually changes the directory to - // `/private/var/folders/...`. These directories are identical, but confuse the package - // config resolution logic. - // TODO(bkonyi): consider removing if we stop placing the scaffold in $TMP. - // See https://github.com/flutter/flutter/issues/179036 - projectRootPath: widgetPreviewScaffoldProject.directory.absolute.path, - ); - unawaited( - _widgetPreviewApp!.run( - appStartedCompleter: appStarted, - connectionInfoCompleter: connectionInfo, + webConfig: WebDebugConfig( + enableExpressionEvaluation: true, + runHeadless: boolArg(kHeadless), ), + devToolsServerAddress: devToolsServerAddress, ); - await appStarted.future; - logger.sendStartedEvent(applicationUrl: flutterDevice.devFS!.baseUri!); - final DebugConnectionInfo debugConnection = await connectionInfo.future; - final Uri? devToolsUri = devToolsServerAddress ?? debugConnection.devToolsUri; - if (devToolsUri == null) { - throwToolExit('Could not determine DevTools server address for the widget inspector.'); - } - _dtdService.setDevToolsServerAddress( - devToolsServerAddress: devToolsServerAddress ?? debugConnection.devToolsUri!, - applicationUri: debugConnection.wsUri!, + final String target = widgetPreviewScaffoldProject.directory + .childDirectory('lib') + .childFile('main.dart') + .absolute + .path; + final FlutterDevice flutterDevice = await FlutterDevice.create( + device, + toolContext: toolContext, + buildInfo: debuggingOptions.buildInfo, + target: target, ); - } + + if (boolArg(kLaunchPreviewer)) { + final appStarted = Completer(); + final connectionInfo = Completer(); + _widgetPreviewApp = _residentRunnerFactoryOverride != null + ? _residentRunnerFactoryOverride( + flutterDevice, + target: target, + debuggingOptions: debuggingOptions, + flutterProject: widgetPreviewScaffoldProject, + projectRootPath: widgetPreviewScaffoldProject.directory.absolute.path, + ) + : ResidentWebRunner( + flutterDevice, + buildSystem: _buildSystem, + buildTargets: _buildTargets, + toolContext: toolContext, + target: target, + debuggingOptions: debuggingOptions, + analytics: analytics, + flutterProject: widgetPreviewScaffoldProject, + // Explicitly provide the project root path rather than relying on the current directory + // as the current directory exists within $TMP. At least on MacOS, when setting the + // current directory to the widget_preview_scaffold project created under + // `/var/folders/...`, the underlying chdir call actually changes the directory to + // `/private/var/folders/...`. These directories are identical, but confuse the package + // config resolution logic. + // TODO(bkonyi): consider removing if we stop placing the scaffold in $TMP. + // See https://github.com/flutter/flutter/issues/179036 + projectRootPath: widgetPreviewScaffoldProject.directory.absolute.path, + ); + unawaited( + _widgetPreviewApp!.run( + appStartedCompleter: appStarted, + connectionInfoCompleter: connectionInfo, + ), + ); + await appStarted.future; + logger.sendStartedEvent(applicationUrl: flutterDevice.devFS!.baseUri!); + final DebugConnectionInfo debugConnection = await connectionInfo.future; + final Uri? devToolsUri = devToolsServerAddress ?? debugConnection.devToolsUri; + if (devToolsUri == null) { + throwToolExit('Could not determine DevTools server address for the widget inspector.'); + } + _dtdService.setDevToolsServerAddress( + devToolsServerAddress: devToolsServerAddress ?? debugConnection.devToolsUri!, + applicationUri: debugConnection.wsUri!, + ); + } + }); } on Exception catch (error) { + // Clear the runner reference so any queued reload callbacks become no-ops, + // then tear down a partially started previewer since the shutdown hook will + // no longer see it. + final ResidentRunner? app = _widgetPreviewApp; + _widgetPreviewApp = null; + await app?.exitApp(); throwToolExit(error.toString()); } @@ -655,12 +737,19 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C previewAnalytics.reportLaunchTiming(); // If _widgetPreviewApp is null --no-launch-previewer was provided so return success. - return _widgetPreviewApp?.waitForAppToFinish() ?? 0; + if (_widgetPreviewApp == null) { + return 0; + } + final int exitCode = await _widgetPreviewApp!.waitForAppToFinish(); + // Clear the runner reference so any reload callbacks triggered after the + // previewer exits become no-ops. + _widgetPreviewApp = null; + return exitCode; } } final class WidgetPreviewCleanCommand extends WidgetPreviewSubCommandBase { - WidgetPreviewCleanCommand({required this.fs, required this.logger, required this.projectFactory}); + WidgetPreviewCleanCommand({required super.toolContext}); @override String get description => 'Cleans up widget preview state.'; @@ -668,15 +757,6 @@ final class WidgetPreviewCleanCommand extends WidgetPreviewSubCommandBase { @override String get name => 'clean'; - @override - final FileSystem fs; - - @override - final Logger logger; - - @override - final FlutterProjectFactory projectFactory; - @override Future runCommand() async { final Directory widgetPreviewScaffold = getRootProject().widgetPreviewScaffold; @@ -694,10 +774,16 @@ final class WidgetPreviewCleanCommand extends WidgetPreviewSubCommandBase { /// A custom logger for the widget-preview commands that disables non-event output to stdio when /// machine mode is enabled. final class WidgetPreviewMachineAwareLogger extends DelegatingLogger { - WidgetPreviewMachineAwareLogger(super.delegate, {required this.machine, required this.verbose}); + WidgetPreviewMachineAwareLogger( + super.delegate, { + required this.machine, + required this._stdio, + required this.verbose, + }); final bool machine; final bool verbose; + final Stdio _stdio; @override void printError( @@ -816,7 +902,7 @@ final class WidgetPreviewMachineAwareLogger extends DelegatingLogger { } // Don't call super.printStatus as it will result in a prefix being printed when --verbose is // provided. - globals.stdio.stdout.writeln( + _stdio.stdout.writeln( json.encode([ {'event': 'widget_preview.$name', 'params': ?args}, ]), diff --git a/packages/flutter_tools/lib/src/compile.dart b/packages/flutter_tools/lib/src/compile.dart index cf8ec8f8..11073e13 100644 --- a/packages/flutter_tools/lib/src/compile.dart +++ b/packages/flutter_tools/lib/src/compile.dart @@ -28,6 +28,38 @@ import 'dart/package_map.dart'; /// Opt-in changes to the dart compilers. const kDartCompilerExperiments = []; +/// Whether DDC canary features are enabled. +/// +/// This is the single source of truth for DDC canary mode. It controls both +/// the flags passed to the frontend server when compiling with DDC (see +/// [kDdcLibraryBundleFlags]) and which precompiled artifacts are served at +/// runtime. Keeping these in sync is required: code compiled with canary +/// features must be loaded alongside an SDK compiled the same way. +/// +/// This must stay `true` until DDC emits the library bundle format without +/// `--dartdevc-canary`. Until then, `--dartdevc-module-format=ddc` without +/// canary selects the legacy DDC module format, which is no longer supported. +const kDdcCanaryFeatures = true; + +// Flags passed when compiling with DDC. +const kDdcLibraryBundleFlags = [ + '--dartdevc-module-format=ddc', + if (kDdcCanaryFeatures) '--dartdevc-canary', + '--no-js-strongly-connected-components', +]; + +/// Returns the frontend server options used when compiling [buildInfo] with +/// DDC. +/// +/// These options are part of the default cached kernel path (see +/// [getDefaultCachedKernelPath]), so any code that reads or writes the cached +/// kernel for DDC must use them to compute the same path as the compiler. +List ddcFrontEndOptions(BuildInfo buildInfo) => [ + ...buildInfo.extraFrontEndOptions, + ...kDdcLibraryBundleFlags, + ...deprecatedJsInteropCompilerFlags(buildInfo.deprecatedJsInterop), +]; + /// The target model describes the set of core libraries that are available within /// the SDK. class TargetModel { @@ -66,9 +98,9 @@ class TargetModel { /// Infers the appropriate [TargetModel] from a given [TargetPlatform]. static TargetModel fromTargetPlatform(TargetPlatform? platform) { - return switch (platform) { - TargetPlatform.web_javascript => TargetModel.dartdevc, - TargetPlatform.fuchsia_arm64 || TargetPlatform.fuchsia_x64 => TargetModel.flutterRunner, + return switch (platform?.os) { + .web => TargetModel.dartdevc, + .fuchsia => TargetModel.flutterRunner, _ => TargetModel.flutter, }; } @@ -98,9 +130,7 @@ enum StdoutState { CollectDiagnostic, CollectDependencies } /// Handles stdin/stdout communication with the frontend server. class StdoutHandler { - StdoutHandler({required Logger logger, required FileSystem fileSystem}) - : _logger = logger, - _fileSystem = fileSystem { + StdoutHandler({required this._logger, required this._fileSystem}) { reset(); } @@ -235,17 +265,13 @@ class KernelCompiler { KernelCompiler({ required FileSystem fileSystem, required Logger logger, - required ProcessManager processManager, - required Artifacts artifacts, - required List fileSystemRoots, - String? fileSystemScheme, + required this._processManager, + required this._artifacts, + required this._fileSystemRoots, + this._fileSystemScheme, @visibleForTesting StdoutHandler? stdoutHandler, }) : _logger = logger, _fileSystem = fileSystem, - _artifacts = artifacts, - _processManager = processManager, - _fileSystemScheme = fileSystemScheme, - _fileSystemRoots = fileSystemRoots, _stdoutHandler = stdoutHandler ?? StdoutHandler(logger: logger, fileSystem: fileSystem); final FileSystem _fileSystem; @@ -571,12 +597,7 @@ class ResidentCompilerFactory { // Override the filesystem scheme so that the frontend_server can find // the generated entrypoint code. fileSystemScheme: 'org-dartlang-app', - extraFrontEndOptions: [ - ...buildInfo.extraFrontEndOptions, - if (buildInfo.webEnableHotReload) - // These flags are only valid to be passed when compiling with DDC. - ...['--dartdevc-canary', '--dartdevc-module-format=ddc'], - ], + extraFrontEndOptions: ddcFrontEndOptions(buildInfo), ); } else { if (targetPlatform case .fuchsia_arm64 || .fuchsia_x64) { @@ -715,11 +736,11 @@ class DefaultResidentCompiler implements ResidentCompiler { String sdkRoot, { required BuildInfo buildInfo, required Logger logger, - required ProcessManager processManager, + required this._processManager, required this.artifacts, - required Platform platform, + required this._platform, required FileSystem fileSystem, - required ShutdownHooks shutdownHooks, + required this._shutdownHooks, required Config config, this.testCompilation = false, this.targetModel = TargetModel.flutter, @@ -745,10 +766,7 @@ class DefaultResidentCompiler implements ResidentCompiler { assumeInitializeFromDillUpToDate = buildInfo.assumeInitializeFromDillUpToDate, frontendServerStarterPath = buildInfo.frontendServerStarterPath, _logger = logger, - _processManager = processManager, - _shutdownHooks = shutdownHooks, _stdoutHandler = stdoutHandler ?? StdoutHandler(logger: logger, fileSystem: fileSystem), - _platform = platform, dartDefines = buildInfo.dartDefines, // This is a URI, not a file path, so the forward slash is correct even on Windows. sdkRoot = sdkRoot.endsWith('/') ? sdkRoot : '$sdkRoot/', @@ -1082,25 +1100,25 @@ class DefaultResidentCompiler implements ResidentCompiler { return null; } - final String inputKey = const Uuid().v4(); - server.stdin - ..writeln('compile-expression $inputKey') - ..writeln(request.expression); - request.definitions?.forEach(server.stdin.writeln); - server.stdin.writeln(inputKey); - request.definitionTypes?.forEach(server.stdin.writeln); - server.stdin.writeln(inputKey); - request.typeDefinitions?.forEach(server.stdin.writeln); - server.stdin.writeln(inputKey); - request.typeBounds?.forEach(server.stdin.writeln); - server.stdin.writeln(inputKey); - request.typeDefaults?.forEach(server.stdin.writeln); server.stdin - ..writeln(inputKey) - ..writeln(request.libraryUri ?? '') - ..writeln(request.klass ?? '') - ..writeln(request.method ?? '') - ..writeln(request.isStatic); + ..writeln('JSON_INPUT') + ..writeln( + json.encode({ + 'type': 'COMPILE_EXPRESSION', + 'data': { + 'expression': request.expression, + 'definitions': request.definitions ?? [], + 'definitionTypes': request.definitionTypes ?? [], + 'typeDefinitions': request.typeDefinitions ?? [], + 'typeBounds': request.typeBounds ?? [], + 'typeDefaults': request.typeDefaults ?? [], + 'libraryUri': request.libraryUri ?? '', + 'class': request.klass, + 'method': request.method, + 'static': request.isStatic, + }, + }), + ); return _stdoutHandler.compilerOutput?.future; } diff --git a/packages/flutter_tools/lib/src/context/android_context.dart b/packages/flutter_tools/lib/src/context/android_context.dart new file mode 100644 index 00000000..5ae06966 --- /dev/null +++ b/packages/flutter_tools/lib/src/context/android_context.dart @@ -0,0 +1,35 @@ +// Copyright 2014 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 '../android/android_sdk.dart'; +import '../android/android_studio.dart'; +import '../android/gradle_utils.dart'; +import '../android/java.dart'; + +/// Holds Android-specific dependencies. +class AndroidContext { + AndroidContext({ + required this.androidSdkBuilder, + required this.androidStudioBuilder, + required this.gradleUtilsBuilder, + required this.javaBuilder, + }); + + /// Discovers, validates, and manages the local Android SDK and platform tools. + late final AndroidSdk? androidSdk = androidSdkBuilder(); + + /// Discovers and inspects local Android Studio installations and embedded JDK paths. + late final AndroidStudio? androidStudio = androidStudioBuilder(); + + /// Utility helpers for interacting with Gradle builds and resolving project configs. + late final GradleUtils gradleUtils = gradleUtilsBuilder(); + + /// Discovers and validates the active Java Development Kit (JDK) binary. + late final Java? java = javaBuilder(); + + final AndroidSdk? Function() androidSdkBuilder; + final AndroidStudio? Function() androidStudioBuilder; + final GradleUtils Function() gradleUtilsBuilder; + final Java? Function() javaBuilder; +} diff --git a/packages/flutter_tools/lib/src/context/apple_context.dart b/packages/flutter_tools/lib/src/context/apple_context.dart new file mode 100644 index 00000000..319a4725 --- /dev/null +++ b/packages/flutter_tools/lib/src/context/apple_context.dart @@ -0,0 +1,50 @@ +// Copyright 2014 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 '../ios/ios_workflow.dart'; +import '../ios/plist_parser.dart'; +import '../ios/simulators.dart'; +import '../ios/xcodeproj.dart'; +import '../macos/cocoapods.dart'; +import '../macos/cocoapods_validator.dart'; +import '../macos/xcdevice.dart'; +import '../macos/xcode.dart'; + +/// Holds Apple-specific dependencies. +class AppleContext { + AppleContext({ + required this.cocoaPods, + required this.cocoapodsValidator, + required this.iosSimulatorUtils, + required this.iosWorkflow, + required this.plistParser, + required this.xcdevice, + required this.xcode, + required this.xcodeProjectInterpreter, + }); + + /// Interacts with and executes CocoaPods dependency management commands. + final CocoaPods cocoaPods; + + /// Validates the host CocoaPods installation health and doctor status. + final CocoaPodsValidator cocoapodsValidator; + + /// Manages discovery, booting, and queries for iOS simulator devices. + final IOSSimulatorUtils iosSimulatorUtils; + + /// Evaluates host readiness and toolchain requirements for iOS workflows. + final IOSWorkflow iosWorkflow; + + /// Parses Apple Property List (`.plist`) XML and binary format files. + final PlistParser plistParser; + + /// Discovers and interacts with physical iOS devices attached to the host. + final XCDevice xcdevice; + + /// Inspects and validates the host Xcode installation and SDK version compliance. + final Xcode xcode; + + /// Evaluates Xcode project settings, schemes, and target configurations. + final XcodeProjectInterpreter xcodeProjectInterpreter; +} diff --git a/packages/flutter_tools/lib/src/context/tool_context.dart b/packages/flutter_tools/lib/src/context/tool_context.dart new file mode 100644 index 00000000..8638cb2e --- /dev/null +++ b/packages/flutter_tools/lib/src/context/tool_context.dart @@ -0,0 +1,161 @@ +// Copyright 2014 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. + +/// @docImport '../build_system/build_system.dart'; +/// @docImport '../doctor.dart'; +/// @docImport 'android_context.dart'; +/// @docImport 'apple_context.dart'; +/// @docImport 'tool_dependencies.dart'; +library; + +import 'package:process/process.dart'; + +import '../artifacts.dart'; +import '../base/bot_detector.dart'; +import '../base/config.dart'; +import '../base/file_system.dart'; +import '../base/io.dart'; +import '../base/logger.dart'; +import '../base/os.dart'; +import '../base/platform.dart'; +import '../base/process.dart'; +import '../base/signals.dart'; +import '../base/terminal.dart'; +import '../base/time.dart'; +import '../base/user_messages.dart'; +import '../cache.dart'; +import '../custom_devices/custom_devices_config.dart'; +import '../git.dart'; +import '../native_assets.dart'; +import '../persistent_tool_state.dart'; +import '../pre_run_validator.dart'; +import '../project.dart'; +import '../runner/local_engine.dart'; +import '../version.dart'; + +/// Holds the core, platform-independent services used throughout the tool. +/// +/// This includes host I/O and environment wrappers (such as [FileSystem], +/// [Logger], [Platform], [ProcessManager], [Stdio], [AnsiTerminal], [Signals], +/// and [SystemClock]) and SDK configuration and state (such as [Artifacts], +/// [Cache], [Config], [FlutterVersion], and [FlutterProjectFactory]). +/// +/// [ToolContext] is the lowest layer of the dependency graph and is what most +/// code should depend on: commands, builders, compilers, and test runners take +/// a [ToolContext] rather than [ToolDependencies]. It deliberately excludes the +/// platform toolchains ([AndroidContext], [AppleContext]) and the higher-level +/// services built on top of it (such as [BuildSystem] and [Doctor]), so +/// consumers stay decoupled from platform SDKs and unit tests can use a +/// lightweight fake without constructing the full object graph. +class ToolContext { + ToolContext({ + required this.artifacts, + required this.botDetector, + required this.cache, + required this.config, + required this.customDevicesConfig, + required this.flutterVersion, + required this.fs, + required this.git, + required this.localEngineLocator, + required this.logger, + this.nativeAssetsBuilder, + required this.os, + required this.outputPreferences, + required this.persistentToolState, + required this.platform, + required this.preRunValidator, + required this.processInfo, + required this.processManager, + required this.processUtils, + required this.projectFactory, + required this.shutdownHooks, + required this.signals, + required this.stdio, + required this.systemClock, + required this.terminal, + required this.userMessages, + }); + + /// Cached and host-specific binary artifacts. + final Artifacts artifacts; + + /// Detects whether the tool is running in a CI or automated bot environment. + final BotDetector botDetector; + + /// Manages cached SDK artifacts, binary downloads, and directory structures. + final Cache cache; + + /// Reads and writes user and persistent global configuration settings. + final Config config; + + /// Manages user-configured custom device definitions stored on disk. + final CustomDevicesConfig customDevicesConfig; + + /// Provides version and git channel info for the current Flutter SDK. + final FlutterVersion flutterVersion; + + /// Provides mockable file system operations across host and virtual environments. + final FileSystem fs; + + /// Wraps host `git` operations, tracking repository state and branch revisions. + final Git git; + + /// Locates local engine builds specified via command-line flags. + final LocalEngineLocator localEngineLocator; + + /// Formats and emits console status, trace, warning, and error logs. + final Logger logger; + + /// Builds and packages native C/C++ or Rust assets for compilation and tests. + final TestCompilerNativeAssetsBuilder? nativeAssetsBuilder; + + /// Operating system utilities and environment queries. + final OperatingSystemUtils os; + + /// Manages formatting preferences for console output, such as line wrapping width. + final OutputPreferences outputPreferences; + + /// Global tool internal state that persists across tool invocations. + final PersistentToolState persistentToolState; + + /// Provides host operating system details and environment variables. + final Platform platform; + + /// Validates environment prerequisites and file permissions before command execution. + final PreRunValidator preRunValidator; + + /// Process resource and memory usage reporting. + final ProcessInfo processInfo; + + /// Spawns and manages external host processes. + final ProcessManager processManager; + + /// Utility helpers for executing processes, error handling, and stream capturing. + final ProcessUtils processUtils; + + /// Instantiates and caches [FlutterProject] instances for project directories. + final FlutterProjectFactory projectFactory; + + /// Manages lifecycle callbacks executed upon tool termination or interrupt signals. + final ShutdownHooks shutdownHooks; + + /// Intercepts and dispatches process signals. + final Signals signals; + + /// Provides standard I/O streams (`stdin`, `stdout`, `stderr`). + final Stdio stdio; + + /// Provides mockable system time interfaces for timed operations. + final SystemClock systemClock; + + /// Formats terminal text output, colorization, and ANSI terminal capabilities. + final AnsiTerminal terminal; + + /// Centralized templates for user-facing status strings and error messages. + final UserMessages userMessages; + + /// Common file system utilities. + FileSystemUtils get fileSystemUtils => FileSystemUtils(fileSystem: fs, platform: platform); +} diff --git a/packages/flutter_tools/lib/src/context/tool_dependencies.dart b/packages/flutter_tools/lib/src/context/tool_dependencies.dart new file mode 100644 index 00000000..548e096f --- /dev/null +++ b/packages/flutter_tools/lib/src/context/tool_dependencies.dart @@ -0,0 +1,674 @@ +// Copyright 2014 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. + +/// @docImport '../../executable.dart'; +library; + +import 'dart:async'; + +import 'package:process/process.dart'; +import 'package:unified_analytics/unified_analytics.dart'; + +import '../android/android_sdk.dart'; +import '../android/android_studio.dart'; +import '../android/android_workflow.dart'; +import '../android/gradle_utils.dart'; +import '../android/java.dart'; +import '../artifacts.dart'; +import '../base/bot_detector.dart'; +import '../base/config.dart'; +import '../base/error_handling_io.dart'; +import '../base/file_system.dart'; +import '../base/io.dart'; +import '../base/logger.dart'; +import '../base/os.dart'; +import '../base/platform.dart'; +import '../base/process.dart'; +import '../base/signals.dart'; +import '../base/terminal.dart'; +import '../base/time.dart'; +import '../base/user_messages.dart'; +import '../build_system/build_system.dart'; +import '../build_system/build_targets.dart'; +import '../cache.dart'; +import '../custom_devices/custom_devices_config.dart'; +import '../device.dart'; +import '../doctor.dart'; +import '../emulator.dart'; +import '../experimental/extension_build_manager.dart'; +import '../experimental/extension_discovery.dart'; +import '../experimental/extension_manager.dart'; +import '../features.dart'; +import '../flutter_cache.dart'; +import '../flutter_device_manager.dart'; +import '../flutter_features.dart'; +import '../flutter_features_config.dart'; +import '../flutter_manifest.dart'; +import '../git.dart'; +import '../ios/ios_workflow.dart'; +import '../ios/iproxy.dart'; +import '../ios/plist_parser.dart'; +import '../ios/simulators.dart'; +import '../ios/xcodeproj.dart'; +import '../macos/cocoapods.dart'; +import '../macos/cocoapods_validator.dart'; +import '../macos/macos_workflow.dart'; +import '../macos/xcdevice.dart'; +import '../macos/xcode.dart'; +import '../native_assets.dart'; +import '../persistent_tool_state.dart'; +import '../pre_run_validator.dart'; +import '../project.dart'; +import '../reporting/crash_reporting.dart'; +import '../reporting/unified_analytics.dart'; +import '../runner/local_engine.dart'; +import '../version.dart'; +import '../windows/windows_workflow.dart'; +import 'android_context.dart'; +import 'apple_context.dart'; +import 'tool_context.dart'; + +/// The root of the Flutter tool's dependency graph. +/// +/// [ToolDependencies.bootstrap] is called once per tool invocation, before any +/// command runs, and constructs the tool's long-lived services in dependency +/// order. The result holds the [ToolContext], the platform-specific +/// [AndroidContext] and [AppleContext], and services that don't belong to a +/// single context (such as [Analytics], [BuildSystem], [Doctor], and +/// [FeatureFlags]). +/// +/// Like [ToolContext], it only holds instances that live for the whole tool +/// invocation; the two differ in what they expose, not in lifetime. +/// [ToolDependencies] holds [ToolContext] (not the reverse) and is only used at +/// the composition root: [generateCommands] unpacks it and passes each command +/// only the contexts and services that command needs. Passing +/// [ToolDependencies] itself into commands or services would expose the whole +/// graph to every component and turn it into a service locator. +class ToolDependencies { + ToolDependencies({ + required this.analytics, + required this.androidContext, + required this.appleContext, + required this.buildSystem, + required this.buildTargets, + required this.crashReporter, + required this.deviceManager, + required this.doctor, + required this.emulatorManager, + required this.featureFlags, + required this.toolContext, + this.extensionBuildManager, + this.extensionManager, + }); + + /// Telemetry and analytics reporter for command and feature usage. + final Analytics analytics; + + /// Sub-context containing Android-specific platform services. + final AndroidContext androidContext; + + /// Sub-context containing Apple platform services. + final AppleContext appleContext; + + /// High-performance compilation and build pipeline orchestrator. + final BuildSystem buildSystem; + + /// Factory interface for constructing compile and bundle build targets. + final BuildTargets buildTargets; + + /// Captures and submits unhandled tool crash reports and stack traces. + final CrashReporter crashReporter; + + /// Manager for discovering and filtering connected target devices. + final DeviceManager deviceManager; + + /// System health diagnostics and toolchain validator. + final Doctor doctor; + + /// Manager for discovering, launching, and creating emulators. + final EmulatorManager emulatorManager; + + /// Manager for querying custom build targets and executing builds via tool extensions. + final ExtensionBuildManager? extensionBuildManager; + + /// Manager for discovering and communicating with active tool extensions. + final ExtensionManager? extensionManager; + + /// Feature flags that govern tool capabilities and rollouts. + final FeatureFlags featureFlags; + + /// Core container holding host environment and SDK configuration dependencies. + final ToolContext toolContext; + + /// Bootstraps the dependency graph and constructs all three contexts. + /// + /// [FlutterVersion] uses [Git], which runs processes through the + /// [ErrorHandlingProcessManager], which reads [Analytics] to propagate the + /// analytics-suppression flag to child processes; [Analytics] in turn + /// depends on [FlutterVersion]. To break this cycle, the process manager + /// gets a lazy callback that returns [NoOpAnalytics] until [Analytics] has + /// been constructed. + static Future bootstrap({ + Analytics? analytics, + AndroidSdk? androidSdk, + AndroidSdk? Function()? androidSdkBuilder, + AndroidStudio? androidStudio, + AndroidStudio? Function()? androidStudioBuilder, + AndroidWorkflow? androidWorkflow, + Artifacts? artifacts, + BotDetector? botDetector, + BuildSystem? buildSystem, + BuildTargets? buildTargets, + Cache? cache, + CocoaPods? cocoaPods, + CocoaPodsValidator? cocoapodsValidator, + Config? config, + CrashReporter? crashReporter, + CustomDevicesConfig? customDevicesConfig, + DeviceManager? deviceManager, + Doctor? doctor, + EmulatorManager? emulatorManager, + List extensionEntryPoints = const [], + FeatureFlags? featureFlags, + FlutterVersion? flutterVersion, + FileSystem? fs, + Git? git, + GradleUtils? gradleUtils, + GradleUtils Function()? gradleUtilsBuilder, + IOSSimulatorUtils? iosSimulatorUtils, + IOSWorkflow? iosWorkflow, + Java? java, + Java? Function()? javaBuilder, + LocalEngineLocator? localEngineLocator, + Logger? logger, + MacOSWorkflow? macOSWorkflow, + TestCompilerNativeAssetsBuilder? nativeAssetsBuilder, + OutputPreferences? outputPreferences, + PersistentToolState? persistentToolState, + Platform? platform, + PlistParser? plistParser, + PreRunValidator? preRunValidator, + ProcessInfo? processInfo, + ProcessManager? processManager, + FlutterProjectFactory? projectFactory, + ShutdownHooks? shutdownHooks, + Stdio? stdio, + SystemClock? systemClock, + AnsiTerminal? terminal, + UserMessages? userMessages, + WindowsWorkflow? windowsWorkflow, + XCDevice? xcdevice, + Xcode? xcode, + XcodeProjectInterpreter? xcodeProjectInterpreter, + }) async { + assert( + androidSdk == null || androidSdkBuilder == null, + 'Cannot provide both androidSdk and androidSdkBuilder to ToolDependencies.bootstrap.', + ); + assert( + androidStudio == null || androidStudioBuilder == null, + 'Cannot provide both androidStudio and androidStudioBuilder to ToolDependencies.bootstrap.', + ); + assert( + gradleUtils == null || gradleUtilsBuilder == null, + 'Cannot provide both gradleUtils and gradleUtilsBuilder to ToolDependencies.bootstrap.', + ); + assert( + java == null || javaBuilder == null, + 'Cannot provide both java and javaBuilder to ToolDependencies.bootstrap.', + ); + + // 1. Core Platform Inputs + final Platform finalPlatform = platform ?? const LocalPlatform(); + final SystemClock finalSystemClock = systemClock ?? const SystemClock(); + final UserMessages finalUserMessages = userMessages ?? UserMessages(); + final ShutdownHooks finalShutdownHooks = shutdownHooks ?? ShutdownHooks(); + final Stdio finalStdio = stdio ?? Stdio(); + + // 2. Terminal and Preferences + final AnsiTerminal finalTerminal = + terminal ?? + AnsiTerminal( + stdio: finalStdio, + platform: finalPlatform, + now: finalSystemClock.now(), + shutdownHooks: finalShutdownHooks, + ); + + final OutputPreferences finalOutputPreferences = + outputPreferences ?? + OutputPreferences( + wrapText: finalStdio.hasTerminal, + showColor: finalPlatform.stdoutSupportsAnsi, + stdio: finalStdio, + ); + + // 3. Logger + final Logger finalLogger = + logger ?? + (finalPlatform.isWindows + ? WindowsStdoutLogger( + terminal: finalTerminal, + stdio: finalStdio, + outputPreferences: finalOutputPreferences, + ) + : StdoutLogger( + terminal: finalTerminal, + stdio: finalStdio, + outputPreferences: finalOutputPreferences, + )); + + // 4. File System + final finalFS = fs == null + ? ErrorHandlingFileSystem( + delegate: LocalFileSystem( + LocalSignals.instance, + Signals.defaultExitSignals, + finalShutdownHooks, + ), + platform: finalPlatform, + ) + : ErrorHandlingFileSystem(delegate: fs, platform: finalPlatform); + + // 5. Bot Detector and Config + final PersistentToolState finalPersistentToolState = + persistentToolState ?? + PersistentToolState(fileSystem: finalFS, logger: finalLogger, platform: finalPlatform); + + final BotDetector finalBotDetector = + botDetector ?? + BotDetector( + httpClientFactory: () => HttpClient(), + platform: finalPlatform, + persistentToolState: finalPersistentToolState, + ); + + final bool isBot = await finalBotDetector.isRunningOnBot; + + final Config finalConfig = + config ?? + Config( + Config.kFlutterSettings, + fileSystem: finalFS, + logger: finalLogger, + platform: finalPlatform, + ); + + // 6. Process Management & Git (resolving cycle via lazy analytics closure) + var finalAnalyticsInitialized = false; + late final Analytics finalAnalytics; + + final finalProcessManager = ErrorHandlingProcessManager( + delegate: processManager ?? const LocalProcessManager(), + platform: finalPlatform, + analytics: () => finalAnalyticsInitialized ? finalAnalytics : const NoOpAnalytics(), + ); + + final finalProcessUtils = ProcessUtils( + processManager: finalProcessManager, + logger: finalLogger, + ); + + final Git finalGit = + git ?? Git(currentPlatform: finalPlatform, runProcessWith: finalProcessUtils); + + // 7. Flutter Version and Cache + final String flutterRoot = + Cache.flutterRoot ?? + Cache.defaultFlutterRoot( + platform: finalPlatform, + fileSystem: finalFS, + userMessages: finalUserMessages, + ); + Cache.flutterRoot ??= flutterRoot; + + final FlutterVersion finalFlutterVersion = + flutterVersion ?? FlutterVersion(fs: finalFS, flutterRoot: flutterRoot, git: finalGit); + + // 8. Analytics + finalAnalytics = + analytics ?? + getAnalytics( + runningOnBot: isBot, + flutterVersion: finalFlutterVersion, + environment: finalPlatform.environment, + clientIde: finalPlatform.environment['FLUTTER_HOST'], + config: finalConfig, + ); + finalAnalyticsInitialized = true; + + // 9. Project Factory and Operating System Utilities + final FlutterProjectFactory finalProjectFactory = + projectFactory ?? FlutterProjectFactory(logger: finalLogger, fileSystem: finalFS); + + final finalOS = OperatingSystemUtils( + fileSystem: finalFS, + logger: finalLogger, + platform: finalPlatform, + processManager: finalProcessManager, + ); + + final Cache finalCache = + cache ?? + FlutterCache( + fileSystem: finalFS, + logger: finalLogger, + platform: finalPlatform, + osUtils: finalOS, + projectFactory: finalProjectFactory, + stdio: finalStdio, + ); + + // 10. Remaining ToolContext Dependencies + final BuildSystem finalBuildSystem = + buildSystem ?? + FlutterBuildSystem(fileSystem: finalFS, logger: finalLogger, platform: finalPlatform); + + final BuildTargets finalBuildTargets = buildTargets ?? const NoOpBuildTargets(); + + final CrashReporter finalCrashReporter = + crashReporter ?? + CrashReporter( + fileSystem: finalFS, + logger: finalLogger, + flutterProjectFactory: finalProjectFactory, + ); + + final CustomDevicesConfig finalCustomDevicesConfig = + customDevicesConfig ?? + CustomDevicesConfig(fileSystem: finalFS, logger: finalLogger, platform: finalPlatform); + + final PreRunValidator finalPreRunValidator = + preRunValidator ?? PreRunValidator(fileSystem: finalFS); + + final ProcessInfo finalProcessInfo = processInfo ?? ProcessInfo(finalFS); + + final LocalEngineLocator finalLocalEngineLocator = + localEngineLocator ?? + LocalEngineLocator( + userMessages: finalUserMessages, + logger: finalLogger, + platform: finalPlatform, + fileSystem: finalFS, + flutterRoot: flutterRoot, + ); + + final finalNativeAssetsBuilder = nativeAssetsBuilder; + + // 11. AppleContext Dependencies + final XcodeProjectInterpreter finalXcodeProjectInterpreter = + xcodeProjectInterpreter ?? + XcodeProjectInterpreter( + platform: finalPlatform, + processManager: finalProcessManager, + logger: finalLogger, + fileSystem: finalFS, + analytics: finalAnalytics, + ); + + final Xcode finalXcode = + xcode ?? + Xcode( + platform: finalPlatform, + processManager: finalProcessManager, + logger: finalLogger, + fileSystem: finalFS, + xcodeProjectInterpreter: finalXcodeProjectInterpreter, + userMessages: finalUserMessages, + ); + + final CocoaPods finalCocoaPods = + cocoaPods ?? + CocoaPods( + fileSystem: finalFS, + processManager: finalProcessManager, + logger: finalLogger, + platform: finalPlatform, + xcodeProjectInterpreter: finalXcodeProjectInterpreter, + analytics: finalAnalytics, + ); + + final CocoaPodsValidator finalCocoapodsValidator = + cocoapodsValidator ?? CocoaPodsValidator(finalCocoaPods, finalUserMessages); + + // Artifacts will be updated later if a local engine is used. + final Artifacts finalArtifacts = switch (artifacts) { + final DeferredArtifacts deferredArtifacts => deferredArtifacts, + final Artifacts providedArtifacts => DeferredArtifacts(providedArtifacts), + null => DeferredArtifacts( + CachedArtifacts( + fileSystem: finalFS, + cache: finalCache, + platform: finalPlatform, + operatingSystemUtils: finalOS, + ), + ), + }; + + final XCDevice finalXCDevice = + xcdevice ?? + XCDevice( + processManager: finalProcessManager, + logger: finalLogger, + artifacts: finalArtifacts, + cache: finalCache, + platform: finalPlatform, + xcode: finalXcode, + iproxy: IProxy( + artifacts: finalArtifacts, + logger: finalLogger, + processManager: finalProcessManager, + dyLdLibEntry: finalCache.dyLdLibEntry, + ), + fileSystem: finalFS, + analytics: finalAnalytics, + shutdownHooks: finalShutdownHooks, + ); + + final String projectRoot = findProjectRoot(finalFS) ?? finalFS.currentDirectory.path; + final FlutterManifest? projectManifest = FlutterManifest.createFromPath( + finalFS.path.join(projectRoot, 'pubspec.yaml'), + fileSystem: finalFS, + logger: finalLogger, + ); + + final FeatureFlags finalFeatureFlags = + featureFlags ?? + FlutterFeatureFlags( + flutterVersion: finalFlutterVersion, + featuresConfig: FlutterFeaturesConfig( + globalConfig: finalConfig, + platform: finalPlatform, + projectManifest: projectManifest, + ), + platform: finalPlatform, + ); + + final IOSWorkflow finalIOSWorkflow = + iosWorkflow ?? + IOSWorkflow(featureFlags: finalFeatureFlags, xcode: finalXcode, platform: finalPlatform); + + final IOSSimulatorUtils finalIOSSimulatorUtils = + iosSimulatorUtils ?? + IOSSimulatorUtils( + logger: finalLogger, + operatingSystemUtils: finalOS, + processManager: finalProcessManager, + xcode: finalXcode, + ); + + final PlistParser finalPlistParser = + plistParser ?? + PlistParser(fileSystem: finalFS, processManager: finalProcessManager, logger: finalLogger); + + final finalToolContext = ToolContext( + artifacts: finalArtifacts, + botDetector: finalBotDetector, + cache: finalCache, + config: finalConfig, + customDevicesConfig: finalCustomDevicesConfig, + flutterVersion: finalFlutterVersion, + fs: finalFS, + git: finalGit, + localEngineLocator: finalLocalEngineLocator, + logger: finalLogger, + nativeAssetsBuilder: finalNativeAssetsBuilder, + os: finalOS, + outputPreferences: finalOutputPreferences, + persistentToolState: finalPersistentToolState, + platform: finalPlatform, + preRunValidator: finalPreRunValidator, + processInfo: finalProcessInfo, + processManager: finalProcessManager, + processUtils: finalProcessUtils, + projectFactory: finalProjectFactory, + shutdownHooks: finalShutdownHooks, + signals: LocalSignals.instance, + stdio: finalStdio, + systemClock: finalSystemClock, + terminal: finalTerminal, + userMessages: finalUserMessages, + ); + + // 12. AndroidContext Dependencies + final AndroidSdk? Function() finalAndroidSdkBuilder = + androidSdkBuilder ?? + (androidSdk != null + ? () => androidSdk + : () => AndroidSdk.locateAndroidSdk(toolContext: finalToolContext)); + + final AndroidStudio? Function() finalAndroidStudioBuilder = + androidStudioBuilder ?? + (androidStudio != null + ? () => androidStudio + : () => AndroidStudio.latestValid( + plistParser: finalPlistParser, + toolContext: finalToolContext, + )); + + late final AndroidContext finalAndroidContext; + + final Java? Function() finalJavaBuilder = + javaBuilder ?? + (java != null + ? () => java + : () => Java.find( + androidStudioBuilder: () => finalAndroidContext.androidStudio, + config: finalConfig, + fileSystem: finalFS, + logger: finalLogger, + platform: finalPlatform, + processManager: finalProcessManager, + )); + + final GradleUtils Function() finalGradleUtilsBuilder = + gradleUtilsBuilder ?? + (gradleUtils != null + ? () => gradleUtils + : () => GradleUtils( + platform: finalPlatform, + logger: finalLogger, + cache: finalCache, + operatingSystemUtils: finalOS, + )); + + finalAndroidContext = AndroidContext( + androidSdkBuilder: finalAndroidSdkBuilder, + androidStudioBuilder: finalAndroidStudioBuilder, + gradleUtilsBuilder: finalGradleUtilsBuilder, + javaBuilder: finalJavaBuilder, + ); + + // 13. Doctor, EmulatorManager, and DeviceManager Dependencies + final Doctor finalDoctor = + doctor ?? Doctor(clock: finalSystemClock, logger: finalLogger, analytics: finalAnalytics); + + final AndroidWorkflow finalAndroidWorkflow = + androidWorkflow ?? + AndroidWorkflow( + androidSdkBuilder: () => finalAndroidContext.androidSdk, + featureFlags: finalFeatureFlags, + ); + + final EmulatorManager finalEmulatorManager = + emulatorManager ?? + EmulatorManager( + androidSdkBuilder: () => finalAndroidContext.androidSdk, + androidWorkflow: finalAndroidWorkflow, + fileSystem: finalFS, + javaBuilder: () => finalAndroidContext.java, + logger: finalLogger, + processManager: finalProcessManager, + ); + + final MacOSWorkflow finalMacOSWorkflow = + macOSWorkflow ?? MacOSWorkflow(featureFlags: finalFeatureFlags, platform: finalPlatform); + + final WindowsWorkflow finalWindowsWorkflow = + windowsWorkflow ?? + WindowsWorkflow(featureFlags: finalFeatureFlags, platform: finalPlatform); + + final extensionManager = ExtensionManager( + entryPoints: extensionEntryPoints, + featureFlags: finalFeatureFlags, + hostPlatform: finalOS.hostPlatform, + logger: finalLogger, + ); + + final extensionBuildManager = ExtensionBuildManager( + extensionManager: extensionManager, + featureFlags: finalFeatureFlags, + logger: finalLogger, + ); + + final DeviceManager finalDeviceManager = + deviceManager ?? + FlutterDeviceManager( + logger: finalLogger, + platform: finalPlatform, + processManager: finalProcessManager, + fileSystem: finalFS, + androidSdkBuilder: () => finalAndroidContext.androidSdk, + featureFlags: finalFeatureFlags, + iosSimulatorUtils: finalIOSSimulatorUtils, + xcDevice: finalXCDevice, + androidWorkflow: finalAndroidWorkflow, + iosWorkflow: finalIOSWorkflow, + flutterVersion: finalFlutterVersion, + artifacts: finalArtifacts, + macOSWorkflow: finalMacOSWorkflow, + userMessages: finalUserMessages, + operatingSystemUtils: finalOS, + windowsWorkflow: finalWindowsWorkflow, + customDevicesConfig: finalCustomDevicesConfig, + nativeAssetsBuilder: finalNativeAssetsBuilder, + extensionManager: extensionManager, + ); + + return ToolDependencies( + analytics: finalAnalytics, + androidContext: finalAndroidContext, + appleContext: AppleContext( + cocoaPods: finalCocoaPods, + cocoapodsValidator: finalCocoapodsValidator, + iosSimulatorUtils: finalIOSSimulatorUtils, + iosWorkflow: finalIOSWorkflow, + plistParser: finalPlistParser, + xcdevice: finalXCDevice, + xcode: finalXcode, + xcodeProjectInterpreter: finalXcodeProjectInterpreter, + ), + buildSystem: finalBuildSystem, + buildTargets: finalBuildTargets, + crashReporter: finalCrashReporter, + deviceManager: finalDeviceManager, + doctor: finalDoctor, + emulatorManager: finalEmulatorManager, + extensionBuildManager: extensionBuildManager, + extensionManager: extensionManager, + featureFlags: finalFeatureFlags, + toolContext: finalToolContext, + ); + } +} diff --git a/packages/flutter_tools/lib/src/context_runner.dart b/packages/flutter_tools/lib/src/context_runner.dart index 69cd533f..f96e9c6b 100644 --- a/packages/flutter_tools/lib/src/context_runner.dart +++ b/packages/flutter_tools/lib/src/context_runner.dart @@ -17,29 +17,32 @@ import 'android/java.dart'; import 'application_package.dart'; import 'artifacts.dart'; import 'asset.dart'; +import 'base/bot_detector.dart'; import 'base/config.dart'; import 'base/context.dart'; import 'base/error_handling_io.dart'; +import 'base/file_system.dart'; import 'base/io.dart'; import 'base/logger.dart'; import 'base/os.dart'; +import 'base/platform.dart'; import 'base/process.dart'; +import 'base/signals.dart'; import 'base/terminal.dart'; import 'base/time.dart'; import 'base/user_messages.dart'; import 'build_system/build_system.dart'; import 'cache.dart'; +import 'context/tool_context.dart'; import 'custom_devices/custom_devices_config.dart'; import 'dart/pub.dart'; import 'devfs.dart'; -import 'device.dart'; import 'devtools_launcher.dart'; import 'doctor.dart'; import 'emulator.dart'; import 'features.dart'; import 'flutter_application_package.dart'; import 'flutter_cache.dart'; -import 'flutter_device_manager.dart'; import 'flutter_features.dart'; import 'flutter_features_config.dart'; import 'flutter_manifest.dart'; @@ -55,7 +58,10 @@ import 'macos/macos_workflow.dart'; import 'macos/xcdevice.dart'; import 'macos/xcode.dart'; import 'mdns_discovery.dart'; +import 'native_assets.dart'; import 'persistent_tool_state.dart'; +import 'pre_run_validator.dart'; +import 'project.dart'; import 'reporting/crash_reporting.dart'; import 'reporting/unified_analytics.dart'; import 'resident_runner.dart'; @@ -67,6 +73,92 @@ import 'windows/visual_studio.dart'; import 'windows/visual_studio_validator.dart'; import 'windows/windows_workflow.dart'; +/// A [ToolContext] that delegates to [globals] for legacy callers. +class GlobalToolContext implements ToolContext { + const GlobalToolContext(); + + @override + Artifacts get artifacts => globals.artifacts!; + + @override + BotDetector get botDetector => globals.botDetector; + + @override + Cache get cache => globals.cache; + + @override + Config get config => globals.config; + + @override + CustomDevicesConfig get customDevicesConfig => globals.customDevicesConfig; + + @override + FileSystemUtils get fileSystemUtils => globals.fsUtils; + + @override + FlutterVersion get flutterVersion => globals.flutterVersion; + + @override + FileSystem get fs => globals.fs; + + @override + Git get git => globals.git; + + @override + LocalEngineLocator get localEngineLocator => globals.localEngineLocator!; + + @override + Logger get logger => globals.logger; + + @override + TestCompilerNativeAssetsBuilder? get nativeAssetsBuilder => globals.nativeAssetsBuilder; + + @override + OperatingSystemUtils get os => globals.os; + + @override + OutputPreferences get outputPreferences => globals.outputPreferences; + + @override + PersistentToolState get persistentToolState => globals.persistentToolState!; + + @override + Platform get platform => globals.platform; + + @override + PreRunValidator get preRunValidator => globals.preRunValidator; + + @override + ProcessInfo get processInfo => globals.processInfo; + + @override + ProcessManager get processManager => globals.processManager; + + @override + ProcessUtils get processUtils => globals.processUtils; + + @override + FlutterProjectFactory get projectFactory => globals.projectFactory; + + @override + ShutdownHooks get shutdownHooks => globals.shutdownHooks; + + @override + Signals get signals => globals.signals; + + @override + Stdio get stdio => globals.stdio; + + @override + SystemClock get systemClock => globals.systemClock; + + @override + AnsiTerminal get terminal => globals.terminal; + + @override + UserMessages get userMessages => globals.userMessages; +} + Future runInContext(FutureOr Function() runner, {Map? overrides}) async { // Wrap runner with any asynchronous initialization that should run with the // overrides and callbacks. @@ -101,6 +193,34 @@ Future runInContext(FutureOr Function() runner, {Map? platform: globals.platform, androidStudio: globals.androidStudio, androidSdk: globals.androidSdk, + toolContext: ToolContext( + artifacts: globals.artifacts!, + botDetector: globals.botDetector, + cache: globals.cache, + config: globals.config, + customDevicesConfig: globals.customDevicesConfig, + flutterVersion: globals.flutterVersion, + fs: globals.fs, + git: globals.git, + localEngineLocator: globals.localEngineLocator!, + logger: globals.logger, + nativeAssetsBuilder: globals.nativeAssetsBuilder, + os: globals.os, + outputPreferences: globals.outputPreferences, + persistentToolState: globals.persistentToolState!, + platform: globals.platform, + preRunValidator: globals.preRunValidator, + processInfo: globals.processInfo, + processManager: globals.processManager, + processUtils: globals.processUtils, + projectFactory: globals.projectFactory, + shutdownHooks: globals.shutdownHooks, + signals: globals.signals, + stdio: globals.stdio, + systemClock: globals.systemClock, + terminal: globals.terminal, + userMessages: globals.userMessages, + ), ), AndroidLicenseValidator: () => AndroidLicenseValidator( platform: globals.platform, @@ -111,8 +231,11 @@ Future runInContext(FutureOr Function() runner, {Map? logger: globals.logger, stdio: globals.stdio, ), - AndroidSdk: AndroidSdk.locateAndroidSdk, - AndroidStudio: AndroidStudio.latestValid, + AndroidSdk: () => AndroidSdk.locateAndroidSdk(toolContext: const GlobalToolContext()), + AndroidStudio: () => AndroidStudio.latestValid( + plistParser: globals.plistParser, + toolContext: const GlobalToolContext(), + ), AndroidValidator: () => AndroidValidator( java: globals.java, androidSdk: globals.androidSdk, @@ -123,7 +246,7 @@ Future runInContext(FutureOr Function() runner, {Map? osUtils: globals.os, ), AndroidWorkflow: () => - AndroidWorkflow(androidSdk: globals.androidSdk, featureFlags: featureFlags), + AndroidWorkflow(androidSdkBuilder: () => globals.androidSdk, featureFlags: featureFlags), ApplicationPackageFactory: () => FlutterApplicationPackageFactory( userMessages: globals.userMessages, processManager: globals.processManager, @@ -183,26 +306,6 @@ Future runInContext(FutureOr Function() runner, {Map? flutterProjectFactory: globals.projectFactory, ), DevFSConfig: () => DevFSConfig(), - DeviceManager: () => FlutterDeviceManager( - logger: globals.logger, - processManager: globals.processManager, - platform: globals.platform, - androidSdk: globals.androidSdk, - iosSimulatorUtils: globals.iosSimulatorUtils!, - featureFlags: featureFlags, - fileSystem: globals.fs, - iosWorkflow: globals.iosWorkflow!, - artifacts: globals.artifacts!, - flutterVersion: globals.flutterVersion, - androidWorkflow: androidWorkflow!, - xcDevice: globals.xcdevice!, - userMessages: globals.userMessages, - windowsWorkflow: windowsWorkflow!, - macOSWorkflow: MacOSWorkflow(platform: globals.platform, featureFlags: featureFlags), - operatingSystemUtils: globals.os, - customDevicesConfig: globals.customDevicesConfig, - nativeAssetsBuilder: globals.nativeAssetsBuilder, - ), DevtoolsLauncher: () => DevtoolsServerLauncher( processManager: globals.processManager, artifacts: globals.artifacts!, @@ -212,8 +315,8 @@ Future runInContext(FutureOr Function() runner, {Map? Doctor: () => Doctor(logger: globals.logger, clock: globals.systemClock), DoctorValidatorsProvider: () => DoctorValidatorsProvider.defaultInstance, EmulatorManager: () => EmulatorManager( - java: globals.java, - androidSdk: globals.androidSdk, + javaBuilder: () => globals.java, + androidSdkBuilder: () => globals.androidSdk, processManager: globals.processManager, logger: globals.logger, fileSystem: globals.fs, @@ -257,10 +360,10 @@ Future runInContext(FutureOr Function() runner, {Map? platform: globals.platform, ), Java: () => Java.find( + androidStudioBuilder: () => globals.androidStudio, config: globals.config, - androidStudio: globals.androidStudio, - logger: globals.logger, fileSystem: globals.fs, + logger: globals.logger, platform: globals.platform, processManager: globals.processManager, ), @@ -348,7 +451,7 @@ Future runInContext(FutureOr Function() runner, {Map? platform: globals.platform, xcode: globals.xcode!, iproxy: IProxy( - iproxyPath: globals.artifacts!.getHostArtifact(HostArtifact.iproxy).path, + artifacts: globals.artifacts!, logger: globals.logger, processManager: globals.processManager, dyLdLibEntry: globals.cache.dyLdLibEntry, diff --git a/packages/flutter_tools/lib/src/convert.dart b/packages/flutter_tools/lib/src/convert.dart index 6383d02d..b54e02e2 100644 --- a/packages/flutter_tools/lib/src/convert.dart +++ b/packages/flutter_tools/lib/src/convert.dart @@ -99,6 +99,5 @@ class Utf8Decoder extends Converter, String> { Stream bind(Stream> stream) => _allowMalformedDecoder.bind(stream); @override - Converter, T> fuse(Converter other) => - _allowMalformedDecoder.fuse(other); + Converter, T> fuse(Converter other) => _allowMalformedDecoder.fuse(other); } diff --git a/packages/flutter_tools/lib/src/custom_devices/custom_device.dart b/packages/flutter_tools/lib/src/custom_devices/custom_device.dart index 55e70bf4..b0da33a3 100644 --- a/packages/flutter_tools/lib/src/custom_devices/custom_device.dart +++ b/packages/flutter_tools/lib/src/custom_devices/custom_device.dart @@ -120,19 +120,15 @@ class CustomDeviceLogReader extends DeviceLogReader { /// A [DevicePortForwarder] that uses commands to forward / unforward a port. class CustomDevicePortForwarder extends DevicePortForwarder { CustomDevicePortForwarder({ - required String deviceName, - required List forwardPortCommand, - required RegExp forwardPortSuccessRegex, + required this._deviceName, + required this._forwardPortCommand, + required this._forwardPortSuccessRegex, this.numTries, required ProcessManager processManager, required Logger logger, - Map additionalReplacementValues = const {}, - }) : _deviceName = deviceName, - _forwardPortCommand = forwardPortCommand, - _forwardPortSuccessRegex = forwardPortSuccessRegex, - _processManager = processManager, - _processUtils = ProcessUtils(processManager: processManager, logger: logger), - _additionalReplacementValues = additionalReplacementValues; + this._additionalReplacementValues = const {}, + }) : _processManager = processManager, + _processUtils = ProcessUtils(processManager: processManager, logger: logger); final String _deviceName; final List _forwardPortCommand; @@ -245,13 +241,11 @@ class CustomDevicePortForwarder extends DevicePortForwarder { class CustomDeviceAppSession { CustomDeviceAppSession({ required this.name, - required CustomDevice device, - required ApplicationPackage appPackage, + required this._device, + required this._appPackage, required Logger logger, required ProcessManager processManager, - }) : _appPackage = appPackage, - _device = device, - _logger = logger, + }) : _logger = logger, _processManager = processManager, _processUtils = ProcessUtils(processManager: processManager, logger: logger), logReader = CustomDeviceLogReader(name); @@ -349,12 +343,15 @@ class CustomDeviceAppSession { if (packageName == null) { throwToolExit('Could not start app, name for $_appPackage is unknown.'); } - final List interpolated = - interpolateCommand(_device._config.runDebugCommand, { - 'remotePath': '/tmp/', - 'appName': packageName, - 'engineOptions': _getEngineOptionsForCmdline(debuggingOptions, traceStartup, route), - }, additionalReplacementValues: additionalReplacementValues); + final List interpolated = interpolateCommand( + _device._config.runDebugCommand, + { + 'remotePath': '/tmp/', + 'appName': packageName, + 'engineOptions': _getEngineOptionsForCmdline(debuggingOptions, traceStartup, route), + }, + additionalReplacementValues: additionalReplacementValues, + ); final Process process = await _processUtils.start(interpolated); assert(_process == null); @@ -490,7 +487,7 @@ class CustomDevice extends Device { /// /// If [timeout] is not null and the process doesn't finish in time, /// it will be killed with a SIGTERM, false will be returned and the timeout - /// will be reported in the log using [Logger.printError]. If [timeout] + /// will be reported in the log using [Logger.printTrace]. If [timeout] /// is null, it's treated as if it's an infinite timeout. Future tryPing({ Duration? timeout, @@ -498,7 +495,13 @@ class CustomDevice extends Device { }) async { final List interpolated = interpolateCommand(_config.pingCommand, replacementValues); - final RunResult result = await _processUtils.run(interpolated, timeout: timeout); + final RunResult result; + try { + result = await _processUtils.run(interpolated, timeout: timeout); + } on ProcessException catch (e) { + _logger.printTrace('Error pinging custom device $id: $e'); + return false; + } if (result.exitCode != 0) { return false; @@ -805,13 +808,10 @@ class CustomDevices extends PollingDeviceDiscovery { /// given [CustomDevicesConfig]. CustomDevices({ required FeatureFlags featureFlags, - required ProcessManager processManager, - required Logger logger, - required CustomDevicesConfig config, + required this._processManager, + required this._logger, + required this._config, }) : _customDeviceWorkflow = CustomDeviceWorkflow(featureFlags: featureFlags), - _logger = logger, - _processManager = processManager, - _config = config, super('custom devices'); final CustomDeviceWorkflow _customDeviceWorkflow; diff --git a/packages/flutter_tools/lib/src/custom_devices/custom_device_workflow.dart b/packages/flutter_tools/lib/src/custom_devices/custom_device_workflow.dart index cd0517df..b49d0db9 100644 --- a/packages/flutter_tools/lib/src/custom_devices/custom_device_workflow.dart +++ b/packages/flutter_tools/lib/src/custom_devices/custom_device_workflow.dart @@ -15,7 +15,7 @@ import '../features.dart'; /// Can't list emulators at all. @immutable class CustomDeviceWorkflow implements Workflow { - const CustomDeviceWorkflow({required FeatureFlags featureFlags}) : _featureFlags = featureFlags; + const CustomDeviceWorkflow({required this._featureFlags}); final FeatureFlags _featureFlags; diff --git a/packages/flutter_tools/lib/src/custom_devices/custom_devices_config.dart b/packages/flutter_tools/lib/src/custom_devices/custom_devices_config.dart index fb811fde..177ea4a6 100644 --- a/packages/flutter_tools/lib/src/custom_devices/custom_devices_config.dart +++ b/packages/flutter_tools/lib/src/custom_devices/custom_devices_config.dart @@ -36,12 +36,11 @@ class CustomDevicesConfig { @visibleForTesting CustomDevicesConfig.test({ - required FileSystem fileSystem, + required this._fileSystem, required Logger logger, Directory? directory, Platform? platform, }) : _platform = platform ?? FakePlatform(), - _fileSystem = fileSystem, _logger = logger, _configLoader = (() => Config.test( name: _kCustomDevicesConfigName, diff --git a/packages/flutter_tools/lib/src/daemon.dart b/packages/flutter_tools/lib/src/daemon.dart index d60ac2ef..218fafbe 100644 --- a/packages/flutter_tools/lib/src/daemon.dart +++ b/packages/flutter_tools/lib/src/daemon.dart @@ -172,18 +172,26 @@ class DaemonStreams { DaemonStreams( Stream> rawInputStream, StreamSink> outputSink, { - required Logger logger, + required this._logger, }) : _outputSink = outputSink, - inputStream = DaemonInputStreamConverter(rawInputStream).convertedStream, - _logger = logger; + inputStream = DaemonInputStreamConverter(rawInputStream).convertedStream; /// Creates a [DaemonStreams] that uses stdin and stdout as the underlying streams. DaemonStreams.fromStdio(Stdio stdio, {required Logger logger}) : this(stdio.stdin, stdio.stdout, logger: logger); /// Creates a [DaemonStreams] that uses [Socket] as the underlying streams. - DaemonStreams.fromSocket(Socket socket, {required Logger logger}) - : this(socket, socket, logger: logger); + factory DaemonStreams.fromSocket(Socket socket, {required Logger logger}) { + // We have to listen to socket.done. Otherwise when the connection is + // reset, we will receive an uncatchable exception. + // https://github.com/dart-lang/sdk/issues/25518 + unawaited( + socket.done.handleError((Object error, StackTrace stackTrace) { + logger.printTrace('Socket error: $error\n$stackTrace'); + }), + ); + return DaemonStreams(socket, socket, logger: logger); + } /// Connects to a server and creates a [DaemonStreams] from the connection as the underlying streams. factory DaemonStreams.connect(String host, int port, {required Logger logger}) { @@ -192,8 +200,30 @@ class DaemonStreams { final outputStreamController = StreamController>(); socketFuture.then( (Socket socket) { - inputStreamController.addStream(socket); - socket.addStream(outputStreamController.stream); + // We have to listen to socket.done. Otherwise when the connection is + // reset, we will receive an uncatchable exception. + // https://github.com/dart-lang/sdk/issues/25518 + unawaited( + socket.done.handleError((Object error, StackTrace stackTrace) { + logger.printTrace('Socket error: $error\n$stackTrace'); + }), + ); + unawaited( + inputStreamController.addStream(socket).handleError(( + Object error, + StackTrace stackTrace, + ) { + logger.printTrace('Error adding socket to input stream: $error\n$stackTrace'); + }), + ); + unawaited( + socket.addStream(outputStreamController.stream).handleError(( + Object error, + StackTrace stackTrace, + ) { + logger.printTrace('Error adding output stream to socket: $error\n$stackTrace'); + }), + ); }, onError: (Object error, StackTrace stackTrace) { logger.printError('Socket error: $error'); @@ -241,9 +271,8 @@ class DaemonStreams { /// Connection between a flutter daemon and a client. class DaemonConnection { - DaemonConnection({required DaemonStreams daemonStreams, required Logger logger}) - : _logger = logger, - _daemonStreams = daemonStreams { + DaemonConnection({required DaemonStreams daemonStreams, required this._logger}) + : _daemonStreams = daemonStreams { _commandSubscription = daemonStreams.inputStream.listen( _handleMessage, onError: (Object error, StackTrace stackTrace) { diff --git a/packages/flutter_tools/lib/src/dart/analysis.dart b/packages/flutter_tools/lib/src/dart/analysis.dart index 316c9f10..abc5e86f 100644 --- a/packages/flutter_tools/lib/src/dart/analysis.dart +++ b/packages/flutter_tools/lib/src/dart/analysis.dart @@ -14,27 +14,26 @@ import '../base/platform.dart'; import '../base/terminal.dart'; import '../base/utils.dart'; import '../convert.dart'; -import '../globals.dart' as globals; +import '../vmservice.dart'; /// An interface to the Dart analysis server. class AnalysisServer { AnalysisServer( this.sdkPath, this.directories, { - required FileSystem fileSystem, - required ProcessManager processManager, - required Logger logger, - required Platform platform, - required Terminal terminal, + required this._fileSystem, + required this._processManager, + required this._logger, + required this._platform, + required this._terminal, required this.suppressAnalytics, - String? protocolTrafficLog, - }) : _fileSystem = fileSystem, - _processManager = processManager, - _logger = logger, - _platform = platform, - _terminal = terminal, - _protocolTrafficLog = protocolTrafficLog; + this.withFineDependencies = true, + this.usePlugins = true, + this._protocolTrafficLog, + }); + final bool withFineDependencies; + final bool usePlugins; final String sdkPath; final List directories; final FileSystem _fileSystem; @@ -46,38 +45,24 @@ class AnalysisServer { final bool suppressAnalytics; Process? _process; + StreamSubscription? _errorStreamSubscription; + StreamSubscription>? _stdoutStreamSubscription; final _analyzingController = StreamController.broadcast(); final _errorsController = StreamController.broadcast(); var _didServerErrorOccur = false; - /// Whether the server is currently analyzing. - bool get isAnalyzing => _isAnalyzing; - bool _isAnalyzing = false; - - /// Returns a [Future] that completes when the server is no longer analyzing. + /// Returns a [Future] that completes when the server has completed any + /// in-progress initialization or analysis. /// - /// If [delay] is provided, this method will wait for that duration before - /// checking if the server is analyzing. if the server starts analyzing during - /// that duration, it will wait for analysis to complete. + /// This method will wait for [delay] before calling the server. If not + /// provided, defaults to 100ms. /// /// This is useful to avoid the race condition where analysis hasn't started /// yet after a file change. Future waitForAnalysis({Duration delay = const Duration(milliseconds: 100)}) async { - if (_isAnalyzing) { - await onAnalyzing.firstWhere((bool analyzing) => !analyzing); - } - if (delay != Duration.zero) { - // Wait for analysis to potentially start. - try { - await onAnalyzing.firstWhere((bool analyzing) => analyzing).timeout(delay); - // If analysis started, wait for it to finish. - if (_isAnalyzing) { - await onAnalyzing.firstWhere((bool analyzing) => !analyzing); - } - } on TimeoutException { - // Analysis didn't start within the delay, so we assume it's not going to. - } - } + await Future.delayed(delay); + + await sendRequest('dart/workspace/analysis/complete', {}); } var _id = 0; @@ -91,6 +76,8 @@ class AnalysisServer { sdkPath, '--disable-server-feature-completion', '--disable-server-feature-search', + if (!withFineDependencies) '--no-with-fine-dependencies', + if (!usePlugins) '--no-plugins', if (suppressAnalytics) '--suppress-analytics', if (_protocolTrafficLog != null) '--protocol-traffic-log=$_protocolTrafficLog', ]; @@ -102,9 +89,9 @@ class AnalysisServer { unawaited(process.exitCode.whenComplete(() => _process = null)); final Stream errorStream = process.stderr.transform(utf8LineDecoder); - errorStream.listen(_handleError); + _errorStreamSubscription = errorStream.listen(_handleError); - process.stdout.listen(_handleServerResponseRaw); + _stdoutStreamSubscription = process.stdout.listen(_handleServerResponseRaw); await Future.any([ sendRequest('initialize', { @@ -146,6 +133,12 @@ class AnalysisServer { bool get didServerErrorOccur => _didServerErrorOccur; + /// A stream of booleans indicating that the server is starting or stopping + /// analysis. + /// + /// These statuses are intended to show progress to a user and not intended + /// as a reliable indicator that all analysis has completed. To know when + /// analysis has definitely completed, use [waitForAnalysis]. Stream get onAnalyzing => _analyzingController.stream; Stream get onErrors => _errorsController.stream; @@ -154,7 +147,8 @@ class AnalysisServer { Future? _onExit; void _writeMessage({required String message}) { - _process?.stdin.write('Content-Length: ${message.length}\r\n\r\n$message'); + final List encodedMessage = utf8.encode(message); + _process?.stdin.write('Content-Length: ${encodedMessage.length}\r\n\r\n$message'); } Future?> sendRequest(String method, Map params) async { @@ -251,25 +245,13 @@ class AnalysisServer { void _handleServerResponse(String line) { _logs.add('[stdout] $line'); _logger.printTrace('<== $line'); - if (line.startsWith(globals.kVMServiceMessageRegExp)) { + if (line.startsWith(kVMServiceMessageRegExp)) { return; } final Object? response = json.decode(line); if (response is Map) { - final Object? id = response['id']; - final Completer?>? completer = _outstandingRequests.remove(id); - if (completer != null) { - if (response case {'result': final Map? result}) { - completer.complete(result); - } else if (response case {'error': final Map error}) { - completer.completeError(error['message'] ?? error); - } else { - completer.completeError('Response for unknown request received: $response'); - } - } - final method = response['method'] as String?; if (method != null) { final Object? id = response['id']; @@ -296,6 +278,18 @@ class AnalysisServer { _handleShowMessage(paramsMap); } } + } else { + final Object? id = response['id']; + final Completer?>? completer = _outstandingRequests.remove(id); + if (completer != null) { + if (response case {'result': final Map? result}) { + completer.complete(result); + } else if (response case {'error': final Map error}) { + completer.completeError(error['message'] ?? error); + } else { + completer.completeError('Response for unknown request received: $response'); + } + } } } } @@ -304,13 +298,11 @@ class AnalysisServer { // LSP progress for analysis is typically reported via tokens. // The server sends begin/report/end for a token. final Object? value = params['value']; - if (value is Map) { + if (value is Map && !_analyzingController.isClosed) { final kind = value['kind'] as String?; if (kind == 'begin') { - _isAnalyzing = true; _analyzingController.add(true); } else if (kind == 'end') { - _isAnalyzing = false; _analyzingController.add(false); } } @@ -367,6 +359,10 @@ class AnalysisServer { } Future dispose() async { + await _stdoutStreamSubscription?.cancel(); + _stdoutStreamSubscription = null; + await _errorStreamSubscription?.cancel(); + _errorStreamSubscription = null; await _analyzingController.close(); await _errorsController.close(); return _process?.kill(); @@ -379,12 +375,10 @@ enum AnalysisSeverity { error, warning, info, none } class AnalysisError implements Comparable { AnalysisError( this.writtenError, { - required Platform platform, - required Terminal terminal, - required FileSystem fileSystem, - }) : _platform = platform, - _terminal = terminal, - _fileSystem = fileSystem; + required this._platform, + required this._terminal, + required this._fileSystem, + }); final WrittenError writtenError; final Platform _platform; diff --git a/packages/flutter_tools/lib/src/dart/language_version.dart b/packages/flutter_tools/lib/src/dart/language_version.dart index 1a18cd76..54399fb4 100644 --- a/packages/flutter_tools/lib/src/dart/language_version.dart +++ b/packages/flutter_tools/lib/src/dart/language_version.dart @@ -14,9 +14,6 @@ final _declarationEnd = RegExp('(import)|(library)|(part)'); const _blockCommentStart = '/*'; const _blockCommentEnd = '*/'; -/// The first language version where null safety was available by default. -final nullSafeVersion = LanguageVersion(2, 12); - LanguageVersion? _currentLanguageVersion; /// Lookup the current Dart language version. diff --git a/packages/flutter_tools/lib/src/dart/package_map.dart b/packages/flutter_tools/lib/src/dart/package_map.dart index 78417c24..2cd7926c 100644 --- a/packages/flutter_tools/lib/src/dart/package_map.dart +++ b/packages/flutter_tools/lib/src/dart/package_map.dart @@ -3,7 +3,6 @@ // found in the LICENSE file. import 'dart:isolate'; -import 'dart:typed_data'; import 'package:meta/meta.dart'; import 'package:package_config/package_config.dart'; @@ -11,7 +10,8 @@ import 'package:package_config/package_config.dart'; import '../base/common.dart'; import '../base/file_system.dart'; import '../base/logger.dart'; -import '../globals.dart' as globals; +import '../base/platform.dart'; +import '../context/tool_context.dart'; /// Whether to ignore [Isolate.packageConfigSync] and force the fallback /// path in [currentPackageConfig]. @@ -21,20 +21,20 @@ bool debugIgnorePackageConfigSync = false; const String _fileScheme = 'file'; /// Loads the package configuration of the current isolate. -Future currentPackageConfig() async { +Future currentPackageConfig({required ToolContext toolContext}) async { final Uri? packageConfigUri = debugIgnorePackageConfigSync ? null : Isolate.packageConfigSync; if (packageConfigUri != null) { return loadPackageConfigUri(packageConfigUri); } - final FileSystem fileSystem = globals.fs; - final Directory cwd = fileSystem.currentDirectory; + final ToolContext(:FileSystem fs, :Logger logger, :Platform platform) = toolContext; + final Directory cwd = fs.currentDirectory; File? packageConfigFile = findPackageConfigFile(cwd); if (packageConfigFile == null) { - final Uri scriptUri = globals.platform.script; + final Uri scriptUri = platform.script; if (scriptUri.scheme == _fileScheme) { - final File scriptFile = fileSystem.file(scriptUri); + final File scriptFile = fs.file(scriptUri); packageConfigFile = findPackageConfigFile(scriptFile.parent); } } @@ -44,12 +44,12 @@ Future currentPackageConfig() async { 'Failed to resolve package configuration.\n' 'Isolate.packageConfigSync was null, and no .dart_tool/package_config.json ' 'could be found in the current working directory (${cwd.path}) or ' - 'relative to the script (${globals.platform.script}).\n' + 'relative to the script (${platform.script}).\n' 'Did you run "flutter pub get"?', ); } - return loadPackageConfigWithLogging(packageConfigFile, logger: globals.logger); + return loadPackageConfigWithLogging(packageConfigFile, logger: logger); } /// Locates the `.dart_tool/package_config.json` relevant to [dir]. @@ -105,22 +105,31 @@ Future loadPackageConfigWithLogging( if (!configFile.existsSync()) { return null; } - return Future.value(configFile.readAsBytesSync()); + return configFile.readAsBytes(); }, - onError: (dynamic error) { + onError: (Object? error) { if (!throwOnError) { return; } logger.printTrace(error.toString()); - var message = '${file.path} does not exist.'; - final String pubspecPath = fileSystem.path.absolute( - fileSystem.path.dirname(file.path), - 'pubspec.yaml', - ); - if (fileSystem.isFileSync(pubspecPath)) { - message += '\nDid you run "flutter pub get" in this directory?'; + final String message; + if (file.existsSync()) { + message = + 'The package configuration file ${file.path} is invalid: $error\n' + 'Try running "flutter pub get" to regenerate it.'; } else { - message += '\nDid you run this command from the same directory as your pubspec.yaml file?'; + var notFoundMessage = '${file.path} does not exist.'; + final String pubspecPath = fileSystem.path.absolute( + fileSystem.path.dirname(file.path), + 'pubspec.yaml', + ); + if (fileSystem.isFileSync(pubspecPath)) { + notFoundMessage += '\nDid you run "flutter pub get" in this directory?'; + } else { + notFoundMessage += + '\nDid you run this command from the same directory as your pubspec.yaml file?'; + } + message = notFoundMessage; } logger.printError(message); didError = true; diff --git a/packages/flutter_tools/lib/src/dart/pub.dart b/packages/flutter_tools/lib/src/dart/pub.dart index 2fea38ff..11b51e16 100644 --- a/packages/flutter_tools/lib/src/dart/pub.dart +++ b/packages/flutter_tools/lib/src/dart/pub.dart @@ -9,6 +9,7 @@ import 'dart:async'; import 'package:meta/meta.dart'; import 'package:process/process.dart'; + import '../base/bot_detector.dart'; import '../base/common.dart'; import '../base/context.dart'; @@ -109,21 +110,40 @@ enum PubOutputMode { abstract class Pub { /// Create a default [Pub] instance. factory Pub({ + required BotDetector botDetector, required FileSystem fileSystem, required Logger logger, - required ProcessManager processManager, required Platform platform, - required BotDetector botDetector, - }) = _DefaultPub; + required ProcessManager processManager, + Stdio? stdio, + }) { + if (stdio != null) { + return _DefaultPub.test( + fileSystem: fileSystem, + logger: logger, + processManager: processManager, + platform: platform, + botDetector: botDetector, + stdio: stdio, + ); + } + return _DefaultPub( + fileSystem: fileSystem, + logger: logger, + processManager: processManager, + platform: platform, + botDetector: botDetector, + ); + } /// Create a [Pub] instance with a mocked [stdio]. @visibleForTesting factory Pub.test({ + required BotDetector botDetector, required FileSystem fileSystem, required Logger logger, - required ProcessManager processManager, required Platform platform, - required BotDetector botDetector, + required ProcessManager processManager, required Stdio stdio, }) = _DefaultPub.test; @@ -147,13 +167,13 @@ abstract class Pub { Future get({ required PubContext context, required FlutterProject project, - bool upgrade = false, - bool offline = false, - String? flutterRootOverride, bool checkUpToDate = false, - bool shouldSkipThirdPartyGenerator = true, bool enforceLockfile = false, + String? flutterRootOverride, + bool offline = false, PubOutputMode outputMode = PubOutputMode.all, + bool shouldSkipThirdPartyGenerator = true, + bool upgrade = false, }); /// Runs pub in 'batch' mode. @@ -188,25 +208,23 @@ abstract class Pub { /// create a new `.dart_tool/package_config.json` file. Future interactively( List arguments, { - FlutterProject? project, - required PubContext context, required String command, - bool touchesPackageConfig = false, + required PubContext context, PubOutputMode outputMode = PubOutputMode.all, + FlutterProject? project, + bool touchesPackageConfig = false, }); } class _DefaultPub implements Pub { _DefaultPub({ - required FileSystem fileSystem, + required this._botDetector, + required this._fileSystem, required Logger logger, - required ProcessManager processManager, required Platform platform, - required BotDetector botDetector, - }) : _fileSystem = fileSystem, - _logger = logger, + required ProcessManager processManager, + }) : _logger = logger, _platform = platform, - _botDetector = botDetector, _processUtils = ProcessUtils(logger: logger, processManager: processManager), _processManager = processManager, _stdio = null { @@ -215,19 +233,16 @@ class _DefaultPub implements Pub { @visibleForTesting _DefaultPub.test({ - required FileSystem fileSystem, + required this._botDetector, + required this._fileSystem, required Logger logger, - required ProcessManager processManager, required Platform platform, - required BotDetector botDetector, - required Stdio stdio, - }) : _fileSystem = fileSystem, - _logger = logger, + required ProcessManager processManager, + required Stdio this._stdio, + }) : _logger = logger, _platform = platform, - _botDetector = botDetector, _processUtils = ProcessUtils(logger: logger, processManager: processManager), - _processManager = processManager, - _stdio = stdio { + _processManager = processManager { _git = Git(currentPlatform: platform, runProcessWith: _processUtils); } @@ -244,13 +259,13 @@ class _DefaultPub implements Pub { Future get({ required PubContext context, required FlutterProject project, - bool upgrade = false, - bool offline = false, - String? flutterRootOverride, bool checkUpToDate = false, - bool shouldSkipThirdPartyGenerator = true, bool enforceLockfile = false, + String? flutterRootOverride, + bool offline = false, PubOutputMode outputMode = PubOutputMode.all, + bool shouldSkipThirdPartyGenerator = true, + bool upgrade = false, }) async { final String directory = project.directory.path; @@ -269,10 +284,21 @@ class _DefaultPub implements Pub { .childFile('workspace_ref.json'); final File packageConfigFile; if (workspaceRefFile.existsSync()) { - switch (jsonDecode(workspaceRefFile.readAsStringSync())) { + Object? decoded; + try { + decoded = jsonDecode(workspaceRefFile.readAsStringSync()); + } on Exception { + // Fall through to default. + } + switch (decoded) { case {'workspaceRoot': final String workspaceRoot}: packageConfigFile = _fileSystem.file( - _fileSystem.path.join(workspaceRefFile.parent.path, workspaceRoot), + _fileSystem.path.join( + workspaceRefFile.parent.path, + workspaceRoot, + '.dart_tool', + 'package_config.json', + ), ); default: // The workspace_ref.json file was malformed. Attempt to load the @@ -289,15 +315,13 @@ class _DefaultPub implements Pub { } if (packageConfigFile.existsSync()) { - final Directory workspaceRoot = packageConfigFile.parent.parent; - final File lastVersion = workspaceRoot.childDirectory('.dart_tool').childFile('version'); + final File lastVersion = packageConfigFile.parent.childFile('version'); final versionFromFile = FlutterVersion( flutterRoot: Cache.flutterRoot!, fs: _fileSystem, git: _git, + platform: _platform, ); - final File pubspecYaml = project.pubspecFile; - final File pubLockFile = workspaceRoot.childFile('pubspec.lock'); if (shouldSkipThirdPartyGenerator) { Map packageConfigMap; @@ -317,18 +341,14 @@ class _DefaultPub implements Pub { } } - // If the pubspec.yaml is older than the package config file and the last - // flutter version used is the same as the current version skip pub get. - // This will incorrectly skip pub on the master branch if dependencies - // are being added/removed from the flutter framework packages, but this - // can be worked around by manually running pub. - if (checkUpToDate && - pubLockFile.existsSync() && - pubspecYaml.lastModifiedSync().isBefore(pubLockFile.lastModifiedSync()) && - pubspecYaml.lastModifiedSync().isBefore(packageConfigFile.lastModifiedSync()) && + final bool versionMatch = lastVersion.existsSync() && - lastVersion.readAsStringSync() == versionFromFile.frameworkVersion) { - _logger.printTrace('Skipping pub get: version match.'); + lastVersion.readAsStringSync() == versionFromFile.frameworkVersion; + + if (checkUpToDate && + versionMatch && + await _checkResolutionUpToDate(directory, context, flutterRootOverride)) { + _logger.printTrace('Skipping pub get: resolution up-to-date.'); return; } } @@ -355,6 +375,33 @@ class _DefaultPub implements Pub { await _updateVersionAndPackageConfig(project); } + Future _checkResolutionUpToDate( + String directory, + PubContext context, + String? flutterRootOverride, + ) async { + final pubCommand = [ + ..._pubCommand, + '--directory', + _fileSystem.path.relative(directory), + 'check-resolution-up-to-date', + ]; + final Map pubEnvironment = await _createPubEnvironment( + context: context, + flutterRootOverride: flutterRootOverride, + ); + try { + final RunResult result = await _processUtils.run( + pubCommand, + workingDirectory: _fileSystem.path.current, + environment: pubEnvironment, + ); + return result.exitCode == 0; + } on io.ProcessException { + return false; + } + } + /// Runs pub with [arguments] and [ProcessStartMode.inheritStdio] mode. /// /// Uses [ProcessStartMode.normal] and [_stdio] if [Pub.test] constructor @@ -549,12 +596,12 @@ class _DefaultPub implements Pub { @override Future interactively( List arguments, { - FlutterProject? project, - required PubContext context, required String command, - bool touchesPackageConfig = false, + required PubContext context, bool generateSyntheticPackage = false, PubOutputMode outputMode = PubOutputMode.all, + FlutterProject? project, + bool touchesPackageConfig = false, }) async { await _runWithStdioInherited( arguments, @@ -687,6 +734,7 @@ class _DefaultPub implements Pub { flutterRoot: Cache.flutterRoot!, fs: _fileSystem, git: _git, + platform: _platform, ); lastVersion.writeAsStringSync(versionFromFile.frameworkVersion); diff --git a/packages/flutter_tools/lib/src/darwin/darwin.dart b/packages/flutter_tools/lib/src/darwin/darwin.dart index 87feaf93..0f041fe6 100644 --- a/packages/flutter_tools/lib/src/darwin/darwin.dart +++ b/packages/flutter_tools/lib/src/darwin/darwin.dart @@ -39,11 +39,11 @@ enum FlutterDarwinPlatform { required this.binaryName, required this.targetPlatform, required this.swiftPackagePlatform, - required String artifactName, + required this._artifactName, required this.artifactZip, required this.xcframeworkArtifact, required this.sdks, - }) : _artifactName = artifactName; + }); /// The name of the binary file within the [xcframeworkArtifact]. final String binaryName; diff --git a/packages/flutter_tools/lib/src/debug_adapters/error_formatter.dart b/packages/flutter_tools/lib/src/debug_adapters/error_formatter.dart index af9ed21f..c101b00f 100644 --- a/packages/flutter_tools/lib/src/debug_adapters/error_formatter.dart +++ b/packages/flutter_tools/lib/src/debug_adapters/error_formatter.dart @@ -4,13 +4,12 @@ import 'dart:math' as math; -typedef _OutputSender = - void Function( - String category, - String message, { - bool? parseStackFrames, - int? variablesReference, - }); +typedef _OutputSender = void Function( + String category, + String message, { + bool? parseStackFrames, + int? variablesReference, +}); /// Deserializes and formats a Flutter structured error. /// diff --git a/packages/flutter_tools/lib/src/debug_adapters/flutter_adapter.dart b/packages/flutter_tools/lib/src/debug_adapters/flutter_adapter.dart index 9d315f2f..7c0519cf 100644 --- a/packages/flutter_tools/lib/src/debug_adapters/flutter_adapter.dart +++ b/packages/flutter_tools/lib/src/debug_adapters/flutter_adapter.dart @@ -5,14 +5,13 @@ import 'dart:async'; import 'dart:math' as math; -import 'package:dds/dap.dart' hide PidTracker; +import 'package:dap_adapters/dap_adapters.dart' hide PidTracker; import 'package:vm_service/vm_service.dart' as vm; import '../base/io.dart'; import '../base/process.dart'; import '../cache.dart'; import '../convert.dart'; -import '../globals.dart' as globals show fs; import 'error_formatter.dart'; import 'flutter_adapter_args.dart'; import 'flutter_base_adapter.dart'; @@ -81,12 +80,12 @@ class FlutterDebugAdapter extends FlutterBaseDebugAdapter with VmServiceInfoFile /// Completers for reverse requests from Flutter that may need to be handled by the client. final _reverseRequestCompleters = >{}; - /// Whether or not the user requested debugging be enabled. + /// Whether or not the user requested debugging be enabled and it's supported. /// /// For debugging to be enabled, the user must have chosen "Debug" (and not /// "Run") in the editor (which maps to the DAP `noDebug` field) _and_ must - /// not have requested to run in Profile or Release mode. Profile/Release - /// modes will always disable debugging. + /// not have requested to run in Profile, Release or WASM mode. These modes + /// will always disable debugging. /// /// This is always `true` for attach requests. /// @@ -95,33 +94,25 @@ class FlutterDebugAdapter extends FlutterBaseDebugAdapter with VmServiceInfoFile /// Functionality provided via the daemon (hot reload/restart) will still be /// available. @override - bool get enableDebugger => super.enableDebugger && !profileMode && !releaseMode; + bool get enableDebugger => super.enableDebugger && !profileMode && !releaseMode && !wasmMode; /// Whether the launch configuration arguments specify `--profile`. /// /// Always `false` for attach requests. - bool get profileMode { - final DartCommonLaunchAttachRequestArguments args = this.args; - if (args is FlutterLaunchRequestArguments) { - return args.toolArgs?.contains('--profile') ?? false; - } - - // Otherwise (attach), always false. - return false; - } + bool get profileMode => args.hasLaunchArg('--profile'); /// Whether the launch configuration arguments specify `--release`. /// /// Always `false` for attach requests. - bool get releaseMode { - final DartCommonLaunchAttachRequestArguments args = this.args; - if (args is FlutterLaunchRequestArguments) { - return args.toolArgs?.contains('--release') ?? false; - } + bool get releaseMode => args.hasLaunchArg('--release'); - // Otherwise (attach), always false. - return false; - } + /// Whether the launch configuration arguments specify `--wasm`. + /// + /// Debugging is not supported for WASM, even if `--release` was not + /// specified, see https://github.com/flutter/flutter/issues/190777. + /// + /// Always `false` for attach requests. + bool get wasmMode => args.hasLaunchArg('--wasm'); /// Called by [attachRequest] to request that we actually connect to the app to be debugged. @override @@ -143,7 +134,7 @@ class FlutterDebugAdapter extends FlutterBaseDebugAdapter with VmServiceInfoFile if (vmServiceUri == null && vmServiceInfoFile != null) { final Uri uriFromFile = await waitForVmServiceInfoFile( logger, - globals.fs.file(vmServiceInfoFile), + fileSystem.file(vmServiceInfoFile), ); vmServiceUri = uriFromFile.toString(); } @@ -463,7 +454,20 @@ class FlutterDebugAdapter extends FlutterBaseDebugAdapter with VmServiceInfoFile // This may be useful when there's no VM Service (for example Profile mode) // but the editor still wants to know that startup has finished. if (enableDebugger) { - await debuggerInitialized; // Ensure we're fully initialized before sending. + waitingForDebugger = true; + try { + await Future.any([debuggerInitialized, debuggerInitializationFailedCompleter.future]); + } on DebugAdapterException catch (e) { + sendConsoleOutput(e.message); + return; + } on Object catch (e) { + if (!isTerminating) { + sendConsoleOutput('Failed to initialize debugger: $e'); + } + return; + } finally { + waitingForDebugger = false; + } } sendEvent(RawEventBody({}), eventType: 'flutter.appStarted'); } @@ -744,3 +748,29 @@ class FlutterDebugAdapter extends FlutterBaseDebugAdapter with VmServiceInfoFile } } } + +extension on DartCommonLaunchAttachRequestArguments { + /// Whether `this` is a set of launch arguments (not attach) and contains + /// [arg] in the `args` or `toolArgs`. + /// + /// For Flutter, `args` and `toolArgs` as essentially the same, whereas for + /// Dart, `toolArgs` are passed to `dart run` and `args` to the users + /// script. + bool hasLaunchArg(String arg) { + if (this case final FlutterLaunchRequestArguments args) { + return args.hasArg(arg); + } + return false; + } +} + +extension on FlutterLaunchRequestArguments { + /// Whether these launch args contain [arg] in the `args` or `toolArgs`. + /// + /// For Flutter, `args` and `toolArgs` as essentially the same, whereas for + /// Dart, `toolArgs` are passed to `dart run` and `args` to the users + /// script. + bool hasArg(String arg) { + return (args?.contains(arg) ?? false) || (toolArgs?.contains(arg) ?? false); + } +} diff --git a/packages/flutter_tools/lib/src/debug_adapters/flutter_adapter_args.dart b/packages/flutter_tools/lib/src/debug_adapters/flutter_adapter_args.dart index 3be9e937..b01d6496 100644 --- a/packages/flutter_tools/lib/src/debug_adapters/flutter_adapter_args.dart +++ b/packages/flutter_tools/lib/src/debug_adapters/flutter_adapter_args.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:dds/dap.dart'; +import 'package:dap_adapters/dap_adapters.dart'; /// An implementation of [AttachRequestArguments] that includes all fields used by the Flutter debug adapter. /// diff --git a/packages/flutter_tools/lib/src/debug_adapters/flutter_base_adapter.dart b/packages/flutter_tools/lib/src/debug_adapters/flutter_base_adapter.dart index 3b483f10..e3e1e324 100644 --- a/packages/flutter_tools/lib/src/debug_adapters/flutter_base_adapter.dart +++ b/packages/flutter_tools/lib/src/debug_adapters/flutter_base_adapter.dart @@ -4,7 +4,7 @@ import 'dart:async'; -import 'package:dds/dap.dart' hide PidTracker; +import 'package:dap_adapters/dap_adapters.dart' hide PidTracker; import 'package:vm_service/vm_service.dart' as vm; import '../base/file_system.dart'; @@ -45,6 +45,30 @@ abstract class FlutterBaseDebugAdapter /// the same as what is passed to the base class, which is always provided 'false'. final bool enableFlutterDds; + /// Whether the adapter is currently waiting for the debugger to initialize. + bool waitingForDebugger = false; + + /// A completer that completes with an error if debugger initialization fails + /// (for example, if the session terminates early). + /// + /// A dummy error handler is attached to the future to prevent unhandled + /// asynchronous exceptions if it completes before any listeners are active. + final Completer debuggerInitializationFailedCompleter = Completer() + ..future.then((_) {}, onError: (Object _) {}); + + @override + void handleSessionTerminate([String exitSuffix = '']) { + isTerminating = true; + if (waitingForDebugger && !debuggerInitializationFailedCompleter.isCompleted) { + final String suffix = exitSuffix.trim(); + final message = suffix.isNotEmpty + ? 'Session terminated before debugger initialized: $suffix' + : 'Session terminated before debugger initialized'; + debuggerInitializationFailedCompleter.completeError(DebugAdapterException(message)); + } + super.handleSessionTerminate(exitSuffix); + } + @override final FlutterLaunchRequestArguments Function(Map obj) parseLaunchArgs = FlutterLaunchRequestArguments.fromJson; diff --git a/packages/flutter_tools/lib/src/debug_adapters/flutter_test_adapter.dart b/packages/flutter_tools/lib/src/debug_adapters/flutter_test_adapter.dart index cb0b36dd..87e62889 100644 --- a/packages/flutter_tools/lib/src/debug_adapters/flutter_test_adapter.dart +++ b/packages/flutter_tools/lib/src/debug_adapters/flutter_test_adapter.dart @@ -5,7 +5,7 @@ import 'dart:async'; import 'dart:math' as math; -import 'package:dds/dap.dart' hide PidTracker; +import 'package:dap_adapters/dap_adapters.dart' hide PidTracker; import '../base/io.dart'; import '../cache.dart'; @@ -66,11 +66,26 @@ class FlutterTestDebugAdapter extends FlutterBaseDebugAdapter with TestAdapter { final processArgs = [...toolArgs, ...?args.toolArgs, ?program, ...?args.args]; - await launchAsProcess(executable: executable, processArgs: processArgs, env: args.env); + if (debug) { + waitingForDebugger = true; + } + + try { + await launchAsProcess(executable: executable, processArgs: processArgs, env: args.env); + } catch (e) { + if (debug) { + waitingForDebugger = false; + } + rethrow; + } // Delay responding until the debugger is connected. if (debug) { - await debuggerInitialized; + try { + await Future.any([debuggerInitialized, debuggerInitializationFailedCompleter.future]); + } finally { + waitingForDebugger = false; + } } } diff --git a/packages/flutter_tools/lib/src/debug_adapters/server.dart b/packages/flutter_tools/lib/src/debug_adapters/server.dart index 93f55142..c678bf6b 100644 --- a/packages/flutter_tools/lib/src/debug_adapters/server.dart +++ b/packages/flutter_tools/lib/src/debug_adapters/server.dart @@ -4,7 +4,7 @@ import 'dart:async'; -import 'package:dds/dap.dart' hide DapServer; +import 'package:dap_adapters/dap_adapters.dart' hide DapServer; import '../base/file_system.dart'; import '../base/platform.dart'; diff --git a/packages/flutter_tools/lib/src/desktop_device.dart b/packages/flutter_tools/lib/src/desktop_device.dart index d2ce93dc..b766a522 100644 --- a/packages/flutter_tools/lib/src/desktop_device.dart +++ b/packages/flutter_tools/lib/src/desktop_device.dart @@ -29,13 +29,10 @@ abstract class DesktopDevice extends Device { required PlatformType super.platformType, required super.ephemeral, required super.logger, - required ProcessManager processManager, - required FileSystem fileSystem, - required OperatingSystemUtils operatingSystemUtils, + required this._processManager, + required this._fileSystem, + required this._operatingSystemUtils, }) : _logger = logger, - _processManager = processManager, - _fileSystem = fileSystem, - _operatingSystemUtils = operatingSystemUtils, super(category: Category.desktop); final Logger _logger; @@ -403,7 +400,7 @@ class SingleLaunchLogReader extends DeviceLogReader { // Ignore how `scope` completed — only that it did — so an error from it // (e.g. an unexpected failure reading `process.exitCode`) can't escape // as an unhandled Future error. - scope.catchError((Object _, StackTrace _) {}).whenComplete(() { + scope.then((_) {}, onError: (Object _, StackTrace _) {}).whenComplete(() { unawaited(_subscription.cancel()); unawaited(_controller.close()); }); diff --git a/packages/flutter_tools/lib/src/devfs.dart b/packages/flutter_tools/lib/src/devfs.dart index bb80a67d..dfac2216 100644 --- a/packages/flutter_tools/lib/src/devfs.dart +++ b/packages/flutter_tools/lib/src/devfs.dart @@ -5,22 +5,20 @@ import 'dart:async'; import 'package:package_config/package_config.dart'; -import 'package:process/process.dart'; import 'package:vm_service/vm_service.dart' as vm_service; import 'artifacts.dart'; import 'asset.dart'; import 'base/config.dart'; -import 'base/context.dart'; import 'base/file_system.dart'; import 'base/io.dart'; import 'base/logger.dart'; -import 'base/net.dart'; import 'base/os.dart'; import 'build_info.dart'; import 'build_system/tools/asset_transformer.dart'; import 'build_system/tools/shader_compiler.dart'; import 'compile.dart'; +import 'context/tool_context.dart'; import 'convert.dart' show base64, utf8; import 'vmservice.dart'; @@ -32,8 +30,6 @@ class DevFSConfig { bool noDirectorySymlinks = false; } -DevFSConfig? get devFSConfig => context.get(); - /// Common superclass for content copied to the device. abstract class DevFSContent { /// Return true if the entry has been modified since it was last marked clean. @@ -58,9 +54,10 @@ abstract class DevFSContent { // File content to be copied to the device. class DevFSFileContent extends DevFSContent { - DevFSFileContent(this.file); + DevFSFileContent(this.file, {this._devFSConfig}); final FileSystemEntity file; + final DevFSConfig? _devFSConfig; File? _linkTarget; FileStat? _fileStat; @@ -122,7 +119,7 @@ class DevFSFileContent extends DevFSContent { void markClean() { final (FileStat? fileStat, File? linkTarget) = _statFile(); _fileStat = fileStat; - if (linkTarget != null && (devFSConfig?.cacheSymlinks ?? false)) { + if (linkTarget != null && (_devFSConfig?.cacheSymlinks ?? false)) { _linkTarget = linkTarget; } else { _linkTarget = null; @@ -270,15 +267,12 @@ class _DevFSHttpWriter implements DevFSWriter { _DevFSHttpWriter( this.fsName, FlutterVmService serviceProtocol, { - required OperatingSystemUtils osUtils, + required this._osUtils, required HttpClient httpClient, - required Logger logger, - Duration? uploadRetryThrottle, + required this._logger, + this._uploadRetryThrottle, }) : httpAddress = serviceProtocol.httpAddress, - _client = httpClient, - _osUtils = osUtils, - _uploadRetryThrottle = uploadRetryThrottle, - _logger = logger; + _client = httpClient; final HttpClient _client; final OperatingSystemUtils _osUtils; @@ -378,22 +372,15 @@ class _DevFSHttpWriter implements DevFSWriter { // Basic statistics for DevFS update operation. class UpdateFSReport { UpdateFSReport({ - bool success = false, - int invalidatedSourcesCount = 0, - int syncedBytes = 0, - int scannedSourcesCount = 0, - Duration compileDuration = Duration.zero, - Duration transferDuration = Duration.zero, - Duration findInvalidatedDuration = Duration.zero, - bool hotReloadRejected = false, - }) : _success = success, - _invalidatedSourcesCount = invalidatedSourcesCount, - _syncedBytes = syncedBytes, - _scannedSourcesCount = scannedSourcesCount, - _compileDuration = compileDuration, - _transferDuration = transferDuration, - _findInvalidatedDuration = findInvalidatedDuration, - _hotReloadRejected = hotReloadRejected; + this._success = false, + this._invalidatedSourcesCount = 0, + this._syncedBytes = 0, + this._scannedSourcesCount = 0, + this._compileDuration = Duration.zero, + this._transferDuration = Duration.zero, + this._findInvalidatedDuration = Duration.zero, + this._hotReloadRejected = false, + }); bool get success => _success; int get invalidatedSourcesCount => _invalidatedSourcesCount; @@ -442,50 +429,36 @@ class DevFS { FlutterVmService serviceProtocol, this.fsName, this.rootDirectory, { - required OperatingSystemUtils osUtils, - required Logger logger, - required FileSystem fileSystem, - required ProcessManager processManager, - required Artifacts artifacts, required BuildMode buildMode, + required ToolContext toolContext, HttpClient? httpClient, + this._stopwatchFactory = const StopwatchFactory(), Duration? uploadRetryThrottle, - StopwatchFactory stopwatchFactory = const StopwatchFactory(), - Config? config, - }) : _vmService = serviceProtocol, - _logger = logger, - _fileSystem = fileSystem, + }) : _toolContext = toolContext, + _vmService = serviceProtocol, _httpWriter = _DevFSHttpWriter( fsName, serviceProtocol, - osUtils: osUtils, - logger: logger, + osUtils: toolContext.os, + logger: toolContext.logger, uploadRetryThrottle: uploadRetryThrottle, - httpClient: - httpClient ?? - ((context.get() == null) - ? HttpClient() - : context.get()!()), + httpClient: httpClient ?? HttpClient(), ), - _stopwatchFactory = stopwatchFactory, - _config = config, _assetTransformer = DevelopmentAssetTransformer( transformer: AssetTransformer( - processManager: processManager, - fileSystem: fileSystem, - dartBinaryPath: artifacts.getArtifactPath(Artifact.engineDartBinary), + processManager: toolContext.processManager, + fileSystem: toolContext.fs, + dartBinaryPath: toolContext.artifacts.getArtifactPath(Artifact.engineDartBinary), buildMode: buildMode, ), - fileSystem: fileSystem, - logger: logger, + fileSystem: toolContext.fs, + logger: toolContext.logger, ); + final ToolContext _toolContext; final FlutterVmService _vmService; final _DevFSHttpWriter _httpWriter; - final Logger _logger; - final FileSystem _fileSystem; final StopwatchFactory _stopwatchFactory; - final Config? _config; final DevelopmentAssetTransformer _assetTransformer; final String fsName; @@ -518,7 +491,8 @@ class DevFS { } Future create() async { - _logger.printTrace('DevFS: Creating new filesystem on the device ($_baseUri)'); + final Logger logger = _toolContext.logger; + logger.printTrace('DevFS: Creating new filesystem on the device ($_baseUri)'); try { final vm_service.Response response = await _vmService.createDevFS(fsName); _baseUri = Uri.parse(response.json!['uri'] as String); @@ -536,19 +510,20 @@ class DevFS { // logging. rethrow; } - _logger.printTrace('DevFS: Creating failed. Destroying and trying again'); + logger.printTrace('DevFS: Creating failed. Destroying and trying again'); await destroy(); final vm_service.Response response = await _vmService.createDevFS(fsName); _baseUri = Uri.parse(response.json!['uri'] as String); } - _logger.printTrace('DevFS: Created new filesystem on the device ($_baseUri)'); + logger.printTrace('DevFS: Created new filesystem on the device ($_baseUri)'); return _baseUri!; } Future destroy() async { - _logger.printTrace('DevFS: Deleting filesystem on the device ($_baseUri)'); + final Logger logger = _toolContext.logger; + logger.printTrace('DevFS: Deleting filesystem on the device ($_baseUri)'); await _vmService.deleteDevFS(fsName); - _logger.printTrace('DevFS: Deleted filesystem on the device ($_baseUri)'); + logger.printTrace('DevFS: Deleted filesystem on the device ($_baseUri)'); } /// Mark the [lastCompiled] time to the previous successful compile. @@ -588,6 +563,7 @@ class DevFS { bool resetCompiler = false, File? dartPluginRegistrant, }) async { + final ToolContext(:Config config, :FileSystem fs, :Logger logger) = _toolContext; final candidateCompileTime = DateTime.now(); lastPackageConfig = packageConfig; @@ -601,7 +577,7 @@ class DevFS { // On a full restart, or on an initial compile for the attach based workflow, // this will produce a full dill. Subsequent invocations will produce incremental // dill files that depend on the invalidated files. - _logger.printTrace('Compiling dart to kernel with ${invalidatedFiles.length} updated files'); + logger.printTrace('Compiling dart to kernel with ${invalidatedFiles.length} updated files'); // Await the compiler response after checking if the bundle is updated. This allows the file // stating to be done while waiting for the frontend_server response. @@ -611,7 +587,7 @@ class DevFS { mainUri, invalidatedFiles, outputPath: dillOutputPath, - fs: _fileSystem, + fs: fs, projectRootPath: rootDirectory.path, packageConfig: packageConfig, checkDartPluginRegistry: true, // The entry point is assumed not to have changed. @@ -625,11 +601,11 @@ class DevFS { if (bundle != null) { // Mark processing of bundle started for testability of starting the compile // before processing bundle. - _logger.printTrace('Processing bundle.'); + logger.printTrace('Processing bundle.'); // await null to give time for telling the compiler to compile. await null; - final String assetDirectory = getAssetBuildDirectory(_config, _fileSystem); + final String assetDirectory = getAssetBuildDirectory(config, fs); try { final int bundleSyncedBytes = await updateBundle( bundle: bundle, @@ -637,7 +613,7 @@ class DevFS { assetDirectory: assetDirectory, assetTransformer: _assetTransformer, shaderCompiler: shaderCompiler, - fileSystem: _fileSystem, + fileSystem: fs, rootDirectoryPath: rootDirectory.path, assetPathsToEvict: assetPathsToEvict, shaderPathsToEvict: shaderPathsToEvict, @@ -648,14 +624,14 @@ class DevFS { syncedBytes += bundleSyncedBytes; _assetTransformer.pruneDependencies(bundle.entries.keys.toSet()); } on Exception catch (err, stackTrace) { - _logger.printError('Error updating bundle: $err'); - _logger.printTrace('$stackTrace'); + logger.printError('Error updating bundle: $err'); + logger.printTrace('$stackTrace'); assetBuildFailed = true; } // Mark processing of bundle done for testability of starting the compile // before processing bundle. - _logger.printTrace('Bundle processing done.'); + logger.printTrace('Bundle processing done.'); } final CompilerOutput? compilerOutput = await pendingCompilerOutput; if (compilerOutput == null || compilerOutput.errorCount > 0) { @@ -675,25 +651,25 @@ class DevFS { if (!bundleFirstUpload) { final String compiledBinary = compilerOutput.outputFilename; if (compiledBinary.isNotEmpty) { - final Uri entryUri = _fileSystem.path.toUri(pathToReload); - final content = DevFSFileContent(_fileSystem.file(compiledBinary)); + final Uri entryUri = fs.path.toUri(pathToReload); + final content = DevFSFileContent(fs.file(compiledBinary)); syncedBytes += content.size; dirtyEntries[entryUri] = content; } } - _logger.printTrace('Updating files.'); + logger.printTrace('Updating files.'); final Stopwatch transferTimer = _stopwatchFactory.createStopwatch('transfer')..start(); if (assetBuildFailed) { return UpdateFSReport(); } - _logger.printTrace('Pending asset builds completed. Writing dirty entries.'); + logger.printTrace('Pending asset builds completed. Writing dirty entries.'); if (dirtyEntries.isNotEmpty) { await (devFSWriter ?? _httpWriter).write(dirtyEntries, _baseUri!, _httpWriter); } transferTimer.stop(); - _logger.printTrace('DevFS: Sync finished'); + logger.printTrace('DevFS: Sync finished'); return UpdateFSReport( success: true, syncedBytes: syncedBytes, @@ -839,7 +815,7 @@ class DevFS { /// /// Requires that the file system is the same for both the tool and application. class LocalDevFSWriter implements DevFSWriter { - LocalDevFSWriter({required FileSystem fileSystem}) : _fileSystem = fileSystem; + LocalDevFSWriter({required this._fileSystem}); final FileSystem _fileSystem; diff --git a/packages/flutter_tools/lib/src/device.dart b/packages/flutter_tools/lib/src/device.dart index 13382fec..f1de3204 100644 --- a/packages/flutter_tools/lib/src/device.dart +++ b/packages/flutter_tools/lib/src/device.dart @@ -4,8 +4,10 @@ import 'dart:async'; +import 'package:flutter_tools_core/flutter_tools_core.dart' as tools_core; import 'package:meta/meta.dart'; +import 'android/android_engine_cli_flags.dart'; import 'application_package.dart'; import 'base/context.dart'; import 'base/dds.dart'; @@ -23,23 +25,8 @@ import 'web/devfs_config.dart'; DeviceManager? get deviceManager => context.get(); -/// A description of the kind of workflow the device supports. -enum Category { - web._('web'), - desktop._('desktop'), - mobile._('mobile'); - - const Category._(this.value); - - final String value; - - @override - String toString() => value; - - static Category? fromString(String category) { - return const {'web': web, 'desktop': desktop, 'mobile': mobile}[category]; - } -} +/// The user-visible category of a [Device]. +typedef Category = tools_core.Category; /// The platform sub-folder that a device type supports. enum PlatformType { @@ -60,7 +47,7 @@ enum PlatformType { /// A discovery mechanism for flutter-supported development devices. abstract class DeviceManager { - DeviceManager({required Logger logger}) : _logger = logger; + DeviceManager({required this._logger}); final Logger _logger; @@ -310,25 +297,23 @@ class DeviceDiscoverySupportFilter { _flutterProject = null; /// Filter devices to only include those supported by Flutter and the - /// provided [flutterProject]. + /// provided [_flutterProject]. /// - /// If [flutterProject] is null, all devices will be considered supported by + /// If [_flutterProject] is null, all devices will be considered supported by /// the project. DeviceDiscoverySupportFilter.excludeDevicesUnsupportedByFlutterOrProject({ - required FlutterProject? flutterProject, - }) : _flutterProject = flutterProject, - _excludeDevicesNotSupportedByProject = true, + required this._flutterProject, + }) : _excludeDevicesNotSupportedByProject = true, _excludeDevicesNotSupportedByAll = false; /// Filter devices to only include those supported by Flutter, the provided - /// [flutterProject], and `--device all`. + /// [_flutterProject], and `--device all`. /// - /// If [flutterProject] is null, all devices will be considered supported by + /// If [_flutterProject] is null, all devices will be considered supported by /// the project. DeviceDiscoverySupportFilter.excludeDevicesUnsupportedByFlutterOrProjectOrAll({ - required FlutterProject? flutterProject, - }) : _flutterProject = flutterProject, - _excludeDevicesNotSupportedByProject = true, + required this._flutterProject, + }) : _excludeDevicesNotSupportedByProject = true, _excludeDevicesNotSupportedByAll = true; final FlutterProject? _flutterProject; @@ -354,11 +339,10 @@ class DeviceDiscoverySupportFilter { /// runner instance. Both web and fuchsia require differently configured /// compilers, and web requires an entirely different resident runner. Future isDeviceSupportedForAll(Device device) async { - final TargetPlatform devicePlatform = await device.targetPlatform; + final TargetOperatingSystem deviceOS = (await device.targetPlatform).os; return await device.isSupported() && - devicePlatform != TargetPlatform.fuchsia_arm64 && - devicePlatform != TargetPlatform.fuchsia_x64 && - devicePlatform != TargetPlatform.web_javascript && + deviceOS != .fuchsia && + deviceOS != .web && await isDeviceSupportedForProject(device); } @@ -376,7 +360,7 @@ class DeviceDiscoverySupportFilter { if (_flutterProject == null) { return true; } - return device.isSupportedForProject(_flutterProject); + return await device.isSupportedForProject(_flutterProject); } } @@ -676,7 +660,7 @@ abstract class Device { } /// Whether the device is supported for the current project directory. - bool isSupportedForProject(FlutterProject flutterProject); + FutureOr isSupportedForProject(FlutterProject flutterProject); /// Check if a version of the given app is already installed. /// @@ -941,6 +925,111 @@ enum ImpellerStatus { final bool? asBool; } +/// Configuration options for web debugging and the web development server. +@immutable +class WebDebugConfig { + /// Creates a [WebDebugConfig] with the given options. + const WebDebugConfig({ + this.enableExposeUrl = false, + this.useSseForDebugProxy = true, + this.useSseForDebugBackend = true, + this.useSseForInjectedClient = true, + this.runHeadless = false, + this.browserDebugPort, + this.browserFlags = const [], + this.enableExpressionEvaluation = false, + this.launchUrl, + bool? crossOriginIsolation, + WebRendererMode? renderer, + this.useWasm = false, + this.devServerConfig, + }) : crossOriginIsolation = crossOriginIsolation ?? useWasm, + renderer = + renderer ?? (useWasm ? WebRendererMode.defaultForWasm : WebRendererMode.defaultForJs); + + /// Creates a [WebDebugConfig] from a serialized JSON [json] map. + factory WebDebugConfig.fromJson(Map json) => WebDebugConfig( + enableExposeUrl: json['webEnableExposeUrl'] as bool? ?? false, + useSseForDebugProxy: json['webUseSseForDebugProxy']! as bool, + useSseForDebugBackend: json['webUseSseForDebugBackend']! as bool, + useSseForInjectedClient: json['webUseSseForInjectedClient']! as bool, + runHeadless: json['webRunHeadless']! as bool, + browserDebugPort: json['webBrowserDebugPort'] as int?, + browserFlags: (json['webBrowserFlags']! as List).cast(), + enableExpressionEvaluation: json['webEnableExpressionEvaluation']! as bool, + launchUrl: json['webLaunchUrl'] as String?, + crossOriginIsolation: json['webCrossOriginIsolation']! as bool, + renderer: WebRendererMode.values.byName(json['webRenderer']! as String), + useWasm: json['webUseWasm']! as bool, + devServerConfig: WebDevServerConfig( + port: json['port'] is int ? json['port']! as int : 8080, + host: json['hostname'] is String ? json['hostname']! as String : 'localhost', + https: HttpsConfig.parse(json['tlsCertPath'], json['tlsCertKeyPath']), + headers: (json['webHeaders']! as Map).cast(), + ), + ); + + /// Whether to enable exposing URLs via the daemon URL tunneller. + final bool enableExposeUrl; + + /// Whether to use Server-Sent Events (SSE) for the debug proxy. + final bool useSseForDebugProxy; + + /// Whether to use Server-Sent Events (SSE) for the debug backend. + final bool useSseForDebugBackend; + + /// Whether to use Server-Sent Events (SSE) for the injected client. + final bool useSseForInjectedClient; + + /// Whether to run the browser in headless mode. + final bool runHeadless; + + /// The debug port for the browser, or `null` to use the default port. + final int? browserDebugPort; + + /// Additional command-line flags to pass to the browser when launching. + final List browserFlags; + + /// Whether expression evaluation is enabled in the web debugger. + final bool enableExpressionEvaluation; + + /// A custom URL to launch in the browser instead of the default serving URL. + final String? launchUrl; + + /// Whether Cross-Origin Isolation headers are enabled on the web dev server. + final bool crossOriginIsolation; + + /// The web rendering backend mode to use. + final WebRendererMode renderer; + + /// Whether to compile the application to WebAssembly (Wasm). + final bool useWasm; + + /// Configuration for the local web development server. + final WebDevServerConfig? devServerConfig; + + /// Serializes this configuration into a JSON-compatible map. + Map toJson() => { + 'port': devServerConfig?.port, + 'hostname': devServerConfig?.host, + 'tlsCertPath': devServerConfig?.https?.certPath, + 'tlsCertKeyPath': devServerConfig?.https?.certKeyPath, + 'webEnableExposeUrl': enableExposeUrl, + 'webUseSseForDebugProxy': useSseForDebugProxy, + 'webUseSseForDebugBackend': useSseForDebugBackend, + 'webUseSseForInjectedClient': useSseForInjectedClient, + 'webRunHeadless': runHeadless, + 'webBrowserDebugPort': browserDebugPort, + 'webBrowserFlags': browserFlags, + 'webEnableExpressionEvaluation': enableExpressionEvaluation, + 'webLaunchUrl': launchUrl, + 'webCrossOriginIsolation': crossOriginIsolation, + 'webHeaders': devServerConfig?.headers ?? {}, + 'webRenderer': renderer.name, + 'webUseWasm': useWasm, + }; +} + class DebuggingOptions { DebuggingOptions.enabled( this.buildInfo, { @@ -968,18 +1057,7 @@ class DebuggingOptions { this.deviceVmServicePort, this.ddsPort, this.devToolsServerAddress, - this.webEnableExposeUrl, - this.webUseSseForDebugProxy = true, - this.webUseSseForDebugBackend = true, - this.webUseSseForInjectedClient = true, - this.webRunHeadless = false, - this.webBrowserDebugPort, - this.webBrowserFlags = const [], - this.webEnableExpressionEvaluation = false, - this.webLaunchUrl, - bool? webCrossOriginIsolation, - WebRendererMode? webRenderer, - this.webUseWasm = false, + this.webConfig = const WebDebugConfig(), this.vmserviceOutFile, this.nativeNullAssertions = false, this.enableImpeller = ImpellerStatus.platformDefault, @@ -988,7 +1066,7 @@ class DebuggingOptions { this.uninstallFirst = false, this.uninstallApp = true, this.enableDartProfiling = true, - this.enableHcpp = false, + this.enableHcpp, this.profileStartup = false, this.enableEmbedderApi = false, this.usingCISystem = false, @@ -997,27 +1075,15 @@ class DebuggingOptions { this.ipv6 = false, this.google3WorkspaceRoot, this.printDtd = false, - this.webDevServerConfig, this.testFlag = false, + this.adbLogFiltering = true, this.iosProfileDebugger, - }) : debuggingEnabled = true, - webCrossOriginIsolation = webCrossOriginIsolation ?? webUseWasm, - webRenderer = webRenderer ?? WebRendererMode.getDefault(useWasm: webUseWasm); + }) : debuggingEnabled = true; DebuggingOptions.disabled( this.buildInfo, { this.dartEntrypointArgs = const [], - this.webEnableExposeUrl, - this.webUseSseForDebugProxy = true, - this.webUseSseForDebugBackend = true, - this.webUseSseForInjectedClient = true, - this.webRunHeadless = false, - this.webBrowserDebugPort, - this.webBrowserFlags = const [], - this.webLaunchUrl, - bool? webCrossOriginIsolation, - WebRendererMode? webRenderer, - this.webUseWasm = false, + this.webConfig = const WebDebugConfig(), this.traceAllowlist, this.enableImpeller = ImpellerStatus.platformDefault, this.enableFlutterGpu = false, @@ -1025,16 +1091,16 @@ class DebuggingOptions { this.uninstallFirst = false, this.uninstallApp = true, this.enableDartProfiling = true, - this.enableHcpp = false, + this.enableHcpp, this.profileStartup = false, this.enableEmbedderApi = false, this.usingCISystem = false, this.debugLogsDirectoryPath, - this.webDevServerConfig, this.testFlag = false, this.iosProfileDebugger, this.traceSystrace = false, }) : debuggingEnabled = false, + adbLogFiltering = true, useTestFonts = false, startPaused = false, dartFlags = '', @@ -1057,14 +1123,11 @@ class DebuggingOptions { ddsPort = null, devToolsServerAddress = null, vmserviceOutFile = null, - webEnableExpressionEvaluation = false, - webCrossOriginIsolation = webCrossOriginIsolation ?? webUseWasm, nativeNullAssertions = false, enableDevTools = false, ipv6 = false, google3WorkspaceRoot = null, - printDtd = false, - webRenderer = webRenderer ?? WebRendererMode.getDefault(useWasm: webUseWasm); + printDtd = false; DebuggingOptions._({ required this.buildInfo, @@ -1093,18 +1156,7 @@ class DebuggingOptions { required this.disablePortPublication, required this.ddsPort, required this.devToolsServerAddress, - required this.webEnableExposeUrl, - required this.webUseSseForDebugProxy, - required this.webUseSseForDebugBackend, - required this.webUseSseForInjectedClient, - required this.webRunHeadless, - required this.webBrowserDebugPort, - required this.webBrowserFlags, - required this.webEnableExpressionEvaluation, - required this.webLaunchUrl, - required this.webCrossOriginIsolation, - required this.webRenderer, - required this.webUseWasm, + required this.webConfig, required this.vmserviceOutFile, required this.nativeNullAssertions, required this.enableImpeller, @@ -1122,7 +1174,7 @@ class DebuggingOptions { required this.ipv6, required this.google3WorkspaceRoot, required this.printDtd, - this.webDevServerConfig, + required this.adbLogFiltering, this.iosProfileDebugger, }) : testFlag = false; @@ -1153,15 +1205,22 @@ class DebuggingOptions { final bool disablePortPublication; final int? ddsPort; final Uri? devToolsServerAddress; - final bool? webEnableExposeUrl; - final bool webUseSseForDebugProxy; - final bool webUseSseForDebugBackend; - final bool webUseSseForInjectedClient; + + /// Configuration options for web debugging and the web development server. + final WebDebugConfig webConfig; + final ImpellerStatus enableImpeller; final bool enableFlutterGpu; final bool enableVulkanValidation; final bool enableDartProfiling; - final bool enableHcpp; + + /// Whether HCPP platform views were explicitly enabled or disabled with + /// `--[no-]enable-hcpp`. + /// + /// When null the flag was not passed, and no override is sent to the device + /// at launch, so the `io.flutter.embedding.android.EnableHcpp` value in the + /// manifest of the installed artifact decides. + final bool? enableHcpp; final bool profileStartup; final bool enableEmbedderApi; final bool usingCISystem; @@ -1170,8 +1229,8 @@ class DebuggingOptions { final bool ipv6; final String? google3WorkspaceRoot; final bool printDtd; - final WebDevServerConfig? webDevServerConfig; final bool testFlag; + final bool adbLogFiltering; /// Whether to attach the LLDB debugger when running in profile mode on a physical iOS device. final bool? iosProfileDebugger; @@ -1187,35 +1246,6 @@ class DebuggingOptions { /// Defaults to true. final bool uninstallApp; - /// Whether to run the browser in headless mode. - /// - /// Some CI environments do not provide a display and fail to launch the - /// browser with full graphics stack. Some browsers provide a special - /// "headless" mode that runs the browser with no graphics. - final bool webRunHeadless; - - /// The port the browser should use for its debugging protocol. - final int? webBrowserDebugPort; - - /// Arbitrary browser flags. - final List webBrowserFlags; - - /// Enable expression evaluation for web target. - final bool webEnableExpressionEvaluation; - - /// Allow developers to customize the browser's launch URL - final String? webLaunchUrl; - - /// Whether to enable cross-origin isolation. This is on by default for the - /// skwasm renderer. - final bool webCrossOriginIsolation; - - /// Which web renderer to use for the debugging session - final WebRendererMode webRenderer; - - /// Whether to compile to webassembly - final bool webUseWasm; - /// A file where the VM Service URL should be written after the application is started. final String? vmserviceOutFile; @@ -1301,23 +1331,7 @@ class DebuggingOptions { 'disablePortPublication': disablePortPublication, 'ddsPort': ddsPort, 'devToolsServerAddress': devToolsServerAddress.toString(), - 'port': webDevServerConfig?.port, - 'hostname': webDevServerConfig?.host, - 'tlsCertPath': webDevServerConfig?.https?.certPath, - 'tlsCertKeyPath': webDevServerConfig?.https?.certKeyPath, - 'webEnableExposeUrl': webEnableExposeUrl, - 'webUseSseForDebugProxy': webUseSseForDebugProxy, - 'webUseSseForDebugBackend': webUseSseForDebugBackend, - 'webUseSseForInjectedClient': webUseSseForInjectedClient, - 'webRunHeadless': webRunHeadless, - 'webBrowserDebugPort': webBrowserDebugPort, - 'webBrowserFlags': webBrowserFlags, - 'webEnableExpressionEvaluation': webEnableExpressionEvaluation, - 'webLaunchUrl': webLaunchUrl, - 'webCrossOriginIsolation': webCrossOriginIsolation, - 'webHeaders': webDevServerConfig?.headers ?? {}, - 'webRenderer': webRenderer.name, - 'webUseWasm': webUseWasm, + ...webConfig.toJson(), 'vmserviceOutFile': vmserviceOutFile, 'nativeNullAssertions': nativeNullAssertions, 'enableImpeller': enableImpeller.asBool, @@ -1336,6 +1350,7 @@ class DebuggingOptions { 'ipv6': ipv6, 'google3WorkspaceRoot': google3WorkspaceRoot, 'printDtd': printDtd, + 'adbLogFiltering': adbLogFiltering, // TODO(jsimmons): This field is required for backward compatibility with // the flutter_tools binary that is currently checked into Google3. // Remove this when that binary has been updated. @@ -1377,18 +1392,7 @@ class DebuggingOptions { devToolsServerAddress: json['devToolsServerAddress'] != null ? Uri.parse(json['devToolsServerAddress']! as String) : null, - webEnableExposeUrl: json['webEnableExposeUrl'] as bool?, - webUseSseForDebugProxy: json['webUseSseForDebugProxy']! as bool, - webUseSseForDebugBackend: json['webUseSseForDebugBackend']! as bool, - webUseSseForInjectedClient: json['webUseSseForInjectedClient']! as bool, - webRunHeadless: json['webRunHeadless']! as bool, - webBrowserDebugPort: json['webBrowserDebugPort'] as int?, - webBrowserFlags: (json['webBrowserFlags']! as List).cast(), - webEnableExpressionEvaluation: json['webEnableExpressionEvaluation']! as bool, - webLaunchUrl: json['webLaunchUrl'] as String?, - webCrossOriginIsolation: json['webCrossOriginIsolation']! as bool, - webRenderer: WebRendererMode.values.byName(json['webRenderer']! as String), - webUseWasm: json['webUseWasm']! as bool, + webConfig: WebDebugConfig.fromJson(json), vmserviceOutFile: json['vmserviceOutFile'] as String?, nativeNullAssertions: json['nativeNullAssertions']! as bool, enableImpeller: ImpellerStatus.fromBool(json['enableImpeller'] as bool?), @@ -1397,7 +1401,7 @@ class DebuggingOptions { uninstallFirst: (json['uninstallFirst'] as bool?) ?? false, uninstallApp: (json['uninstallApp'] as bool?) ?? true, enableDartProfiling: (json['enableDartProfiling'] as bool?) ?? true, - enableHcpp: (json['enableHcpp'] as bool?) ?? false, + enableHcpp: json['enableHcpp'] as bool?, profileStartup: (json['profileStartup'] as bool?) ?? false, enableEmbedderApi: (json['enableEmbedderApi'] as bool?) ?? false, usingCISystem: (json['usingCISystem'] as bool?) ?? false, @@ -1406,13 +1410,82 @@ class DebuggingOptions { ipv6: (json['ipv6'] as bool?) ?? false, google3WorkspaceRoot: json['google3WorkspaceRoot'] as String?, printDtd: (json['printDtd'] as bool?) ?? false, - webDevServerConfig: WebDevServerConfig( - port: json['port'] is int ? json['port']! as int : 8080, - host: json['hostname'] is String ? json['hostname']! as String : 'localhost', - https: HttpsConfig.parse(json['tlsCertPath'], json['tlsCertKeyPath']), - headers: (json['webHeaders']! as Map).cast(), - ), + adbLogFiltering: (json['adbLogFiltering'] as bool?) ?? true, ); + + Map _getAndroidEngineConfig() { + return { + if (enableDartProfiling) AndroidEngineCliFlags.enableDartProfiling: true, + if (profileStartup) AndroidEngineCliFlags.profileStartup: true, + if (enableSoftwareRendering) AndroidEngineCliFlags.enableSoftwareRendering: true, + if (skiaDeterministicRendering) AndroidEngineCliFlags.skiaDeterministicRendering: true, + if (traceSkia) AndroidEngineCliFlags.traceSkia: true, + if (traceAllowlist != null) AndroidEngineCliFlags.traceAllowlist: traceAllowlist, + if (traceSkiaAllowlist != null) AndroidEngineCliFlags.traceSkiaAllowlist: traceSkiaAllowlist, + if (traceSystrace) AndroidEngineCliFlags.traceSystrace: true, + if (traceToFile != null) AndroidEngineCliFlags.traceToFile: traceToFile, + if (endlessTraceBuffer) AndroidEngineCliFlags.endlessTraceBuffer: true, + if (profileMicrotasks) AndroidEngineCliFlags.profileMicrotasks: true, + if (purgePersistentCache) AndroidEngineCliFlags.purgePersistentCache: true, + if (enableImpeller == ImpellerStatus.enabled) AndroidEngineCliFlags.enableImpeller: true, + if (enableImpeller == ImpellerStatus.disabled) AndroidEngineCliFlags.enableImpeller: false, + if (enableFlutterGpu) AndroidEngineCliFlags.enableFlutterGpu: true, + if (enableVulkanValidation) AndroidEngineCliFlags.enableVulkanValidation: true, + if (enableHcpp != null) AndroidEngineCliFlags.enableHcppAndSurfaceControl: enableHcpp, + if (testFlag) AndroidEngineCliFlags.testFlag: true, + if (debuggingEnabled) ...{ + // TODO(camsim99): Determine if we should even forward these to the Android embedding since Android + // appears unsupported. https://github.com/flutter/flutter/issues/191849 + if (buildInfo.isDebug) 'enable-checked-mode': true, + if (buildInfo.isDebug) 'verify-entry-points': true, + if (startPaused) AndroidEngineCliFlags.startPaused: true, + if (disableServiceAuthCodes) AndroidEngineCliFlags.disableServiceAuthCodes: true, + if (disableServiceOriginCheck) AndroidEngineCliFlags.disableServiceOriginCheck: true, + if (dartFlags.isNotEmpty) AndroidEngineCliFlags.dartFlags: dartFlags, + if (useTestFonts) AndroidEngineCliFlags.useTestFonts: true, + if (verboseSystemLogs) AndroidEngineCliFlags.verboseLogging: true, + }, + }; + } + + /// Retrieves Android engine shell arguments from the debugging options based on the + /// command line flags that are passed to the engine via the manifest. + Set getAndroidLaunchArguments() { + final Map configs = _getAndroidEngineConfig(); + final args = {}; + for (final MapEntry entry in configs.entries) { + final Object? value = entry.value; + if (entry.key == AndroidEngineCliFlags.enableImpeller || + entry.key == AndroidEngineCliFlags.enableHcppAndSurfaceControl) { + args.add('--${entry.key}=$value'); + } else if (value is bool) { + args.add(value ? '--${entry.key}' : '--${entry.key}=false'); + } else if (value is String) { + args.add('--${entry.key}=$value'); + } else { + assert(false, 'Unsupported engine config value type ${value.runtimeType} for ${entry.key}'); + } + } + return args; + } + + /// Retrieves Android engine shell arguments from the debugging options based on the + /// command line flags that are passed to the engine via the manifest as Intent extras. + List getAndroidLaunchArgumentsAsIntentExtras() { + final Map configs = _getAndroidEngineConfig(); + final args = []; + for (final MapEntry entry in configs.entries) { + final Object? value = entry.value; + if (value is bool) { + args.addAll(['--ez', entry.key, value.toString()]); + } else if (value is String) { + args.addAll(['--es', entry.key, value]); + } else { + assert(false, 'Unsupported engine config value type ${value.runtimeType} for ${entry.key}'); + } + } + return args; + } } class LaunchResult { @@ -1453,13 +1526,6 @@ abstract class DeviceLogReader { void dispose(); } -/// Describes an app running on the device. -class DiscoveredApp { - DiscoveredApp(this.id, this.vmServicePort); - final String id; - final int vmServicePort; -} - // An empty device log reader class NoOpDeviceLogReader implements DeviceLogReader { NoOpDeviceLogReader(String? nameOrNull) : name = nameOrNull ?? ''; diff --git a/packages/flutter_tools/lib/src/device_vm_service_discovery_for_attach.dart b/packages/flutter_tools/lib/src/device_vm_service_discovery_for_attach.dart index ae469043..4689589f 100644 --- a/packages/flutter_tools/lib/src/device_vm_service_discovery_for_attach.dart +++ b/packages/flutter_tools/lib/src/device_vm_service_discovery_for_attach.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'package:async/async.dart'; +import 'base/common.dart'; import 'base/logger.dart'; import 'device.dart'; import 'device_port_forwarder.dart'; @@ -23,6 +24,15 @@ abstract class VMServiceDiscoveryForAttach { /// Port forwarding is only attempted when this is invoked, for each VM /// Service URI in the stream. Stream get uris; + + /// Find the first URI discovered for attach. + Future firstValidUri() async { + final List candidateUris = await uris.take(1).toList(); + if (candidateUris.isEmpty) { + throwToolExit('Failed to find VM Service URL.'); + } + return candidateUris.first; + } } /// An implementation of [VMServiceDiscoveryForAttach] that uses log scanning @@ -64,11 +74,12 @@ class LogScanningVMServiceDiscoveryForAttach extends VMServiceDiscoveryForAttach class MdnsVMServiceDiscoveryForAttach extends VMServiceDiscoveryForAttach { MdnsVMServiceDiscoveryForAttach({ required this.device, - this.appId, - required this.usesIpv6, required this.useDeviceIPAsHost, + required this.usesIpv6, + this.appId, this.deviceVmservicePort, this.hostVmservicePort, + this.throwOnError = true, }); final Device device; @@ -77,6 +88,7 @@ class MdnsVMServiceDiscoveryForAttach extends VMServiceDiscoveryForAttach { final bool useDeviceIPAsHost; final int? deviceVmservicePort; final int? hostVmservicePort; + final bool throwOnError; @override Stream get uris { @@ -84,15 +96,17 @@ class MdnsVMServiceDiscoveryForAttach extends VMServiceDiscoveryForAttach { .getVMServiceUriForAttach( appId, device, - usesIpv6: usesIpv6, - useDeviceIPAsHost: useDeviceIPAsHost, deviceVmservicePort: deviceVmservicePort, hostVmservicePort: hostVmservicePort, + throwOnError: throwOnError, + useDeviceIPAsHost: useDeviceIPAsHost, + usesIpv6: usesIpv6, ); - return Stream.fromFuture( - mDNSDiscoveryFuture, - ).where((Uri? uri) => uri != null).cast().asBroadcastStream(); + return Stream.fromFuture(mDNSDiscoveryFuture) + .where((Uri? uri) => uri != null) + .cast() + .asBroadcastStream(); } } diff --git a/packages/flutter_tools/lib/src/devtools_launcher.dart b/packages/flutter_tools/lib/src/devtools_launcher.dart index 24d4fd16..ff7f49b6 100644 --- a/packages/flutter_tools/lib/src/devtools_launcher.dart +++ b/packages/flutter_tools/lib/src/devtools_launcher.dart @@ -19,14 +19,11 @@ import 'resident_runner.dart'; /// start a DevTools server instance. class DevtoolsServerLauncher extends DevtoolsLauncher { DevtoolsServerLauncher({ - required ProcessManager processManager, - required Logger logger, - required BotDetector botDetector, - required Artifacts artifacts, - }) : _processManager = processManager, - _logger = logger, - _botDetector = botDetector, - _artifacts = artifacts; + required this._processManager, + required this._logger, + required this._botDetector, + required this._artifacts, + }); final ProcessManager _processManager; final Artifacts _artifacts; diff --git a/packages/flutter_tools/lib/src/doctor.dart b/packages/flutter_tools/lib/src/doctor.dart index 99795019..322fbd82 100644 --- a/packages/flutter_tools/lib/src/doctor.dart +++ b/packages/flutter_tools/lib/src/doctor.dart @@ -4,6 +4,8 @@ import 'dart:async'; +import 'package:flutter_tools_core/flutter_tools_core.dart'; +import 'package:flutter_tools_extension/flutter_tools_extension.dart'; import 'package:meta/meta.dart'; import 'package:process/process.dart'; import 'package:unified_analytics/unified_analytics.dart'; @@ -25,6 +27,8 @@ import 'cache.dart'; import 'custom_devices/custom_device_workflow.dart'; import 'device.dart'; import 'doctor_validator.dart'; +import 'experimental/diagnostics.dart'; +import 'experimental/extension_manager.dart'; import 'features.dart'; import 'globals.dart' as globals; import 'http_host_validator.dart'; @@ -48,12 +52,30 @@ const _kIntelMacWarning = ValidationMessage.hint( ); abstract class DoctorValidatorsProvider { + /// Create a [DoctorValidatorsProvider] with explicit options. + factory DoctorValidatorsProvider.create({ + Platform? platform, + required FeatureFlags featureFlags, + ExtensionManager? extensionManager, + }) { + return _DefaultDoctorValidatorsProvider( + platform: platform ?? globals.platform, + featureFlags: featureFlags, + extensionManager: extensionManager, + ); + } + // Allow tests to construct a [_DefaultDoctorValidatorsProvider] with explicit // [FeatureFlags]. - factory DoctorValidatorsProvider.test({Platform? platform, required FeatureFlags featureFlags}) { + factory DoctorValidatorsProvider.test({ + Platform? platform, + required FeatureFlags featureFlags, + ExtensionManager? extensionManager, + }) { return _DefaultDoctorValidatorsProvider( featureFlags: featureFlags, platform: platform ?? FakePlatform(), + extensionManager: extensionManager, ); } @@ -70,12 +92,17 @@ abstract class DoctorValidatorsProvider { } class _DefaultDoctorValidatorsProvider implements DoctorValidatorsProvider { - _DefaultDoctorValidatorsProvider({required this.platform, required this.featureFlags}); + _DefaultDoctorValidatorsProvider({ + required this.platform, + required this.featureFlags, + this.extensionManager, + }); List? _validators; List? _workflows; final Platform platform; final FeatureFlags featureFlags; + final ExtensionManager? extensionManager; late final linuxWorkflow = LinuxWorkflow(platform: platform, featureFlags: featureFlags); @@ -141,6 +168,9 @@ class _DefaultDoctorValidatorsProvider implements DoctorValidatorsProvider { processManager: globals.processManager, userMessages: globals.userMessages, ), + if (extensionManager case final ExtensionManager manager) + for (final DiagnosticsExtension extension in manager.diagnosticsExtensions) + ExtensionDoctorValidator(extension: extension, logger: globals.logger), if (windowsWorkflow!.appliesToHostPlatform) visualStudioValidator!, if (proxyValidator.shouldShow) proxyValidator, if (globals.deviceManager?.canListAnything ?? false) @@ -168,10 +198,8 @@ class _DefaultDoctorValidatorsProvider implements DoctorValidatorsProvider { } class Doctor { - Doctor({required Logger logger, required SystemClock clock, Analytics? analytics}) - : _logger = logger, - _clock = clock, - _analytics = analytics ?? globals.analytics; + Doctor({required this._logger, required this._clock, Analytics? analytics}) + : _analytics = analytics ?? globals.analytics; final Logger _logger; final SystemClock _clock; @@ -183,40 +211,52 @@ class Doctor { /// Return a list of [ValidatorTask] objects and starts validation on all /// objects in [validators]. - List startValidatorTasks() => [ - for (final DoctorValidator validator in validators) - ValidatorTask( - validator, - // We use an asyncGuard() here to be absolutely certain that - // DoctorValidators do not result in an uncaught exception. Since the - // Future returned by the asyncGuard() is not awaited, we pass an - // onError callback to it and translate errors into ValidationResults. - asyncGuard( - () { - final timeoutCompleter = Completer(); - final timer = Timer(doctorDuration, () { - timeoutCompleter.completeError( - Exception( - '${validator.title} exceeded maximum allowed duration of $doctorDuration', - ), - ); - }); - final Future validatorFuture = validator.validate(); - return Future.any(>[ - validatorFuture, - // This future can only complete with an error - timeoutCompleter.future, - ]).then((ValidationResult result) async { - timer.cancel(); - return result; - }); - }, - onError: (Object exception, StackTrace stackTrace) { - return ValidationResult.crash(exception, stackTrace); - }, + Future> startValidatorTasks({ExtensionManager? extensionManager}) async { + if (extensionManager != null) { + await extensionManager.ensureInitialized(); + } + final List validatorList = extensionManager != null + ? DoctorValidatorsProvider.create( + platform: globals.platform, + featureFlags: featureFlags, + extensionManager: extensionManager, + ).validators + : validators; + return [ + for (final DoctorValidator validator in validatorList) + ValidatorTask( + validator, + // We use an asyncGuard() here to be absolutely certain that + // DoctorValidators do not result in an uncaught exception. Since the + // Future returned by the asyncGuard() is not awaited, we pass an + // onError callback to it and translate errors into ValidationResults. + asyncGuard( + () { + final timeoutCompleter = Completer(); + final timer = Timer(doctorDuration, () { + timeoutCompleter.completeError( + Exception( + '${validator.title} exceeded maximum allowed duration of $doctorDuration', + ), + ); + }); + final Future validatorFuture = validator.validate(); + return Future.any(>[ + validatorFuture, + // This future can only complete with an error + timeoutCompleter.future, + ]).then((ValidationResult result) async { + timer.cancel(); + return result; + }); + }, + onError: (Object exception, StackTrace stackTrace) { + return ValidationResult.crash(exception, stackTrace); + }, + ), ), - ), - ]; + ]; + } List get workflows { return DoctorValidatorsProvider._instance.workflows; @@ -242,7 +282,7 @@ class Doctor { // We're generating a summary, so drop the stack trace. result = ValidationResult.crash(exception); } - lineBuffer.write('${result.coloredLeadingBox} ${validator.title}: '); + lineBuffer.write('${result.coloredLeadingBox(_logger.terminal)} ${validator.title}: '); switch (result.type) { case ValidationType.crash: lineBuffer.write('the doctor check crashed without a result.'); @@ -313,8 +353,10 @@ class Doctor { bool showPii = true, List? startedValidatorTasks, bool sendEvent = true, + ExtensionManager? extensionManager, }) async { - final bool showColor = globals.terminal.supportsColor; + final Terminal terminal = _logger.terminal; + final bool showColor = terminal.supportsColor; if (androidLicenses && androidLicenseValidator != null) { return androidLicenseValidator.runLicenseManager(); } @@ -329,7 +371,8 @@ class Doctor { // were sent for each doctor validator and its result final int analyticsTimestamp = _clock.now().millisecondsSinceEpoch; - for (final ValidatorTask validatorTask in startedValidatorTasks ?? startValidatorTasks()) { + for (final ValidatorTask validatorTask + in startedValidatorTasks ?? await startValidatorTasks(extensionManager: extensionManager)) { final DoctorValidator validator = validatorTask.validator; final Status status = _logger.startSpinner( timeout: validator.slowWarningDuration, @@ -405,7 +448,7 @@ class Doctor { return ' [$formatted]'; }(); - final String leadingBox = showColor ? result.coloredLeadingBox : result.leadingBox; + final String leadingBox = showColor ? result.coloredLeadingBox(terminal) : result.leadingBox; if (result.statusInfo != null) { _logger.printStatus( '$leadingBox ${validator.title} (${result.statusInfo})$executionDuration', @@ -422,7 +465,9 @@ class Doctor { if (!message.isInformation || verbose) { var hangingIndent = 2; var indent = 4; - final String indicator = showColor ? message.coloredIndicator : message.indicator; + final String indicator = showColor + ? message.coloredIndicator(terminal) + : message.indicator; for (final String line in '$indicator ${showPii ? message.message : message.piiStrippedMessage}'.split( '\n', @@ -454,13 +499,13 @@ class Doctor { if (issues > 0) { _logger.printStatus( - '${showColor ? globals.terminal.color('!', TerminalColor.yellow) : '!'}' + '${showColor ? terminal.color('!', TerminalColor.yellow) : '!'}' ' Doctor found issues in $issues categor${issues > 1 ? "ies" : "y"}.', hangingIndent: 2, ); } else { _logger.printStatus( - '${showColor ? globals.terminal.color('•', TerminalColor.green) : '•'}' + '${showColor ? terminal.color('•', TerminalColor.green) : '•'}' ' No issues found!', hangingIndent: 2, ); @@ -486,25 +531,16 @@ class Doctor { /// specific commit information. class FlutterValidator extends DoctorValidator { FlutterValidator({ - required Platform platform, - required FlutterVersion Function() flutterVersion, - required String Function() devToolsVersion, - required FileSystem fileSystem, - required Artifacts artifacts, - required ProcessManager processManager, - required String Function() flutterRoot, - required OperatingSystemUtils operatingSystemUtils, - required FeatureFlags featureFlags, - }) : _flutterVersion = flutterVersion, - _devToolsVersion = devToolsVersion, - _platform = platform, - _fileSystem = fileSystem, - _artifacts = artifacts, - _processManager = processManager, - _flutterRoot = flutterRoot, - _operatingSystemUtils = operatingSystemUtils, - _featureFlags = featureFlags, - super('Flutter'); + required this._platform, + required this._flutterVersion, + required this._devToolsVersion, + required this._fileSystem, + required this._artifacts, + required this._processManager, + required this._flutterRoot, + required this._operatingSystemUtils, + required this._featureFlags, + }) : super('Flutter'); final Platform _platform; final FlutterVersion Function() _flutterVersion; @@ -706,7 +742,13 @@ class FlutterValidator extends DoctorValidator { ); } final String resolvedFlutterPath = flutterBin.resolveSymbolicLinksSync(); - if (!_filePathContainsDirPath(flutterRoot, resolvedFlutterPath)) { + var resolvedFlutterRoot = flutterRoot; + try { + resolvedFlutterRoot = _fileSystem.directory(flutterRoot).resolveSymbolicLinksSync(); + } on FileSystemException { + // If the root does not exist or cannot be resolved, retain the un-resolved path. + } + if (!_filePathContainsDirPath(resolvedFlutterRoot, resolvedFlutterPath)) { final hint = 'Warning: `$binary` on your path resolves to ' '$resolvedFlutterPath, which is not inside your current Flutter ' @@ -783,9 +825,9 @@ class DeviceValidator extends DoctorValidator { ); var installedMessages = []; if (devices.isNotEmpty) { - installedMessages = (await Device.descriptions( - devices, - )).map((String msg) => ValidationMessage(msg)).toList(); + installedMessages = (await Device.descriptions(devices)) + .map((String msg) => ValidationMessage(msg)) + .toList(); } var diagnosticMessages = []; @@ -831,12 +873,12 @@ class DoctorText { late final Future piiStrippedText = _runDiagnosis(false); // Start the validator tasks only once. - late final List _validatorTasks = _doctor.startValidatorTasks(); + late final Future> _validatorTasks = _doctor.startValidatorTasks(); Future _runDiagnosis(bool showPii) async { try { await _doctor.diagnose( - startedValidatorTasks: _validatorTasks, + startedValidatorTasks: await _validatorTasks, showPii: showPii, sendEvent: _sendDoctorEvent, verbose: _logger.isVerbose, diff --git a/packages/flutter_tools/lib/src/doctor_validator.dart b/packages/flutter_tools/lib/src/doctor_validator.dart index b9b6802a..c44dc99f 100644 --- a/packages/flutter_tools/lib/src/doctor_validator.dart +++ b/packages/flutter_tools/lib/src/doctor_validator.dart @@ -4,11 +4,10 @@ import 'dart:async'; -import 'package:meta/meta.dart'; +import 'package:flutter_tools_core/flutter_tools_core.dart'; import 'base/async_guard.dart'; import 'base/terminal.dart'; -import 'globals.dart' as globals; class ValidatorTask { ValidatorTask(this.validator, this.result); @@ -33,10 +32,6 @@ abstract class Workflow { bool get canListEmulators; } -enum ValidationType { crash, missing, partial, notAvailable, success } - -enum ValidationMessageType { error, hint, information } - abstract class DoctorValidator { DoctorValidator(this.title); @@ -57,7 +52,7 @@ abstract class DoctorValidator { final stopwatch = Stopwatch()..start(); final ValidationResult result = await validateImpl(); stopwatch.stop(); - result._executionTime = stopwatch.elapsed; + result.executionTime = stopwatch.elapsed; return result; } @@ -137,30 +132,9 @@ class GroupedValidator extends DoctorValidator { } } -class ValidationResult { - /// [ValidationResult.type] should only equal [ValidationType.success] - /// if no [messages] are hints or errors. - ValidationResult(this.type, this.messages, {this.statusInfo}); - - factory ValidationResult.crash(Object error, [StackTrace? stackTrace]) { - return ValidationResult(ValidationType.crash, [ - const ValidationMessage.error( - 'Due to an error, the doctor check did not complete. ' - 'If the error message below is not helpful, ' - 'please let us know about this issue at https://github.com/flutter/flutter/issues.', - ), - ValidationMessage.error('$error'), - if (stackTrace != null) - // Stacktrace is informational. Printed in verbose mode only. - ValidationMessage('$stackTrace'), - ], statusInfo: 'the doctor check crashed'); - } - - final ValidationType type; - // A short message about the status. - final String? statusInfo; - final List messages; - +/// Host UI formatting extensions for [ValidationResult]. +extension ValidationResultFormatting on ValidationResult { + /// Leading box indicator for CLI status display. String get leadingBox => switch (type) { ValidationType.crash => '[☠]', ValidationType.missing => '[✗]', @@ -168,106 +142,42 @@ class ValidationResult { ValidationType.notAvailable || ValidationType.partial => '[!]', }; - /// The time taken to perform the validation, set by [DoctorValidator.validate]. - Duration? get executionTime => _executionTime; - Duration? _executionTime; + /// String representation of the status type. + String get typeStr => switch (type) { + ValidationType.success => 'installed', + _ => type.name, + }; - String get coloredLeadingBox { - return globals.terminal.color(leadingBox, switch (type) { + String coloredLeadingBox(Terminal terminal) { + return terminal.color(leadingBox, switch (type) { ValidationType.success => TerminalColor.green, ValidationType.crash || ValidationType.missing => TerminalColor.red, ValidationType.notAvailable || ValidationType.partial => TerminalColor.yellow, }); } - - /// The string representation of the type. - String get typeStr => switch (type) { - ValidationType.crash => 'crash', - ValidationType.missing => 'missing', - ValidationType.success => 'installed', - ValidationType.notAvailable => 'notAvailable', - ValidationType.partial => 'partial', - }; - - @override - String toString() { - return '$runtimeType($type, $messages, $statusInfo)'; - } } -/// A status line for the flutter doctor validation to display. -/// -/// The [message] is required and represents either an informational statement -/// about the particular doctor validation that passed, or more context -/// on the cause and/or solution to the validation failure. -@immutable -class ValidationMessage { - /// Create a validation message with information for a passing validator. - /// - /// By default this is not displayed unless the doctor is run in - /// verbose mode. - /// - /// The [contextUrl] may be supplied to link to external resources. This - /// is displayed after the informative message in verbose modes. - const ValidationMessage(this.message, {this.contextUrl, String? piiStrippedMessage}) - : type = ValidationMessageType.information, - piiStrippedMessage = piiStrippedMessage ?? message; - - /// Create a validation message with information for a failing validator. - const ValidationMessage.error(this.message, {String? piiStrippedMessage}) - : type = ValidationMessageType.error, - piiStrippedMessage = piiStrippedMessage ?? message, - contextUrl = null; - - /// Create a validation message with information for a partially failing - /// validator. - const ValidationMessage.hint(this.message, {String? piiStrippedMessage}) - : type = ValidationMessageType.hint, - piiStrippedMessage = piiStrippedMessage ?? message, - contextUrl = null; - - final ValidationMessageType type; - final String? contextUrl; - final String message; - - /// Optional message with PII stripped, to show instead of [message]. - final String piiStrippedMessage; - - bool get isError => type == ValidationMessageType.error; - - bool get isHint => type == ValidationMessageType.hint; - - bool get isInformation => type == ValidationMessageType.information; - +/// Host UI formatting extensions for [ValidationMessage]. +extension ValidationMessageFormatting on ValidationMessage { + /// Icon indicator character for CLI display. String get indicator => switch (type) { ValidationMessageType.error => '✗', ValidationMessageType.hint => '!', ValidationMessageType.information => '•', }; - String get coloredIndicator { - return globals.terminal.color(indicator, switch (type) { + String coloredIndicator(Terminal terminal) { + return terminal.color(indicator, switch (type) { ValidationMessageType.error => TerminalColor.red, ValidationMessageType.hint => TerminalColor.yellow, ValidationMessageType.information => TerminalColor.green, }); } - - @override - String toString() => message; - - @override - bool operator ==(Object other) { - return other is ValidationMessage && - other.message == message && - other.type == type && - other.contextUrl == contextUrl; - } - - @override - int get hashCode => Object.hash(type, message, contextUrl); } +/// A validator that reports when no supported IDEs are installed. +/// +/// This class is required by internal google3 tooling (`mobile/flutter/cli/context`). class NoIdeValidator extends DoctorValidator { NoIdeValidator() : super('Flutter IDE Support'); @@ -289,12 +199,3 @@ class NoIdeValidator extends DoctorValidator { ); } } - -class ValidatorWithResult extends DoctorValidator { - ValidatorWithResult(super.title, this.result); - - final ValidationResult result; - - @override - Future validateImpl() async => result; -} diff --git a/packages/flutter_tools/lib/src/drive/drive_service.dart b/packages/flutter_tools/lib/src/drive/drive_service.dart index c9937bd6..e41788dd 100644 --- a/packages/flutter_tools/lib/src/drive/drive_service.dart +++ b/packages/flutter_tools/lib/src/drive/drive_service.dart @@ -7,6 +7,7 @@ import 'dart:async'; import 'package:file/file.dart'; import 'package:meta/meta.dart'; import 'package:package_config/package_config_types.dart'; +import 'package:unified_analytics/unified_analytics.dart'; import 'package:vm_service/vm_service.dart' as vm_service; import '../application_package.dart'; @@ -15,8 +16,10 @@ import '../base/dds.dart'; import '../base/logger.dart'; import '../base/platform.dart'; import '../base/process.dart'; -import '../base/terminal.dart'; import '../build_info.dart'; +import '../build_system/build_system.dart'; +import '../build_system/build_targets.dart'; +import '../context/tool_context.dart'; import '../device.dart'; import '../resident_runner.dart'; import '../vmservice.dart'; @@ -24,55 +27,81 @@ import 'web_driver_service.dart'; class FlutterDriverFactory { FlutterDriverFactory({ - required ApplicationPackageFactory applicationPackageFactory, - required Platform platform, - required Logger logger, - required Terminal terminal, - required OutputPreferences outputPreferences, - required ProcessUtils processUtils, - required String dartSdkPath, - required DevtoolsLauncher devtoolsLauncher, - }) : _applicationPackageFactory = applicationPackageFactory, - _platform = platform, - _logger = logger, - _terminal = terminal, - _outputPreferences = outputPreferences, - _processUtils = processUtils, - _dartSdkPath = dartSdkPath, - _devtoolsLauncher = devtoolsLauncher; + required this._analytics, + required this._applicationPackageFactory, + required this._buildSystem, + required this._buildTargets, + required this._dartSdkPath, + required this._devtoolsLauncher, + required this._toolContext, + }); + final Analytics _analytics; final ApplicationPackageFactory _applicationPackageFactory; - final Platform _platform; - final Logger _logger; - final Terminal _terminal; - final OutputPreferences _outputPreferences; - final ProcessUtils _processUtils; + final BuildSystem _buildSystem; + final BuildTargets _buildTargets; final String _dartSdkPath; final DevtoolsLauncher _devtoolsLauncher; + final ToolContext _toolContext; /// Create a driver service for running `flutter drive`. DriverService createDriverService(bool web) { if (web) { return WebDriverService( - logger: _logger, - terminal: _terminal, - platform: _platform, - outputPreferences: _outputPreferences, - processUtils: _processUtils, + analytics: _analytics, + buildSystem: _buildSystem, + buildTargets: _buildTargets, dartSdkPath: _dartSdkPath, + toolContext: _toolContext, ); } + final ToolContext(:Logger logger, :Platform platform, :ProcessUtils processUtils) = + _toolContext; return FlutterDriverService( - logger: _logger, - platform: _platform, - processUtils: _processUtils, - dartSdkPath: _dartSdkPath, applicationPackageFactory: _applicationPackageFactory, + dartSdkPath: _dartSdkPath, devtoolsLauncher: _devtoolsLauncher, + logger: logger, + platform: platform, + processUtils: processUtils, ); } } +/// Immutable specification consolidating test execution and browser parameters +/// for [DriverService.startTest]. +@immutable +class DriveTestSpecification { + const DriveTestSpecification({ + required this.packageConfig, + this.arguments = const [], + this.headless, + this.chromeBinary, + this.browserName, + this.androidEmulator, + this.driverPort, + this.webBrowserFlags = const [], + this.browserDimension, + this.profileMemory, + }); + + static final _browserDimensionDelimiter = RegExp('[,x@]'); + + /// Splits a raw `--browser-dimension` value (`width x height[@dpr]`) into tokens. + static List? parseBrowserDimension(String? raw) => raw?.split(_browserDimensionDelimiter); + + final List arguments; + final PackageConfig packageConfig; + final bool? headless; + final String? chromeBinary; + final String? browserName; + final bool? androidEmulator; + final int? driverPort; + final List webBrowserFlags; + final List? browserDimension; + final String? profileMemory; +} + /// An interface for the `flutter driver` integration test operations. abstract class DriverService { /// Install and launch the application for the provided [device]. @@ -91,24 +120,12 @@ abstract class DriverService { /// If --use-existing-app is provided, configured the correct VM Service URI. Future reuseApplication(Uri vmServiceUri, Device device, DebuggingOptions debuggingOptions); - /// Start the test file with the provided [arguments] and current environment, + /// Start the test file with the provided [spec] and current environment, /// returning the test process exit code. /// - /// If [profileMemory] is provided, it will be treated as a file path to - /// write a devtools memory profile. - Future startTest( - String testFile, - List arguments, - PackageConfig packageConfig, { - bool? headless, - String? chromeBinary, - String? browserName, - bool? androidEmulator, - int? driverPort, - List webBrowserFlags, - List? browserDimension, - String? profileMemory, - }); + /// If [DriveTestSpecification.profileMemory] is provided, it will be treated + /// as a file path to write a devtools memory profile. + Future startTest(String testFile, DriveTestSpecification spec); /// Stop the running application and uninstall it from the device. Future stop({String? userIdentifier}); @@ -118,22 +135,15 @@ abstract class DriverService { /// applications. class FlutterDriverService extends DriverService { FlutterDriverService({ - required ApplicationPackageFactory applicationPackageFactory, - required Logger logger, - required Platform platform, - required ProcessUtils processUtils, - required String dartSdkPath, - required DevtoolsLauncher devtoolsLauncher, - @visibleForTesting VMServiceConnector vmServiceConnector = connectToVmService, - @visibleForTesting Duration logFlushDelay = const Duration(milliseconds: 500), - }) : _applicationPackageFactory = applicationPackageFactory, - _logger = logger, - _platform = platform, - _processUtils = processUtils, - _dartSdkPath = dartSdkPath, - _vmServiceConnector = vmServiceConnector, - _devtoolsLauncher = devtoolsLauncher, - _logFlushDelay = logFlushDelay; + required this._applicationPackageFactory, + required this._dartSdkPath, + required this._devtoolsLauncher, + required this._logger, + required this._platform, + required this._processUtils, + @visibleForTesting this._logFlushDelay = const Duration(milliseconds: 500), + @visibleForTesting this._vmServiceConnector = connectToVmService, + }); static const _kLaunchAttempts = 3; @@ -252,19 +262,8 @@ class FlutterDriverService extends DriverService { } @override - Future startTest( - String testFile, - List arguments, - PackageConfig packageConfig, { - bool? headless, - String? chromeBinary, - String? browserName, - bool? androidEmulator, - int? driverPort, - List webBrowserFlags = const [], - List? browserDimension, - String? profileMemory, - }) async { + Future startTest(String testFile, DriveTestSpecification spec) async { + final String? profileMemory = spec.profileMemory; if (profileMemory != null) { unawaited( _devtoolsLauncher.launch( @@ -277,7 +276,7 @@ class FlutterDriverService extends DriverService { } try { final int result = await _processUtils.stream( - [_dartSdkPath, ...arguments, testFile], + [_dartSdkPath, ...spec.arguments, testFile], environment: {..._platform.environment, 'VM_SERVICE_URL': _vmServiceUri}, ); return result; diff --git a/packages/flutter_tools/lib/src/drive/import_validator.dart b/packages/flutter_tools/lib/src/drive/import_validator.dart new file mode 100644 index 00000000..c4eee533 --- /dev/null +++ b/packages/flutter_tools/lib/src/drive/import_validator.dart @@ -0,0 +1,156 @@ +// Copyright 2014 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 'package:analyzer/dart/analysis/features.dart'; +import 'package:analyzer/dart/analysis/results.dart'; +import 'package:analyzer/dart/analysis/utilities.dart'; +import 'package:analyzer/dart/ast/ast.dart'; +import 'package:package_config/package_config.dart'; + +import '../base/file_system.dart'; +import '../base/logger.dart'; + +/// Validates that a `flutter_driver` test file and its transitive project-local +/// imports do not import libraries that require the device Flutter engine runtime +/// (such as `dart:ui`, `package:flutter`, or `package:flutter_test`). +/// +/// `flutter_driver` test scripts execute on the host machine using the standard +/// standalone Dart VM, whereas the Flutter framework and `dart:ui` require the +/// Flutter engine runtime on the target device. Importing libraries dependent on +/// `dart:ui` into a host-side driver test results in hundreds of thousands of +/// confusing compilation errors during test execution. +/// +/// This validator performs static AST analysis using [parseString] on the driver +/// test file and recursively inspects referenced files located within the project +/// root. It avoids traversing third-party package dependencies outside the project +/// root to ensure rapid validation (< 10 ms). +class DriverTestImportValidator { + /// Creates a validator for driver test imports. + /// + /// Required arguments: + /// * [fileSystem]: Used to read files and resolve canonical paths. + /// * [_logger]: Used to log trace messages during parsing failures. + /// * [_packageConfig]: Used to resolve `package:` URIs within the project. + /// * [projectRootPath]: The root directory of the Flutter project, used as a + /// boundary to restrict transitive analysis to project-local sources. + DriverTestImportValidator({ + required FileSystem fileSystem, + required this._logger, + required this._packageConfig, + required String projectRootPath, + }) : _fileSystem = fileSystem, + _projectRootPath = fileSystem.path.canonicalize(projectRootPath); + + final FileSystem _fileSystem; + final Logger _logger; + final PackageConfig _packageConfig; + final String _projectRootPath; + + /// Tracks canonical file paths already visited during recursive traversal to + /// prevent infinite loops caused by circular import dependencies. + final Set _visitedFiles = {}; + + /// Package prefixes forbidden in host-side driver tests. + static const List _forbiddenPrefixes = [ + 'package:flutter/', + 'package:flutter_test/', + ]; + + /// Direct library imports forbidden in host-side driver tests. + static const List _forbiddenImports = ['dart:ui']; + + /// Validates the [driverTestFile] and all its transitive imports within the + /// project root. + /// + /// Returns a list of error descriptions. If the returned list is empty, all + /// imports are valid. + List validate(File driverTestFile) { + _visitedFiles.clear(); + final errors = []; + _validateFile(driverTestFile, errors); + return errors; + } + + /// Recursively inspects [file] and its import/export directives for forbidden + /// dependencies. + void _validateFile(File file, List errors) { + final String canonicalPath = _fileSystem.path.canonicalize(file.path); + if (_visitedFiles.contains(canonicalPath)) { + return; + } + _visitedFiles.add(canonicalPath); + + if (!file.existsSync()) { + return; + } + + try { + final String content = file.readAsStringSync(); + final ParseStringResult result = parseString( + content: content, + path: file.path, + featureSet: FeatureSet.latestLanguageVersion(), + ); + final CompilationUnit unit = result.unit; + for (final Directive directive in unit.directives) { + if (directive is UriBasedDirective) { + final String? uriString = directive.uri.stringValue; + if (uriString == null) { + continue; + } + if (_isForbidden(uriString)) { + errors.add('Forbidden import/export "$uriString" found in ${file.path}'); + continue; + } + + final File? resolvedFile = _resolveUri(uriString, file); + if (resolvedFile != null && _isWithinProjectRoot(resolvedFile)) { + _validateFile(resolvedFile, errors); + } + } + } + } on Exception catch (e) { + _logger.printTrace('Failed to parse ${file.path}: $e'); + } + } + + /// Checks whether [uriString] references a forbidden library or package prefix. + bool _isForbidden(String uriString) { + if (_forbiddenImports.contains(uriString)) { + return true; + } + for (final String prefix in _forbiddenPrefixes) { + if (uriString.startsWith(prefix)) { + return true; + } + } + return false; + } + + /// Resolves [uriString] relative to [referencingFile] or via [_packageConfig]. + /// + /// Returns a [File] if the URI resolves to a local file path, or `null` otherwise. + File? _resolveUri(String uriString, File referencingFile) { + final Uri uri = Uri.parse(uriString); + if (uri.scheme == 'package') { + final Uri? resolvedUri = _packageConfig.resolve(uri); + if (resolvedUri != null && resolvedUri.scheme == 'file') { + return _fileSystem.file(resolvedUri); + } + } else if (uri.scheme == '' || uri.scheme == 'file') { + final String dir = _fileSystem.path.dirname(referencingFile.path); + final String resolvedPath = _fileSystem.path.canonicalize( + _fileSystem.path.join(dir, uri.path), + ); + return _fileSystem.file(resolvedPath); + } + return null; + } + + /// Determines whether [file] resides within [_projectRootPath]. + bool _isWithinProjectRoot(File file) { + final String canonicalPath = _fileSystem.path.canonicalize(file.path); + return _fileSystem.path.isWithin(_projectRootPath, canonicalPath); + } +} diff --git a/packages/flutter_tools/lib/src/drive/web_driver_service.dart b/packages/flutter_tools/lib/src/drive/web_driver_service.dart index 0780d817..d884e0a2 100644 --- a/packages/flutter_tools/lib/src/drive/web_driver_service.dart +++ b/packages/flutter_tools/lib/src/drive/web_driver_service.dart @@ -7,7 +7,7 @@ import 'dart:math' as math; import 'package:file/file.dart'; import 'package:meta/meta.dart'; -import 'package:package_config/package_config.dart'; +import 'package:unified_analytics/unified_analytics.dart'; import 'package:webdriver/async_io.dart' as async_io; import '../base/common.dart'; @@ -15,39 +15,34 @@ import '../base/io.dart'; import '../base/logger.dart'; import '../base/platform.dart'; import '../base/process.dart'; -import '../base/terminal.dart'; import '../base/utils.dart'; import '../build_info.dart'; +import '../build_system/build_system.dart'; +import '../build_system/build_targets.dart'; +import '../context/tool_context.dart'; import '../convert.dart'; import '../device.dart'; -import '../globals.dart' as globals; import '../project.dart'; import '../resident_runner.dart'; +import '../web/chrome_constants.dart'; import '../web/web_runner.dart'; import 'drive_service.dart'; /// An implementation of the driver service for web debug and release applications. class WebDriverService extends DriverService { WebDriverService({ - required ProcessUtils processUtils, - required String dartSdkPath, - required Platform platform, - required Logger logger, - required Terminal terminal, - required OutputPreferences outputPreferences, - }) : _processUtils = processUtils, - _dartSdkPath = dartSdkPath, - _platform = platform, - _logger = logger, - _terminal = terminal, - _outputPreferences = outputPreferences; - - final ProcessUtils _processUtils; + required this._analytics, + required this.buildSystem, + required this.buildTargets, + required this._dartSdkPath, + required this._toolContext, + }); + + final Analytics _analytics; + final BuildSystem buildSystem; + final BuildTargets buildTargets; final String _dartSdkPath; - final Platform _platform; - final Logger _logger; - final Terminal _terminal; - final OutputPreferences _outputPreferences; + final ToolContext _toolContext; late ResidentRunner _residentRunner; Uri? _webUri; @@ -76,38 +71,35 @@ class WebDriverService extends DriverService { }) async { final FlutterDevice flutterDevice = await FlutterDevice.create( device, - target: mainPath, + toolContext: _toolContext, buildInfo: buildInfo, - platform: _platform, + target: mainPath, + userIdentifier: userIdentifier, + ); + final ToolContext(:FileSystem fs, :FlutterProjectFactory projectFactory) = _toolContext; + final webConfig = WebDebugConfig( + devServerConfig: debuggingOptions.webConfig.devServerConfig, + renderer: debuggingOptions.webConfig.renderer, + useWasm: debuggingOptions.webConfig.useWasm, ); _residentRunner = webRunnerFactory!.createWebRunner( flutterDevice, target: mainPath, debuggingOptions: buildInfo.isRelease - ? DebuggingOptions.disabled( - buildInfo, - webDevServerConfig: debuggingOptions.webDevServerConfig, - webRenderer: debuggingOptions.webRenderer, - webUseWasm: debuggingOptions.webUseWasm, - ) + ? DebuggingOptions.disabled(buildInfo, webConfig: webConfig) : DebuggingOptions.enabled( buildInfo, - webDevServerConfig: debuggingOptions.webDevServerConfig, disablePortPublication: debuggingOptions.disablePortPublication, - webRenderer: debuggingOptions.webRenderer, - webUseWasm: debuggingOptions.webUseWasm, + webConfig: webConfig, ), platformArgs: platformArgs, stayResident: true, webDefines: webDefines, - flutterProject: FlutterProject.current(), - fileSystem: globals.fs, - analytics: globals.analytics, - logger: _logger, - terminal: _terminal, - platform: _platform, - outputPreferences: _outputPreferences, - systemClock: globals.systemClock, + flutterProject: projectFactory.fromDirectory(fs.currentDirectory), + analytics: _analytics, + buildSystem: buildSystem, + buildTargets: buildTargets, + toolContext: _toolContext, ); final appStartedCompleter = Completer.sync(); final Future runFuture = _residentRunner.run( @@ -142,34 +134,25 @@ class WebDriverService extends DriverService { throwToolExit('Unable to connect to the app. URL not available.'); } - if (debuggingOptions.webLaunchUrl != null) { + if (debuggingOptions.webConfig.launchUrl case final String launchUrl) { // It should throw an error if the provided url is invalid so no tryParse - _webUri = Uri.parse(debuggingOptions.webLaunchUrl!); + _webUri = Uri.parse(launchUrl); } else { _webUri = _residentRunner.uri; } } @override - Future startTest( - String testFile, - List arguments, - PackageConfig packageConfig, { - bool? headless, - String? chromeBinary, - String? browserName, - bool? androidEmulator, - int? driverPort, - List webBrowserFlags = const [], - List? browserDimension, - String? profileMemory, - }) async { + Future startTest(String testFile, DriveTestSpecification spec) async { + final ToolContext(:Logger logger, :Platform platform, :ProcessUtils processUtils) = + _toolContext; late async_io.WebDriver webDriver; - final Browser browser = Browser.fromCliName(browserName); + final Browser browser = Browser.fromCliName(spec.browserName); final isAndroidChrome = browser == Browser.androidChrome; + final List? browserDimension = spec.browserDimension; late int width; late int height; - Map? mobileEmulation; + Map? mobileEmulation; // Do not resize Android Chrome browser. // For PC Chrome use mobileEmulation if dpr is provided. @@ -182,8 +165,8 @@ class WebDriverService extends DriverService { width = int.parse(browserDimension[0]); height = int.parse(browserDimension[1]); if (len == 3) { - mobileEmulation = { - 'deviceMetrics': { + mobileEmulation = { + 'deviceMetrics': { 'width': width, 'height': height, 'pixelRatio': double.parse(browserDimension[2]), @@ -200,21 +183,21 @@ class WebDriverService extends DriverService { try { webDriver = await async_io.createDriver( - uri: Uri.parse('http://localhost:$driverPort/'), + uri: Uri.parse('http://localhost:${spec.driverPort}/'), desired: getDesiredCapabilities( browser, - headless, - platform: _platform, - webBrowserFlags: webBrowserFlags, - chromeBinary: chromeBinary, + spec.headless, + platform: platform, + webBrowserFlags: spec.webBrowserFlags, + chromeBinary: spec.chromeBinary, mobileEmulation: mobileEmulation, ), ); } on SocketException catch (error) { - _logger.printTrace('$error'); + logger.printTrace('$error'); throwToolExit( 'Unable to start a WebDriver session for web testing.\n' - 'Make sure you have the correct WebDriver server (e.g. chromedriver) running at $driverPort.\n' + 'Make sure you have the correct WebDriver server (e.g. chromedriver) running at ${spec.driverPort}.\n' 'For instructions on how to obtain and run a WebDriver server, see:\n' 'https://flutter.dev/to/integration-test-on-web\n', ); @@ -225,12 +208,12 @@ class WebDriverService extends DriverService { await window.setLocation(const math.Point(0, 0)); await window.setSize(math.Rectangle(0, 0, width, height)); } - final int result = await _processUtils.stream( - [_dartSdkPath, ...arguments, testFile], + final int result = await processUtils.stream( + [_dartSdkPath, ...spec.arguments, testFile], environment: { - ..._platform.environment, + ...platform.environment, 'VM_SERVICE_URL': _webUri.toString(), - ..._additionalDriverEnvironment(webDriver, browserName, androidEmulator), + ..._additionalDriverEnvironment(webDriver, spec.browserName, spec.androidEmulator), }, ); await webDriver.quit(); @@ -321,27 +304,32 @@ enum Browser implements CliEnum { /// Returns desired capabilities for given [browser], [headless], [chromeBinary] /// and [webBrowserFlags]. @visibleForTesting -Map getDesiredCapabilities( +Map getDesiredCapabilities( Browser browser, bool? headless, { - Platform platform = const LocalPlatform(), - List webBrowserFlags = const [], + required Platform platform, String? chromeBinary, - Map? mobileEmulation, + Map? mobileEmulation, + List webBrowserFlags = const [], }) => switch (browser) { - Browser.chrome => { + Browser.chrome => { 'acceptInsecureCerts': true, 'browserName': 'chrome', 'goog:loggingPrefs': { async_io.LogType.browser: 'INFO', async_io.LogType.performance: 'ALL', }, - 'goog:chromeOptions': { + 'goog:chromeOptions': { 'w3c': true, 'args': [ '--bwsi', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', + '--disable-background-networking', + '--disable-sync', + '--disable-client-side-phishing-detection', + '--disable-notifications', + ...kGcmDisabledFlags, '--disable-default-apps', '--disable-extensions', '--disable-popup-blocking', @@ -373,12 +361,12 @@ Map getDesiredCapabilities( 'mobileEmulation': ?mobileEmulation, }, }, - Browser.firefox => { + Browser.firefox => { 'acceptInsecureCerts': true, 'browserName': 'firefox', - 'moz:firefoxOptions': { + 'moz:firefoxOptions': { 'args': [if (headless!) '-headless', ...webBrowserFlags], - 'prefs': { + 'prefs': { 'dom.file.createInChild': true, 'dom.timeout.background_throttling_max_budget': -1, 'media.autoplay.default': 0, @@ -391,17 +379,17 @@ Map getDesiredCapabilities( 'log': {'level': 'trace'}, }, }, - Browser.edge => {'acceptInsecureCerts': true, 'browserName': 'edge'}, - Browser.safari => {'browserName': 'safari'}, - Browser.iosSafari => { + Browser.edge => {'acceptInsecureCerts': true, 'browserName': 'edge'}, + Browser.safari => {'browserName': 'safari'}, + Browser.iosSafari => { 'platformName': 'ios', 'browserName': 'safari', 'safari:useSimulator': true, }, - Browser.androidChrome => { + Browser.androidChrome => { 'browserName': 'chrome', 'platformName': 'android', - 'goog:chromeOptions': { + 'goog:chromeOptions': { 'androidPackage': 'com.android.chrome', 'args': ['--disable-fullscreen', ...webBrowserFlags], }, diff --git a/packages/flutter_tools/lib/src/emulator.dart b/packages/flutter_tools/lib/src/emulator.dart index 1dbd5c9c..bea7156f 100644 --- a/packages/flutter_tools/lib/src/emulator.dart +++ b/packages/flutter_tools/lib/src/emulator.dart @@ -23,27 +23,28 @@ EmulatorManager? get emulatorManager => context.get(); /// A class to get all available emulators. class EmulatorManager { EmulatorManager({ - required Java? java, - AndroidSdk? androidSdk, - required Logger logger, - required ProcessManager processManager, required AndroidWorkflow androidWorkflow, required FileSystem fileSystem, - }) : _java = java, - _androidSdk = androidSdk, + required this._javaBuilder, + required Logger logger, + required ProcessManager processManager, + AndroidSdk? Function()? androidSdkBuilder, + }) : _androidSdkBuilder = androidSdkBuilder, _processUtils = ProcessUtils(logger: logger, processManager: processManager), _androidEmulators = AndroidEmulators( - androidSdk: androidSdk, + androidSdkBuilder: androidSdkBuilder, + androidWorkflow: androidWorkflow, + fileSystem: fileSystem, logger: logger, processManager: processManager, - fileSystem: fileSystem, - androidWorkflow: androidWorkflow, ) { _emulatorDiscoverers.add(_androidEmulators); } - final Java? _java; - final AndroidSdk? _androidSdk; + final AndroidSdk? Function()? _androidSdkBuilder; + late final AndroidSdk? _androidSdk = _androidSdkBuilder?.call(); + final Java? Function() _javaBuilder; + late final Java? _java = _javaBuilder(); final AndroidEmulators _androidEmulators; final ProcessUtils _processUtils; diff --git a/packages/flutter_tools/lib/src/experimental/config.dart b/packages/flutter_tools/lib/src/experimental/config.dart new file mode 100644 index 00000000..84b494e6 --- /dev/null +++ b/packages/flutter_tools/lib/src/experimental/config.dart @@ -0,0 +1,197 @@ +// Copyright 2014 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 'package:flutter_tools_core/flutter_tools_core.dart'; +import 'package:flutter_tools_extension/flutter_tools_extension.dart'; + +import '../base/logger.dart'; +import 'extension_discovery.dart'; + +/// Represents a group of configuration settings provided by a specific extension. +class ExtensionSettingsGroup { + const ExtensionSettingsGroup({ + required this.title, + required this.featureFlags, + required this.configOptions, + }); + + /// The human-readable title of the extension. + final String title; + + /// Feature flags registered by the extension. + final List featureFlags; + + /// Config options registered by the extension. + final List configOptions; + + /// Whether this group contains any feature flags or configuration options. + bool get isNotEmpty => featureFlags.isNotEmpty || configOptions.isNotEmpty; + + /// Whether this group is empty. + bool get isEmpty => !isNotEmpty; +} + +/// A host-side configuration manager that delegates feature flag and option queries to extensions. +class ExtensionConfiguration { + ExtensionConfiguration({required List extensions, required this._logger}) + : extensions = List.unmodifiable(extensions); + + /// The active extension services executing configuration queries. + final List extensions; + final Logger _logger; + + /// Retrieves feature flags provided by all registered extensions. + Future> fetchFeatureFlags() async { + _logger.printTrace( + 'ExtensionConfiguration fetching feature flags across ${extensions.length} extension(s)...', + ); + final List> results = await Future.wait( + extensions.map((ConfigurationExtension ext) async { + try { + return await ext.getFeatureFlags(); + } on Object catch (err, stack) { + _logger.printTrace( + 'Failed to fetch feature flags from extension "${ext.title}": $err\n$stack', + ); + return const []; + } + }), + ); + final flags = [for (final list in results) ...list]; + _logger.printTrace('ExtensionConfiguration retrieved ${flags.length} total feature flag(s).'); + return flags; + } + + /// Retrieves configuration options provided by all registered extensions. + Future> fetchConfigurations() async { + _logger.printTrace( + 'ExtensionConfiguration fetching config options across ${extensions.length} extension(s)...', + ); + final List> results = await Future.wait( + extensions.map((ConfigurationExtension ext) async { + try { + return await ext.getConfigurations(); + } on Object catch (err, stack) { + _logger.printTrace( + 'Failed to fetch config options from extension "${ext.title}": $err\n$stack', + ); + return const []; + } + }), + ); + final options = [for (final list in results) ...list]; + _logger.printTrace( + 'ExtensionConfiguration retrieved ${options.length} total config option(s).', + ); + return options; + } + + /// Retrieves configuration settings grouped by extension name. + Future> fetchExtensionSettings() async { + _logger.printTrace( + 'ExtensionConfiguration fetching settings groups across ${extensions.length} extension(s)...', + ); + final List groups = (await Future.wait( + extensions.map((ConfigurationExtension ext) async { + try { + final String title = ext is ConfigurationExtensionClient + ? await ext.fetchTitle() + : ext.title; + final List flags = await ext.getFeatureFlags(); + final List options = await ext.getConfigurations(); + return ExtensionSettingsGroup(title: title, featureFlags: flags, configOptions: options); + } on Object catch (err, stack) { + _logger.printTrace( + 'Failed to fetch settings from extension "${ext.title}": $err\n$stack', + ); + return null; + } + }), + )).whereType().toList(); + _logger.printTrace('ExtensionConfiguration retrieved ${groups.length} settings group(s).'); + return groups; + } +} + +/// A host-side [ConfigurationExtension] client adapter that delegates RPC queries to an [ExtensionConnection]. +class ConfigurationExtensionClient extends ConfigurationExtension { + ConfigurationExtensionClient( + this.connection, { + required this._logger, + this._defaultTitle = 'Tool Extension Configuration', + }); + + /// The active extension isolate connection. + final ExtensionConnection connection; + final String _defaultTitle; + final Logger _logger; + String? _titleCache; + + @override + String get title => _titleCache ?? _defaultTitle; + + @override + Future fetchTitle() async { + if (_titleCache != null) { + return _titleCache!; + } + _logger.printTrace( + 'ConfigurationExtensionClient fetching title via RPC ("${ConfigurationExtension.getTitleMethod}")...', + ); + try { + final Object? response = await connection.sendRequest(ConfigurationExtension.getTitleMethod); + if (response case final String title) { + _titleCache = title; + } else { + _logger.printTrace( + 'ConfigurationExtensionClient received invalid title response: $response', + ); + } + } on Object catch (err, stack) { + _logger.printTrace('ConfigurationExtensionClient failed to fetch title: $err\n$stack'); + } + return _titleCache ?? _defaultTitle; + } + + Future> _fetchList( + String method, + String entityName, + T Function(Map) fromJson, + ) async { + _logger.printTrace('ConfigurationExtensionClient fetching $entityName via RPC ("$method")...'); + try { + final Object? rawResult = await connection.sendRequest(method); + if (rawResult case final List list) { + final results = [ + for (final Object? element in list) + if (element case final Map map) fromJson(map.cast()), + ]; + _logger.printTrace( + 'ConfigurationExtensionClient received ${results.length} $entityName via RPC.', + ); + return results; + } + _logger.printTrace( + 'ConfigurationExtensionClient received invalid or null $entityName response: $rawResult', + ); + } on Object catch (err, stack) { + _logger.printTrace('ConfigurationExtensionClient failed to get $entityName: $err\n$stack'); + } + return []; + } + + @override + Future> getFeatureFlags() => _fetchList( + ConfigurationExtension.getFeatureFlagsMethod, + 'feature flag(s)', + FeatureFlag.fromJson, + ); + + @override + Future> getConfigurations() => _fetchList( + ConfigurationExtension.getConfigurationsMethod, + 'config option(s)', + ConfigOption.fromJson, + ); +} diff --git a/packages/flutter_tools/lib/src/experimental/diagnostics.dart b/packages/flutter_tools/lib/src/experimental/diagnostics.dart new file mode 100644 index 00000000..5d4b10c3 --- /dev/null +++ b/packages/flutter_tools/lib/src/experimental/diagnostics.dart @@ -0,0 +1,135 @@ +// Copyright 2014 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 'package:flutter_tools_core/flutter_tools_core.dart'; +import 'package:flutter_tools_extension/flutter_tools_extension.dart'; + +import '../base/logger.dart'; +import '../doctor_validator.dart'; +import 'extension_discovery.dart'; + +/// A host [DoctorValidator] adapter that delegates diagnostic checks to a single tool extension. +class ExtensionDoctorValidator extends DoctorValidator { + /// Creates an [ExtensionDoctorValidator] for the given diagnostic [extension]. + ExtensionDoctorValidator({required this.extension, required this._logger}) + : super(extension.title); + + /// The active extension service executing diagnostic checks. + final DiagnosticsExtension extension; + final Logger _logger; + + @override + String get title => extension.title; + + @override + Future validateImpl() async { + _logger.printTrace( + 'ExtensionDoctorValidator validating diagnostics for extension "${extension.title}".', + ); + final List results = await extension.runDiagnostics(); + _logger.printTrace( + 'ExtensionDoctorValidator received ${results.length} validation result(s) from "${extension.title}".', + ); + final allMessages = []; + ValidationType aggregateType = ValidationType.success; + String? statusInfo; + + for (final result in results) { + statusInfo ??= result.statusInfo; + allMessages.addAll(result.messages); + switch (result.type) { + case ValidationType.crash: + aggregateType = ValidationType.crash; + case ValidationType.missing: + if (aggregateType != ValidationType.crash) { + aggregateType = ValidationType.missing; + } + case ValidationType.partial: + if (aggregateType != ValidationType.crash && aggregateType != ValidationType.missing) { + aggregateType = ValidationType.partial; + } + case ValidationType.notAvailable: + if (aggregateType != ValidationType.crash && + aggregateType != ValidationType.missing && + aggregateType != ValidationType.partial) { + aggregateType = ValidationType.notAvailable; + } + case ValidationType.success: + break; + } + } + + return ValidationResult(aggregateType, allMessages, statusInfo: statusInfo); + } +} + +/// A host-side [DiagnosticsExtension] client adapter that delegates RPC queries to an [ExtensionConnection]. +class DiagnosticsExtensionClient extends DiagnosticsExtension { + /// Creates a [DiagnosticsExtensionClient] wrapping the host [connection]. + DiagnosticsExtensionClient( + this.connection, { + required this._logger, + this._defaultTitle = 'Tool Extension Diagnostics', + }); + + /// The active extension isolate connection. + final ExtensionConnection connection; + final String _defaultTitle; + final Logger _logger; + String? _titleCache; + + @override + String get title => _titleCache ?? _defaultTitle; + + /// Fetches the validator title from the remote extension isolate. + Future fetchTitle() async { + if (_titleCache != null) { + return _titleCache!; + } + _logger.printTrace( + 'DiagnosticsExtensionClient fetching title via RPC ("${DiagnosticsExtension.getTitleMethod}")...', + ); + try { + final Object? response = await connection.sendRequest(DiagnosticsExtension.getTitleMethod); + if (response is String) { + _titleCache = response; + } else { + _logger.printTrace('DiagnosticsExtensionClient received invalid title response: $response'); + } + } on Object catch (err, stack) { + _logger.printTrace('DiagnosticsExtensionClient failed to fetch title: $err\n$stack'); + } + return _titleCache ?? _defaultTitle; + } + + @override + Future> runDiagnostics() async { + _logger.printTrace( + 'DiagnosticsExtensionClient running diagnostics via RPC ("${DiagnosticsExtension.runDiagnosticsMethod}")...', + ); + try { + final Object? response = await connection.sendRequest( + DiagnosticsExtension.runDiagnosticsMethod, + ); + if (response is List) { + final results = []; + for (final Object? element in response) { + if (element is Map) { + results.add(ValidationResult.fromJson(element.cast())); + } + } + _logger.printTrace( + 'DiagnosticsExtensionClient received ${results.length} result(s) via RPC.', + ); + return results; + } + _logger.printTrace( + 'DiagnosticsExtensionClient received invalid diagnostics response: $response', + ); + } on Object catch (err, stack) { + _logger.printTrace('DiagnosticsExtensionClient failed to run diagnostics: $err\n$stack'); + } + return const []; + } +} diff --git a/packages/flutter_tools/lib/src/experimental/extension_arg_parser.dart b/packages/flutter_tools/lib/src/experimental/extension_arg_parser.dart new file mode 100644 index 00000000..d44e2373 --- /dev/null +++ b/packages/flutter_tools/lib/src/experimental/extension_arg_parser.dart @@ -0,0 +1,152 @@ +// Copyright 2014 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. + +/// Dynamic argument parser mixin for tool extensions. +/// +/// This library provides a mixin to allow commands to dynamically rebuild +/// their argument parsers when extension-provided options change. +library experimental.extension_arg_parser; + +import 'package:args/args.dart'; +import 'package:meta/meta.dart'; + +import '../base/terminal.dart'; +import '../runner/flutter_command.dart'; + +/// A mixin on `FlutterCommand` that supports lazy, dynamic rebuilding of `argParser` +/// when extension-provided options or templates change at runtime. +/// +/// Because `ArgParser` instances in `package:args` cannot be mutated once options +/// or commands are registered, dynamic commands must reconstruct their `argParser` +/// when new extension capabilities (such as project templates) are discovered. +mixin ExtensionArgParserMixin on FlutterCommand { + ArgParser? _baseArgParser; + ArgParser? _dynamicArgParser; + bool _buildingBaseParser = false; + + /// Creates and configures the static base `ArgParser` for this command. + /// + /// Subclasses should override this method to register their static options + /// and flags rather than adding options in the constructor, allowing the + /// base parser to be cloned and dynamically rebuilt when extension options change. + @protected + ArgParser createBaseArgParser() { + final OutputPreferences? preferences = toolContext?.outputPreferences; + final bool wrapText = preferences?.wrapText ?? false; + final int? wrapColumn = preferences?.wrapColumn; + return ArgParser(allowTrailingOptions: false, usageLineLength: wrapText ? wrapColumn : null); + } + + /// Injects dynamic extension options or allowed help entries into [dynamicParser]. + /// + /// [dynamicParser] is a pre-cloned copy of [baseArgParser]. Subclasses should + /// mutate and return [dynamicParser] with extension options added. + @protected + ArgParser buildDynamicArgParser(ArgParser dynamicParser); + + /// Clones [opt] from an existing [ArgParser] into [target]. + @protected + static void copyOption(ArgParser target, Option opt) { + switch (opt.type) { + case OptionType.flag: + target.addFlag( + opt.name, + abbr: opt.abbr, + aliases: opt.aliases, + defaultsTo: opt.defaultsTo as bool?, + help: opt.help, + hide: opt.hide, + hideNegatedUsage: opt.hideNegatedUsage ?? false, + negatable: opt.negatable ?? true, + ); + case OptionType.single: + target.addOption( + opt.name, + abbr: opt.abbr, + aliases: opt.aliases, + allowed: opt.allowed, + allowedHelp: opt.allowedHelp, + defaultsTo: opt.defaultsTo as String?, + help: opt.help, + hide: opt.hide, + mandatory: opt.mandatory, + valueHelp: opt.valueHelp, + ); + case OptionType.multiple: + target.addMultiOption( + opt.name, + abbr: opt.abbr, + aliases: opt.aliases, + allowed: opt.allowed, + allowedHelp: opt.allowedHelp, + defaultsTo: (opt.defaultsTo as Iterable?)?.cast(), + help: opt.help, + hide: opt.hide, + splitCommas: opt.splitCommas, + valueHelp: opt.valueHelp, + ); + } + } + + /// Clones all options from [source] into a new [ArgParser] instance. + @protected + static ArgParser cloneParser( + ArgParser source, { + void Function(ArgParser newParser, Option opt)? optionCloner, + }) { + final newParser = ArgParser( + allowTrailingOptions: source.allowTrailingOptions, + usageLineLength: source.usageLineLength, + ); + for (final Option opt in source.options.values) { + if (optionCloner != null) { + optionCloner(newParser, opt); + } else { + copyOption(newParser, opt); + } + } + return newParser; + } + + /// Returns the base static `ArgParser` for this command, initializing it if needed. + ArgParser get baseArgParser { + if (_baseArgParser != null) { + return _baseArgParser!; + } + _buildingBaseParser = true; + try { + final ArgParser parser = createBaseArgParser(); + _baseArgParser = parser; + return parser; + } finally { + _buildingBaseParser = false; + } + } + + /// Rebuilds the dynamic argument parser from [baseArgParser]. + /// + /// Subclasses should call this when dynamic options or capabilities are discovered. + @protected + void rebuildDynamicArgParser() { + final ArgParser clonedParser = cloneParser(baseArgParser); + _dynamicArgParser = buildDynamicArgParser(clonedParser); + // Re-add subcommands to the dynamic parser to ensure they are not lost. + for (final MapEntry(:key, :value) in subcommands.entries) { + if (!_dynamicArgParser!.commands.containsKey(key)) { + _dynamicArgParser!.addCommand(key, value.argParser); + } + } + } + + /// Returns either the base parser or a dynamically rebuilt parser if dynamic options were added. + @override + ArgParser get argParser { + assert( + !_buildingBaseParser, + 'argParser was accessed re-entrantly while createBaseArgParser was executing. ' + 'Subclasses should add options directly to the ArgParser created in createBaseArgParser.', + ); + return _dynamicArgParser ?? baseArgParser; + } +} diff --git a/packages/flutter_tools/lib/src/experimental/extension_build_manager.dart b/packages/flutter_tools/lib/src/experimental/extension_build_manager.dart new file mode 100644 index 00000000..efba0c42 --- /dev/null +++ b/packages/flutter_tools/lib/src/experimental/extension_build_manager.dart @@ -0,0 +1,155 @@ +// Copyright 2014 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 'package:flutter_tools_core/flutter_tools_core.dart'; +import 'package:flutter_tools_extension/flutter_tools_extension.dart'; + +import '../base/logger.dart'; +import '../features.dart'; +import 'extension_discovery.dart'; +import 'extension_manager.dart'; + +/// Manages querying build targets and running custom builds from extension isolates. +base class ExtensionBuildManager { + ExtensionBuildManager({ + required this._extensionManager, + required this._featureFlags, + required this._logger, + }); + + final ExtensionManager _extensionManager; + final FeatureFlags _featureFlags; + final Logger _logger; + + List? _cachedTargets; + final _targetToConnection = {}; + + /// Retrieve the cached build targets synchronously. + List get cachedTargets => _cachedTargets ?? const []; + + /// Retrieve build targets by routing `build.getBuildTargets` to active tool extensions. + Future> getBuildTargets() async { + if (!_featureFlags.isToolExtensionsEnabled) { + return const []; + } + if (_cachedTargets case final cachedTargets?) { + return cachedTargets; + } + + try { + await _extensionManager.ensureInitialized(); + } on Object catch (e) { + _logger.printTrace('Failed to initialize extension manager: $e'); + return const []; + } + + final targets = []; + _targetToConnection.clear(); + final connections = [ + for (final connection in _extensionManager.connections) + if (connection.capabilities.services.contains(BuildService.serviceNamespace)) connection, + ]; + + final List<(ExtensionConnection, List)> connectionTargets = + await Future.wait( + connections.map((ExtensionConnection connection) async { + try { + final Object? result = await connection.sendRequest( + BuildService.getBuildTargetsMethod, + ); + return (connection, ExtensionBuildTarget.listFromJson(result)); + } on Object catch (e) { + _logger.printTrace('Failed to query build targets from extension: $e'); + return (connection, const []); + } + }), + ); + + for (final (ExtensionConnection connection, List targetList) + in connectionTargets) { + for (final target in targetList) { + if (target.name.isEmpty) { + continue; + } + if (_targetToConnection.containsKey(target.name)) { + // TODO(bkonyi): Include conflicting extension names once extension manifest metadata is wired into ExtensionConnection. + _logger.printWarning('Skipping duplicate build target "${target.name}" from extension.'); + continue; + } + targets.add(target); + _targetToConnection[target.name] = connection; + } + } + + _cachedTargets = targets; + return targets; + } + + /// Triggers a custom build for the given [targetName] by routing to the active extension. + Future build({ + required BuildMode buildMode, + required String mainPath, + required Uri projectRoot, + required String targetName, + }) async { + if (!projectRoot.hasAbsolutePath) { + throw ArgumentError.value(projectRoot, 'projectRoot', 'Must be an absolute path.'); + } + if (!_featureFlags.isToolExtensionsEnabled) { + return const ExtensionBuildResult( + success: false, + errorMessage: 'Tool extensions are disabled.', + ); + } + + try { + await _extensionManager.ensureInitialized(); + } on Object catch (e) { + _logger.printTrace('Failed to initialize extension manager: $e'); + return ExtensionBuildResult( + success: false, + errorMessage: 'Failed to initialize extension manager: $e', + ); + } + + if (_cachedTargets == null) { + await getBuildTargets(); + } + + final ExtensionConnection? connection = _targetToConnection[targetName]; + if (connection == null) { + return ExtensionBuildResult( + success: false, + errorMessage: 'No extension found to handle build target $targetName.', + ); + } + + try { + final Object? result = await connection.sendRequest( + BuildService.buildMethod, + params: { + BuildService.buildModeParam: buildMode.cliName, + BuildService.mainPathParam: mainPath, + BuildService.projectRootParam: projectRoot.toString(), + BuildService.targetNameParam: targetName, + }, + // TODO(bkonyi): Support heartbeat / liveness monitoring for long-running RPCs, + // https://github.com/flutter/flutter/issues/193955. + timeout: null, + ); + if (result case final Map resultMap) { + return ExtensionBuildResult.fromJson(resultMap); + } + return const ExtensionBuildResult( + success: false, + errorMessage: 'Invalid build result from extension.', + ); + } on Object catch (e) { + _logger.printTrace('Failed to run build from extension: $e'); + return ExtensionBuildResult(success: false, errorMessage: e.toString()); + } + } +} diff --git a/packages/flutter_tools/lib/src/experimental/extension_device_manager.dart b/packages/flutter_tools/lib/src/experimental/extension_device_manager.dart new file mode 100644 index 00000000..c92782a4 --- /dev/null +++ b/packages/flutter_tools/lib/src/experimental/extension_device_manager.dart @@ -0,0 +1,236 @@ +// Copyright 2014 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 'package:flutter_tools_core/flutter_tools_core.dart'; +import 'package:flutter_tools_extension/flutter_tools_extension.dart'; + +import '../application_package.dart'; +import '../base/logger.dart'; +import '../build_info.dart'; +import '../device.dart'; +import '../device_port_forwarder.dart'; +import '../project.dart'; +import 'extension_discovery.dart'; +import 'extension_manager.dart'; + +/// A host-side [DeviceService] client adapter delegating RPC queries to an [ExtensionConnection]. +final class ExtensionDeviceClient extends DeviceService { + /// Creates an [ExtensionDeviceClient] wrapping the host [connection]. + ExtensionDeviceClient(this.connection, {required this._logger}); + + /// The active extension isolate connection. + final ExtensionConnection connection; + final Logger _logger; + + @override + Future> getDevices() async { + _logger.printTrace( + 'ExtensionDeviceClient fetching devices via RPC ("${DeviceService.getDevicesMethod}")...', + ); + try { + final Object? rawResult = await connection + .sendRequest(DeviceService.getDevicesMethod) + .timeout(const Duration(seconds: 5)); + final List devices = TargetDevice.listFromJson(rawResult); + _logger.printTrace('ExtensionDeviceClient received ${devices.length} device(s) via RPC.'); + return devices; + } on Object catch (err, stack) { + _logger.printTrace('ExtensionDeviceClient failed to get devices: $err\n$stack'); + } + return const []; + } + + @override + Future isSupportedForProject({required String deviceId, required Uri projectRoot}) async { + _logger.printTrace( + 'ExtensionDeviceClient checking project support for "$deviceId" via RPC ' + '("${DeviceService.isSupportedForProjectMethod}")...', + ); + try { + final Object? rawResult = await connection + .sendRequest( + DeviceService.isSupportedForProjectMethod, + params: { + DeviceService.deviceIdParam: deviceId, + DeviceService.projectRootParam: projectRoot.toString(), + }, + ) + .timeout(const Duration(seconds: 5)); + if (rawResult case final bool supported) { + return supported; + } + } on Object catch (err, stack) { + _logger.printTrace( + 'ExtensionDeviceClient failed to check project support for "$deviceId": $err\n$stack', + ); + } + return false; + } +} + +/// A host-side [DeviceDiscovery] mechanism that discovers devices registered by active extensions. +class ExtensionDevices extends PollingDeviceDiscovery { + /// Creates an [ExtensionDevices] instance. + ExtensionDevices({required this._extensionManager, required this._logger}) + : super('tool_extension'); + + final ExtensionManager _extensionManager; + final Logger _logger; + + @override + bool get supportsPlatform => true; + + @override + bool get canListAnything => true; + + @override + List get wellKnownIds => const []; + + @override + Future> pollingGetDevices({ + Duration? timeout, + bool forWirelessDiscovery = false, + }) async { + _logger.printTrace('ExtensionDevices polling active tool extension devices...'); + await _extensionManager.ensureInitialized(); + final List deviceServices = _extensionManager.deviceExtensions; + if (deviceServices.isEmpty) { + _logger.printTrace('ExtensionDevices found 0 active device extensions.'); + return []; + } + + final List> devicesPerService = await Future.wait( + deviceServices.whereType().map((ExtensionDeviceClient service) async { + try { + final List devices = await service.getDevices(); + return devices + .map( + (TargetDevice targetDevice) => ExtensionBackedDevice( + connection: service.connection, + deviceService: service, + logger: _logger, + targetDevice: targetDevice, + ), + ) + .toList(); + } on Object catch (e, st) { + _logger.printTrace('Error querying device extension service: $e\n$st'); + return []; + } + }), + ); + + final targetDevices = [for (final deviceList in devicesPerService) ...deviceList]; + + _logger.printTrace('ExtensionDevices retrieved ${targetDevices.length} target device(s).'); + return targetDevices; + } + + @override + Future> getDiagnostics() async => []; +} + +/// A host-side [Device] wrapper representing a target device backed by a tool extension. +class ExtensionBackedDevice extends Device { + /// Creates an [ExtensionBackedDevice] wrapping a [TargetDevice]. + ExtensionBackedDevice({ + required this.connection, + required this._deviceService, + required super.logger, + required TargetDevice targetDevice, + }) : _targetDevice = targetDevice, + super( + targetDevice.id, + category: targetDevice.category, + platformType: PlatformType.custom, + ephemeral: targetDevice.ephemeral, + ); + + final DeviceService _deviceService; + final TargetDevice _targetDevice; + final ExtensionConnection connection; + + @override + String get name => _targetDevice.name; + + @override + Future isSupported() async => _targetDevice.isSupported; + + @override + Future isSupportedForProject(FlutterProject flutterProject) => + _deviceService.isSupportedForProject(deviceId: id, projectRoot: flutterProject.directory.uri); + + @override + Future get cpuArch async => CpuArch.unknown; + + @override + Future get sdkNameAndVersion async => + _targetDevice.sdkNameAndVersion ?? 'Tool Extension Device'; + + @override + Future get targetPlatformDisplayName async => _targetDevice.targetPlatform ?? 'custom'; + + @override + Future get targetPlatform async { + final String? platformName = _targetDevice.targetPlatform; + if (platformName != null) { + try { + return TargetPlatform.fromName(platformName); + } on Object { + // Fall through if unrecognized target platform name supplied. + } + } + return TargetPlatform.unsupported; + } + + @override + Future get isLocalEmulator async => false; + + @override + Future get emulatorId async => null; + + @override + DevicePortForwarder? get portForwarder => null; + + @override + DeviceLogReader getLogReader({ApplicationPackage? app, bool includePastLogs = false}) => + NoOpDeviceLogReader(name); + + @override + void clearLogs() {} + + @override + Future dispose() async {} + + @override + Future isLatestBuildInstalled(ApplicationPackage app) async => false; + + @override + Future installApp(ApplicationPackage app, {String? userIdentifier}) async => true; + + @override + Future startApp( + ApplicationPackage? package, { + String? mainPath, + String? route, + DebuggingOptions? debuggingOptions, + Map? platformArgs, + bool prebuiltApplication = false, + bool ipv6 = false, + String? userIdentifier, + }) async { + return LaunchResult.failed(); + } + + @override + Future stopApp(ApplicationPackage? app, {String? userIdentifier}) async => true; + + @override + Future uninstallApp(ApplicationPackage app, {String? userIdentifier}) async => true; + + @override + Future isAppInstalled(ApplicationPackage app, {String? userIdentifier}) async => false; +} diff --git a/packages/flutter_tools/lib/src/experimental/extension_discovery.dart b/packages/flutter_tools/lib/src/experimental/extension_discovery.dart new file mode 100644 index 00000000..2e8e48e0 --- /dev/null +++ b/packages/flutter_tools/lib/src/experimental/extension_discovery.dart @@ -0,0 +1,187 @@ +// Copyright 2014 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:isolate'; + +import 'package:flutter_tools_extension/flutter_tools_extension.dart'; +import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc; +import 'package:stream_channel/isolate_channel.dart'; + +import '../base/logger.dart'; + +/// Typedef for an extension isolate entrypoint function. +typedef ExtensionEntryPoint = void Function(SendPort sendPort); + +/// Represents an active host-side connection to a running tool extension isolate. +class ExtensionConnection { + ExtensionConnection._({ + required Isolate this._isolate, + required this._peer, + required this.capabilities, + required this._logger, + }); + + Isolate? _isolate; + final json_rpc.Peer _peer; + final Logger _logger; + + /// The capabilities and supported service namespaces of the extension. + final ToolExtensionCapabilities capabilities; + + bool _isDisposed = false; + + /// Sends an RPC request to the extension isolate. + Future sendRequest( + String method, { + Object? params, + Duration? timeout = const Duration(seconds: 5), + }) async { + if (_isDisposed) { + throw StateError('ExtensionConnection has been disposed.'); + } + _logger.printTrace('ExtensionConnection sending RPC request "$method"...'); + try { + final Future request = _peer.sendRequest(method, params); + final Object? result = await (timeout != null ? request.timeout(timeout) : request); + _logger.printTrace('ExtensionConnection received response for RPC request "$method".'); + return result; + } catch (error) { + _logger.printTrace('ExtensionConnection RPC request "$method" failed with error: $error'); + rethrow; + } + } + + /// Spawns an extension isolate from [entryPoint] and completes handshake. + static Future spawn( + ExtensionEntryPoint entryPoint, { + required Logger logger, + Duration timeout = const Duration(seconds: 5), + }) async { + logger.printTrace('ExtensionConnection spawning extension isolate...'); + final receivePort = ReceivePort(); + final errorPort = RawReceivePort(); + final exitPort = RawReceivePort(); + final errorCompleter = Completer(); + + errorPort.handler = (Object? error) { + if (!errorCompleter.isCompleted) { + if (error is List && error.isNotEmpty) { + errorCompleter.completeError(StateError('Extension isolate error: ${error[0]}')); + } else { + errorCompleter.completeError(StateError('Extension isolate error: $error')); + } + } + }; + + exitPort.handler = (Object? _) { + if (!errorCompleter.isCompleted) { + errorCompleter.completeError( + StateError('Extension isolate exited unexpectedly before handshake completed.'), + ); + } + }; + + Isolate? isolate; + + try { + isolate = await Isolate.spawn( + entryPoint, + receivePort.sendPort, + onError: errorPort.sendPort, + onExit: exitPort.sendPort, + ); + logger.printTrace('ExtensionConnection isolate spawned; connecting IsolateChannel...'); + final channel = IsolateChannel.connectReceive(receivePort); + final peer = json_rpc.Peer.withoutJson(channel); + + unawaited(peer.listen()); + + logger.printTrace('ExtensionConnection querying extension.getCapabilities...'); + final Object? responseObj = await Future.any([ + peer.sendRequest('extension.getCapabilities'), + errorCompleter.future, + ]).timeout(timeout); + if (responseObj is! Map) { + throw StateError( + 'Extension handshake failed: extension.getCapabilities did not return a Map.', + ); + } + final capabilities = ToolExtensionCapabilities.fromJson(responseObj); + logger.printTrace( + 'ExtensionConnection handshake complete. Capabilities: ${capabilities.services}', + ); + + return ExtensionConnection._( + isolate: isolate, + peer: peer, + capabilities: capabilities, + logger: logger, + ); + } on TimeoutException { + logger.printTrace('ExtensionConnection handshake timed out.'); + receivePort.close(); + isolate?.kill(priority: Isolate.immediate); + throw TimeoutException('Handshake with tool extension isolate timed out.'); + } on Object catch (error) { + logger.printTrace('ExtensionConnection spawn failed with error: $error'); + receivePort.close(); + isolate?.kill(priority: Isolate.immediate); + rethrow; + } finally { + errorPort.close(); + exitPort.close(); + } + } + + /// Disposes the extension isolate connection. + Future dispose() async { + if (_isDisposed) { + return; + } + _isDisposed = true; + _logger.printTrace('ExtensionConnection disposing isolate connection.'); + try { + await _peer.close(); + } on Object catch (error) { + _logger.printTrace('Error closing extension connection peer: $error'); + } finally { + _isolate?.kill(priority: Isolate.immediate); + _isolate = null; + } + } +} + +/// Discovers and manages active tool extension isolate connections. +class ExtensionDiscovery { + /// Creates an [ExtensionDiscovery] instance with required [_logger]. + ExtensionDiscovery({required this._logger}); + + final List _connections = []; + final Logger _logger; + + /// Active extension connections. + List get connections => List.unmodifiable(_connections); + + /// Registers an active [connection]. + void registerConnection(ExtensionConnection connection) { + _logger.printTrace('ExtensionDiscovery registering active connection.'); + _connections.add(connection); + } + + /// Registers multiple active [connections]. + void registerConnections(Iterable connections) { + _logger.printTrace('ExtensionDiscovery registering ${connections.length} connection(s).'); + _connections.addAll(connections); + } + + /// Disposes all registered extension isolate connections. + Future dispose() async { + _logger.printTrace('ExtensionDiscovery disposing all registered connections.'); + for (final ExtensionConnection connection in _connections) { + await connection.dispose(); + } + _connections.clear(); + } +} diff --git a/packages/flutter_tools/lib/src/experimental/extension_manager.dart b/packages/flutter_tools/lib/src/experimental/extension_manager.dart new file mode 100644 index 00000000..3b1c9d9b --- /dev/null +++ b/packages/flutter_tools/lib/src/experimental/extension_manager.dart @@ -0,0 +1,157 @@ +// Copyright 2014 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 'package:flutter_tools_extension/flutter_tools_extension.dart'; + +import '../base/context.dart'; +import '../base/logger.dart'; +import '../base/os.dart'; +import '../features.dart'; +import 'config.dart'; +import 'diagnostics.dart'; +import 'extension_device_manager.dart'; +import 'extension_discovery.dart'; + +/// Manages active tool extension isolate connections and exposes capability proxies. +class ExtensionManager { + /// Creates an [ExtensionManager] targeting the active [hostPlatform]. + ExtensionManager({ + required this.hostPlatform, + required Logger logger, + this._entryPoints = const [], + ExtensionDiscovery? discovery, + FeatureFlags? featureFlags, + }) : _logger = logger, + _discovery = discovery ?? ExtensionDiscovery(logger: logger), + _featureFlags = featureFlags ?? context.get()!; + + /// The active [HostPlatform]. + final HostPlatform hostPlatform; + final Logger _logger; + final ExtensionDiscovery _discovery; + final List _entryPoints; + final FeatureFlags _featureFlags; + bool get isInitialized => _isInitialized; + bool _isInitialized = false; + Future? _initFuture; + final List _diagnosticsExtensions = []; + final List _configurationExtensions = []; + final List _deviceExtensions = []; + + /// Ensures entrypoints are initialized; idempotent. + Future ensureInitialized() { + return _initFuture ??= _doInitialize(); + } + + Future _doInitialize() async { + if (!_featureFlags.isToolExtensionsEnabled) { + _isInitialized = true; + return; + } + if (_entryPoints.isNotEmpty) { + await initialize(entryPoints: _entryPoints); + } else { + _isInitialized = true; + } + } + + /// Active extension connections compatible with [hostPlatform]. + List get connections => _discovery.connections; + + /// The operating system name of the host platform (e.g. `'linux'`, `'macos'`, `'windows'`). + String get hostPlatformName => switch (hostPlatform) { + HostPlatform.darwin_x64 || HostPlatform.darwin_arm64 => 'macos', + HostPlatform.linux_x64 || HostPlatform.linux_arm64 || HostPlatform.linux_riscv64 => 'linux', + HostPlatform.windows_x64 || HostPlatform.windows_arm64 => 'windows', + }; + + /// Spawns entrypoints without host OS checks; disposes any extension that reports + /// it does not support [hostPlatform]. + Future initialize({ + List entryPoints = const [], + }) async { + _logger.printTrace( + 'ExtensionManager initializing for platform "$hostPlatformName" with ${entryPoints.length} entrypoint(s).', + ); + for (final entryPoint in entryPoints) { + final ExtensionConnection connection = await ExtensionConnection.spawn( + entryPoint, + logger: _logger, + ); + if (connection.capabilities.supportsHostPlatform(hostPlatformName) || + connection.capabilities.supportsHostPlatform(hostPlatform.cliName)) { + _logger.printTrace( + 'Extension connection supported on host platform "$hostPlatformName"; registering.', + ); + _discovery.registerConnection(connection); + } else { + _logger.printTrace( + 'Extension connection does not support host platform "$hostPlatformName" ' + '(supported platforms: ${connection.capabilities.supportedPlatforms}); disposing connection.', + ); + await connection.dispose(); + } + } + _diagnosticsExtensions.clear(); + _configurationExtensions.clear(); + _deviceExtensions.clear(); + for (final ExtensionConnection connection in _discovery.connections) { + if (connection.capabilities.services.contains(DiagnosticsExtension.serviceNamespace)) { + final client = DiagnosticsExtensionClient(connection, logger: _logger); + await client.fetchTitle(); + _diagnosticsExtensions.add(client); + } + if (connection.capabilities.services.contains(ConfigurationExtension.serviceNamespace)) { + final client = ConfigurationExtensionClient(connection, logger: _logger); + await client.fetchTitle(); + _configurationExtensions.add(client); + } + if (connection.capabilities.services.contains(DeviceService.serviceNamespace)) { + final client = ExtensionDeviceClient(connection, logger: _logger); + _deviceExtensions.add(client); + } + } + _isInitialized = true; + } + + /// Active [DiagnosticsExtension] proxies for extensions supporting `'diagnostics'`. + List get diagnosticsExtensions { + assert( + _isInitialized, + 'ExtensionManager.ensureInitialized() must be called before accessing diagnosticsExtensions.', + ); + return List.unmodifiable(_diagnosticsExtensions); + } + + /// Active [ConfigurationExtension] proxies for extensions supporting `'config'`. + List get configurationExtensions { + assert( + _isInitialized, + 'ExtensionManager.ensureInitialized() must be called before accessing configurationExtensions.', + ); + return List.unmodifiable(_configurationExtensions); + } + + /// Active [DeviceService] proxies for extensions supporting `'device'`. + List get deviceExtensions { + assert( + _isInitialized, + 'ExtensionManager.ensureInitialized() must be called before accessing deviceExtensions.', + ); + return List.unmodifiable(_deviceExtensions); + } + + /// Disposes all active extension isolate connections. + Future dispose() async { + _logger.printTrace('ExtensionManager disposing all active connections.'); + _diagnosticsExtensions.clear(); + _configurationExtensions.clear(); + _deviceExtensions.clear(); + _isInitialized = false; + _initFuture = null; + await _discovery.dispose(); + } +} diff --git a/packages/flutter_tools/lib/src/experimental/templates.dart b/packages/flutter_tools/lib/src/experimental/templates.dart new file mode 100644 index 00000000..7530e10f --- /dev/null +++ b/packages/flutter_tools/lib/src/experimental/templates.dart @@ -0,0 +1,174 @@ +// Copyright 2014 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 'package:flutter_tools_core/flutter_tools_core.dart'; +import 'package:flutter_tools_extension/flutter_tools_extension.dart'; + +import '../base/file_system.dart'; +import '../base/logger.dart'; +import '../cache.dart'; +import '../features.dart'; +import 'extension_discovery.dart'; +import 'extension_manager.dart'; + +const Duration _kRpcTimeout = Duration(seconds: 5); + +/// Manages querying project templates from extension isolates. +/// +/// This manager interacts with active [ExtensionConnection]s to discover custom +/// project templates, resolve their directories on the host, and generate +/// template parameters over the extension protocol RPC. +base class ExtensionTemplateManager extends TemplateService { + /// Creates an [ExtensionTemplateManager]. + ExtensionTemplateManager({ + required this._extensionManager, + required this._featureFlags, + required this._fileSystem, + required this._logger, + }); + + final ExtensionManager _extensionManager; + final FeatureFlags _featureFlags; + final FileSystem _fileSystem; + final Logger _logger; + + List? _cachedTemplates; + + /// Retrieve the cached templates synchronously. + /// + /// Returns the list of project templates cached from the last [getProjectTemplates] call. + List get cachedTemplates => _cachedTemplates ?? const []; + + /// Retrieve templates by routing `template.getProjectTemplates` to active tool extensions. + /// + /// Results are cached after the first successful call. + Future> getProjectTemplates() async { + if (!_featureFlags.isToolExtensionsEnabled) { + return const []; + } + if (_cachedTemplates != null) { + return _cachedTemplates!; + } + + await _extensionManager.ensureInitialized(); + + final templates = []; + final List connections = _extensionManager.connections + .where( + (ExtensionConnection c) => + c.capabilities.services.contains(TemplateService.serviceNamespace), + ) + .toList(); + + final List> results = await Future.wait( + connections.map((ExtensionConnection connection) async { + try { + final Object? rpcResult = await connection + .sendRequest(TemplateService.getProjectTemplatesMethod) + .timeout(_kRpcTimeout); + return ExtensionProjectTemplate.listFromJson(rpcResult); + } on Object catch (e) { + _logger.printTrace( + 'Failed to get results from extension for ${TemplateService.getProjectTemplatesMethod}: $e', + ); + return const []; + } + }), + ); + + results.forEach(templates.addAll); + + _cachedTemplates = templates; + return templates; + } + + /// Resolves a template package URI to a local directory. + /// + /// Currently only supports 'package:flutter_tools/' and + /// 'package:flutter_tools_extension_linux_prototype/' URIs, resolving them + /// relative to the Flutter SDK root. + Directory resolveTemplateDirectory(String templatePath) { + if (templatePath.startsWith('package:flutter_tools/')) { + final String relativePath = templatePath.substring('package:flutter_tools/'.length); + final String absolutePath = _fileSystem.path.join( + Cache.flutterRoot!, + 'packages', + 'flutter_tools', + 'lib', + relativePath, + ); + return _fileSystem.directory(absolutePath); + } + // TODO(bkonyi): Resolve package URIs generically using package config or discovered paths. + if (templatePath.startsWith('package:flutter_tools_extension_linux_prototype/')) { + final String relativePath = templatePath.substring( + 'package:flutter_tools_extension_linux_prototype/'.length, + ); + final String absolutePath = _fileSystem.path.join( + Cache.flutterRoot!, + 'packages', + 'flutter_tools', + 'packages', + 'flutter_tools_extension_linux_prototype', + 'lib', + relativePath, + ); + return _fileSystem.directory(absolutePath); + } + throw ArgumentError('Unsupported template path format: $templatePath'); + } + + /// Request template parameter generation over extension protocol RPC. + /// + /// Delegates the parameter generation for [templateName] to the active tool + /// extension, passing the host's [toolParameters]. Falls back to returning + /// [toolParameters] unchanged if the extension fails or is not available. + Future> generateTemplateParameters( + String templateName, + Map toolParameters, + ) async { + if (!_featureFlags.isToolExtensionsEnabled) { + return toolParameters; + } + + final List connections = _extensionManager.connections + .where( + (ExtensionConnection c) => + c.capabilities.services.contains(TemplateService.serviceNamespace), + ) + .toList(); + + for (final connection in connections) { + try { + final Object? result = await connection + .sendRequest( + TemplateService.generateTemplateParametersMethod, + params: { + 'templateName': templateName, + 'toolParameters': toolParameters, + }, + ) + .timeout(_kRpcTimeout); + if (result is Map) { + return result.cast(); + } + } on Object catch (e) { + _logger.printTrace('Failed to generate template parameters from extension: $e'); + } + } + + return toolParameters; + } + + @override + Set get projectTemplates => cachedTemplates.toSet(); + + @override + Set get appPlatformTemplates => const {}; + + @override + Set get pluginPlatformTemplates => const {}; +} diff --git a/packages/flutter_tools/lib/src/features.dart b/packages/flutter_tools/lib/src/features.dart index c32c9619..f07a71e4 100644 --- a/packages/flutter_tools/lib/src/features.dart +++ b/packages/flutter_tools/lib/src/features.dart @@ -73,9 +73,6 @@ abstract class FeatureFlags { /// Whether desktop windowing is enabled. bool get isWindowingEnabled; - /// Whether accessibility evaluations is enabled. - bool get isAccessibilityEvaluationsEnabled; - /// Whether physical iOS devices are debugging with LLDB. bool get isLLDBDebuggingEnabled; @@ -88,6 +85,12 @@ abstract class FeatureFlags { /// Whether to only build for arm64 when targeting macOS. bool get isMacOSArm64OnlyEnabled; + /// Whether the HCPP platform view rendering mode is enabled by default. + bool get isHcppEnabled; + + /// Whether support for tool extensions is enabled. + bool get isToolExtensionsEnabled; + /// Whether a particular feature is enabled for the current channel. /// /// Prefer using one of the specific getters above instead of this API. @@ -110,11 +113,12 @@ abstract class FeatureFlags { swiftPackageManager, omitLegacyVersionFile, windowingFeature, - accessibilityEvaluationsFeature, lldbDebugging, uiSceneMigration, riscv64, macOSArm64Only, + hcpp, + toolExtensionsFeature, ]; /// All current Flutter feature flags that can be configured. @@ -229,11 +233,16 @@ const recordUse = Feature( stable: FeatureChannelSetting(available: true, enabledByDefault: true), ); +/// Warning printed when Swift Package Manager is disabled in configuration. +const kSwiftPackageManagerDisabledWarning = + 'Enabling Swift Package Manager will be required in a future version of Flutter.'; + /// Enable Swift Package Manager as a darwin dependency manager. const swiftPackageManager = Feature( name: 'support for Swift Package Manager for iOS and macOS', configSetting: 'enable-swift-package-manager', environmentOverride: 'FLUTTER_SWIFT_PACKAGE_MANAGER', + warningMessageOnDisable: kSwiftPackageManagerDisabledWarning, master: FeatureChannelSetting(available: true, enabledByDefault: true), beta: FeatureChannelSetting(available: true, enabledByDefault: true), stable: FeatureChannelSetting(available: true, enabledByDefault: true), @@ -262,15 +271,6 @@ const windowingFeature = Feature( master: FeatureChannelSetting(available: true), ); -/// Whether accessibility evaluations is enabled. -const accessibilityEvaluationsFeature = Feature( - name: 'support for accessibility evaluations', - configSetting: 'enable-accessibility-evaluations', - environmentOverride: 'FLUTTER_ACCESSIBILITY_EVALUATIONS', - runtimeId: 'accessibility_evaluations', - master: FeatureChannelSetting(available: true), -); - /// Enable LLDB debugging for physical iOS devices. When LLDB debugging is off, /// Xcode debugging is used instead. /// @@ -327,6 +327,33 @@ const macOSArm64Only = Feature( stable: FeatureChannelSetting(available: true), ); +/// Whether the HCPP (Hybrid Composition++) platform view rendering mode is used +/// by default on Android. +/// +/// This is the default only: it is baked into the Android manifest of the +/// artifact the tool builds, and an explicit +/// `io.flutter.embedding.android.EnableHcpp` entry in the app's manifest, or an +/// explicit `--[no-]enable-hcpp`, takes priority over it. +const hcpp = Feature( + name: 'the HCPP platform view rendering mode', + extraHelpText: + 'HCPP requires the Impeller rendering backend, and Android API 34 or above. ' + "Devices that do not support it fall back to each platform view's developer-chosen mode.", + configSetting: 'enable-hcpp', + environmentOverride: 'FLUTTER_ENABLE_HCPP', + master: FeatureChannelSetting(available: true, enabledByDefault: true), + beta: FeatureChannelSetting(available: true, enabledByDefault: true), + stable: FeatureChannelSetting(available: true), +); + +/// Enable tool extensions feature. +const toolExtensionsFeature = Feature( + name: 'support for tool extensions', + configSetting: 'enable-tool-extensions', + environmentOverride: 'FLUTTER_TOOL_EXTENSIONS', + master: FeatureChannelSetting(available: true), +); + /// A [Feature] is a process for conditionally enabling tool features. /// /// All settings are optional, and if not provided will generally default to @@ -343,6 +370,7 @@ class Feature { this.configSetting, this.runtimeId, this.extraHelpText, + this.warningMessageOnDisable, this.master = const FeatureChannelSetting(), this.beta = const FeatureChannelSetting(), this.stable = const FeatureChannelSetting(), @@ -355,6 +383,7 @@ class Feature { this.configSetting, this.runtimeId, this.extraHelpText, + this.warningMessageOnDisable, }) : master = const FeatureChannelSetting(available: true, enabledByDefault: true), beta = const FeatureChannelSetting(available: true, enabledByDefault: true), stable = const FeatureChannelSetting(available: true, enabledByDefault: true); @@ -396,6 +425,11 @@ class Feature { /// If not provided, defaults to `null` meaning there is no additional text. final String? extraHelpText; + /// A warning to print when this feature is explicitly disabled. + /// + /// If not provided, defaults to `null` meaning there is no warning. + final String? warningMessageOnDisable; + /// A help message for the `flutter config` command, or null if unsupported. String? generateHelpMessage() { if (configSetting == null) { diff --git a/packages/flutter_tools/lib/src/flutter_application_package.dart b/packages/flutter_tools/lib/src/flutter_application_package.dart index c61de499..9e540367 100644 --- a/packages/flutter_tools/lib/src/flutter_application_package.dart +++ b/packages/flutter_tools/lib/src/flutter_application_package.dart @@ -12,7 +12,6 @@ import 'base/logger.dart'; import 'base/process.dart'; import 'base/user_messages.dart'; import 'build_info.dart'; -import 'globals.dart' as globals; import 'ios/application_package.dart'; import 'linux/application_package.dart'; import 'macos/application_package.dart'; @@ -24,16 +23,13 @@ import 'windows/application_package.dart'; /// A package factory that supports all Flutter target platforms. class FlutterApplicationPackageFactory extends ApplicationPackageFactory { FlutterApplicationPackageFactory({ - required AndroidSdk? androidSdk, + required this._androidSdk, required ProcessManager processManager, required Logger logger, - required UserMessages userMessages, - required FileSystem fileSystem, - }) : _androidSdk = androidSdk, - _processManager = processManager, + required this._userMessages, + required this._fileSystem, + }) : _processManager = processManager, _logger = logger, - _userMessages = userMessages, - _fileSystem = fileSystem, _processUtils = ProcessUtils(logger: logger, processManager: processManager); final AndroidSdk? _androidSdk; @@ -49,11 +45,8 @@ class FlutterApplicationPackageFactory extends ApplicationPackageFactory { BuildInfo? buildInfo, File? applicationBinary, }) async { - switch (platform) { - case TargetPlatform.android: - case TargetPlatform.android_arm: - case TargetPlatform.android_arm64: - case TargetPlatform.android_x64: + switch (platform.os) { + case .android: if (applicationBinary == null) { return AndroidApk.fromAndroidProject( FlutterProject.current().android, @@ -74,35 +67,30 @@ class FlutterApplicationPackageFactory extends ApplicationPackageFactory { userMessages: _userMessages, processUtils: _processUtils, ); - case TargetPlatform.ios: + case .ios: return applicationBinary == null ? await IOSApp.fromIosProject(FlutterProject.current().ios, buildInfo) : IOSApp.fromPrebuiltApp(applicationBinary); - case TargetPlatform.tester: - return FlutterTesterApp.fromCurrentDirectory(globals.fs); - case TargetPlatform.darwin: + case .tester: + return FlutterTesterApp.fromCurrentDirectory(_fileSystem); + case .macos: return applicationBinary == null ? MacOSApp.fromMacOSProject(FlutterProject.current().macos) : MacOSApp.fromPrebuiltApp(applicationBinary); - case TargetPlatform.web_javascript: + case .web: if (!FlutterProject.current().web.existsSync()) { return null; } return WebApplicationPackage(FlutterProject.current()); - case TargetPlatform.linux_x64: - case TargetPlatform.linux_arm64: - case TargetPlatform.linux_riscv64: + case .linux: return applicationBinary == null ? LinuxApp.fromLinuxProject(FlutterProject.current().linux) : LinuxApp.fromPrebuiltApp(applicationBinary); - case TargetPlatform.windows_x64: - case TargetPlatform.windows_arm64: + case .windows: return applicationBinary == null ? WindowsApp.fromWindowsProject(FlutterProject.current().windows) : WindowsApp.fromPrebuiltApp(applicationBinary); - case TargetPlatform.fuchsia_arm64: - case TargetPlatform.fuchsia_x64: - case TargetPlatform.unsupported: + case .fuchsia || .unsupported: TargetPlatform.throwUnsupportedTarget(); } } diff --git a/packages/flutter_tools/lib/src/flutter_cache.dart b/packages/flutter_tools/lib/src/flutter_cache.dart index 37ce9917..1dffef30 100644 --- a/packages/flutter_tools/lib/src/flutter_cache.dart +++ b/packages/flutter_tools/lib/src/flutter_cache.dart @@ -31,6 +31,7 @@ class FlutterCache extends Cache { required FlutterProjectFactory projectFactory, super.stdio, }) : super(logger: logger, platform: platform, artifacts: []) { + registerArtifact(MaterialFonts(this)); registerArtifact(GradleWrapper(this)); registerArtifact(AndroidGenSnapshotArtifacts(this, platform: platform)); registerArtifact(AndroidInternalBuildArtifacts(this)); @@ -71,15 +72,11 @@ class FlutterCache extends Cache { class PubDependencies extends ArtifactSet { PubDependencies({ // Needs to be lazy to avoid reading from the cache before the root is initialized. - required String Function() flutterRoot, - required Logger logger, - required Pub Function() pub, - required FlutterProjectFactory projectFactory, - }) : _logger = logger, - _flutterRoot = flutterRoot, - _pub = pub, - _projectFactory = projectFactory, - super(DevelopmentArtifact.universal); + required this._flutterRoot, + required this._logger, + required this._pub, + required this._projectFactory, + }) : super(DevelopmentArtifact.universal); final String Function() _flutterRoot; final Logger _logger; @@ -250,9 +247,8 @@ class LegacyCanvasKitRemover extends ArtifactSet { /// A cached artifact containing the dart:ui source code. class FlutterSdk extends EngineCachedArtifact { - FlutterSdk(Cache cache, {required Platform platform}) - : _platform = platform, - super('flutter_sdk', cache, DevelopmentArtifact.universal); + FlutterSdk(Cache cache, {required this._platform}) + : super('flutter_sdk', cache, DevelopmentArtifact.universal); final Platform _platform; @@ -260,7 +256,7 @@ class FlutterSdk extends EngineCachedArtifact { String get displayName => 'Flutter SDK'; @override - List getPackageDirs() => const ['sky_engine']; + List getPackageDirs() => const ['sky_engine', 'flutter_gpu']; @override List> getBinaryDirs() { @@ -287,9 +283,8 @@ class FlutterSdk extends EngineCachedArtifact { } class MacOSEngineArtifacts extends EngineCachedArtifact { - MacOSEngineArtifacts(Cache cache, {required Platform platform}) - : _platform = platform, - super('macos-sdk', cache, DevelopmentArtifact.macOS); + MacOSEngineArtifacts(Cache cache, {required this._platform}) + : super('macos-sdk', cache, DevelopmentArtifact.macOS); final Platform _platform; @@ -313,9 +308,8 @@ class MacOSEngineArtifacts extends EngineCachedArtifact { /// Artifacts required for desktop Windows builds. class WindowsEngineArtifacts extends EngineCachedArtifact { - WindowsEngineArtifacts(Cache cache, {required Platform platform}) - : _platform = platform, - super('windows-sdk', cache, DevelopmentArtifact.windows); + WindowsEngineArtifacts(Cache cache, {required this._platform}) + : super('windows-sdk', cache, DevelopmentArtifact.windows); final Platform _platform; @@ -340,9 +334,8 @@ class WindowsEngineArtifacts extends EngineCachedArtifact { /// Artifacts required for desktop Linux builds. class LinuxEngineArtifacts extends EngineCachedArtifact { - LinuxEngineArtifacts(Cache cache, {required Platform platform}) - : _platform = platform, - super('linux-sdk', cache, DevelopmentArtifact.linux); + LinuxEngineArtifacts(Cache cache, {required this._platform}) + : super('linux-sdk', cache, DevelopmentArtifact.linux); final Platform _platform; @@ -371,9 +364,8 @@ class LinuxEngineArtifacts extends EngineCachedArtifact { /// The artifact used to generate snapshots for Android builds. class AndroidGenSnapshotArtifacts extends EngineCachedArtifact { - AndroidGenSnapshotArtifacts(Cache cache, {required Platform platform}) - : _platform = platform, - super('android-sdk', cache, DevelopmentArtifact.androidGenSnapshot); + AndroidGenSnapshotArtifacts(Cache cache, {required this._platform}) + : super('android-sdk', cache, DevelopmentArtifact.androidGenSnapshot); final Platform _platform; @@ -412,10 +404,8 @@ class AndroidGenSnapshotArtifacts extends EngineCachedArtifact { /// /// Set [Java] to `null` to indicate that no Java/JDK installation could be found. class AndroidMavenArtifacts extends ArtifactSet { - AndroidMavenArtifacts(this.cache, {required Java? java, required Platform platform}) - : _java = java, - _platform = platform, - super(DevelopmentArtifact.androidMaven); + AndroidMavenArtifacts(this.cache, {required this._java, required this._platform}) + : super(DevelopmentArtifact.androidMaven); final Java? _java; final Platform _platform; @@ -502,9 +492,8 @@ class AndroidInternalBuildArtifacts extends EngineCachedArtifact { } class IOSEngineArtifacts extends EngineCachedArtifact { - IOSEngineArtifacts(Cache cache, {required Platform platform}) - : _platform = platform, - super('ios-sdk', cache, DevelopmentArtifact.iOS); + IOSEngineArtifacts(Cache cache, {required this._platform}) + : super('ios-sdk', cache, DevelopmentArtifact.iOS); final Platform _platform; @@ -542,7 +531,7 @@ class GradleWrapper extends CachedArtifact { List get _gradleScripts => ['gradlew', 'gradlew.bat']; - Uri _toStorageUri(String path) => Uri.parse('${cache.originalStorageUrl}/$path'); + Uri _toStorageUri(String path) => Uri.parse('${cache.storageBaseUrl}/$path'); @override Future updateInner( @@ -609,9 +598,8 @@ abstract class _FuchsiaSDKArtifacts extends CachedArtifact { /// The pre-built flutter runner for Fuchsia development. class FlutterRunnerSDKArtifacts extends CachedArtifact { - FlutterRunnerSDKArtifacts(Cache cache, {required Platform platform}) - : _platform = platform, - super('flutter_runner', cache, DevelopmentArtifact.flutterRunner); + FlutterRunnerSDKArtifacts(Cache cache, {required this._platform}) + : super('flutter_runner', cache, DevelopmentArtifact.flutterRunner); final Platform _platform; @@ -670,10 +658,9 @@ class CipdArchiveResolver extends VersionedPackageResolver { class FlutterRunnerDebugSymbols extends CachedArtifact { FlutterRunnerDebugSymbols( Cache cache, { - required Platform platform, + required this._platform, VersionedPackageResolver? packageResolver, - }) : _platform = platform, - packageResolver = packageResolver ?? CipdArchiveResolver(cache), + }) : packageResolver = packageResolver ?? CipdArchiveResolver(cache), super('flutter_runner_debug_symbols', cache, DevelopmentArtifact.flutterRunner); final VersionedPackageResolver packageResolver; @@ -717,9 +704,7 @@ class FlutterRunnerDebugSymbols extends CachedArtifact { /// The Fuchsia core SDK for Linux. class LinuxFuchsiaSDKArtifacts extends _FuchsiaSDKArtifacts { - LinuxFuchsiaSDKArtifacts(Cache cache, {required Platform platform}) - : _platform = platform, - super(cache, 'linux'); + LinuxFuchsiaSDKArtifacts(Cache cache, {required this._platform}) : super(cache, 'linux'); final Platform _platform; @@ -738,9 +723,7 @@ class LinuxFuchsiaSDKArtifacts extends _FuchsiaSDKArtifacts { /// The Fuchsia core SDK for MacOS. class MacOSFuchsiaSDKArtifacts extends _FuchsiaSDKArtifacts { - MacOSFuchsiaSDKArtifacts(Cache cache, {required Platform platform}) - : _platform = platform, - super(cache, 'mac'); + MacOSFuchsiaSDKArtifacts(Cache cache, {required this._platform}) : super(cache, 'mac'); final Platform _platform; @@ -759,9 +742,8 @@ class MacOSFuchsiaSDKArtifacts extends _FuchsiaSDKArtifacts { /// Cached artifacts for font subsetting. class FontSubsetArtifacts extends EngineCachedArtifact { - FontSubsetArtifacts(Cache cache, {required Platform platform}) - : _platform = platform, - super(artifactName, cache, DevelopmentArtifact.universal); + FontSubsetArtifacts(Cache cache, {required this._platform}) + : super(artifactName, cache, DevelopmentArtifact.universal); final Platform _platform; @@ -799,9 +781,8 @@ class FontSubsetArtifacts extends EngineCachedArtifact { /// Cached iOS/USB binary artifacts. class IosUsbArtifacts extends CachedArtifact { - IosUsbArtifacts(String name, Cache cache, {required Platform platform}) - : _platform = platform, - super(name, cache, DevelopmentArtifact.universal); + IosUsbArtifacts(String name, Cache cache, {required this._platform}) + : super(name, cache, DevelopmentArtifact.universal); final Platform _platform; @@ -862,7 +843,7 @@ class IosUsbArtifacts extends CachedArtifact { @visibleForTesting Uri get archiveUri => Uri.parse( - '${cache.originalStorageUrl}/flutter_infra_release/' + '${cache.realmlessStorageBaseUrl}/flutter_infra_release/' 'ios-usb-dependencies/arm64_x86_64${cache.useUnsignedMacBinaries ? '/unsigned' : ''}' '/$name/$version/$name.zip', ); @@ -926,7 +907,6 @@ const _iosBinaryDirs = >[ ]; const _androidBinaryDirs = >[ - ['android-x86', 'android-x86/artifacts.zip'], ['android-x64', 'android-x64/artifacts.zip'], ['android-arm', 'android-arm/artifacts.zip'], ['android-arm-profile', 'android-arm-profile/artifacts.zip'], diff --git a/packages/flutter_tools/lib/src/flutter_device_manager.dart b/packages/flutter_tools/lib/src/flutter_device_manager.dart index 3e36d4d9..6b742cb5 100644 --- a/packages/flutter_tools/lib/src/flutter_device_manager.dart +++ b/packages/flutter_tools/lib/src/flutter_device_manager.dart @@ -15,6 +15,8 @@ import 'base/user_messages.dart'; import 'custom_devices/custom_device.dart'; import 'custom_devices/custom_devices_config.dart'; import 'device.dart'; +import 'experimental/extension_device_manager.dart'; +import 'experimental/extension_manager.dart'; import 'features.dart'; import 'ios/devices.dart'; import 'ios/ios_workflow.dart'; @@ -38,7 +40,7 @@ class FlutterDeviceManager extends DeviceManager { required Platform platform, required ProcessManager processManager, required FileSystem fileSystem, - required AndroidSdk? androidSdk, + required AndroidSdk? Function() androidSdkBuilder, required FeatureFlags featureFlags, required IOSSimulatorUtils iosSimulatorUtils, required XCDevice xcDevice, @@ -52,10 +54,11 @@ class FlutterDeviceManager extends DeviceManager { required WindowsWorkflow windowsWorkflow, required CustomDevicesConfig customDevicesConfig, required TestCompilerNativeAssetsBuilder? nativeAssetsBuilder, + ExtensionManager? extensionManager, }) : deviceDiscoverers = [ AndroidDevices( logger: logger, - androidSdk: androidSdk, + androidSdkBuilder: androidSdkBuilder, androidWorkflow: androidWorkflow, processManager: processManager, fileSystem: fileSystem, @@ -75,6 +78,7 @@ class FlutterDeviceManager extends DeviceManager { processManager: processManager, logger: logger, artifacts: artifacts, + platform: platform, nativeAssetsBuilder: nativeAssetsBuilder, ), MacOSDevices( @@ -121,6 +125,8 @@ class FlutterDeviceManager extends DeviceManager { logger: logger, config: customDevicesConfig, ), + if (extensionManager != null) + ExtensionDevices(extensionManager: extensionManager, logger: logger), ]; @override diff --git a/packages/flutter_tools/lib/src/flutter_features.dart b/packages/flutter_tools/lib/src/flutter_features.dart index 315e648d..81768c5f 100644 --- a/packages/flutter_tools/lib/src/flutter_features.dart +++ b/packages/flutter_tools/lib/src/flutter_features.dart @@ -64,9 +64,6 @@ mixin FlutterFeatureFlagsIsEnabled implements FeatureFlags { @override bool get isWindowingEnabled => isEnabled(windowingFeature); - @override - bool get isAccessibilityEvaluationsEnabled => isEnabled(accessibilityEvaluationsFeature); - @override bool get isLLDBDebuggingEnabled => isEnabled(lldbDebugging); @@ -78,15 +75,20 @@ mixin FlutterFeatureFlagsIsEnabled implements FeatureFlags { @override bool get isMacOSArm64OnlyEnabled => isEnabled(macOSArm64Only); + + @override + bool get isHcppEnabled => isEnabled(hcpp); + + @override + bool get isToolExtensionsEnabled => isEnabled(toolExtensionsFeature); } interface class FlutterFeatureFlags extends FeatureFlags with FlutterFeatureFlagsIsEnabled { FlutterFeatureFlags({ - required FlutterVersion flutterVersion, - required FlutterFeaturesConfig featuresConfig, + required this._flutterVersion, + required this._featuresConfig, required this.platform, - }) : _flutterVersion = flutterVersion, - _featuresConfig = featuresConfig; + }); final FlutterVersion _flutterVersion; final FlutterFeaturesConfig _featuresConfig; diff --git a/packages/flutter_tools/lib/src/flutter_features_config.dart b/packages/flutter_tools/lib/src/flutter_features_config.dart index b0883243..c2a8ee84 100644 --- a/packages/flutter_tools/lib/src/flutter_features_config.dart +++ b/packages/flutter_tools/lib/src/flutter_features_config.dart @@ -14,17 +14,15 @@ import 'flutter_manifest.dart'; interface class FlutterFeaturesConfig { /// Creates a feature configuration reader from the provided sources. /// - /// [globalConfig] reads values stored by the `flutter config` tool, which + /// [_globalConfig] reads values stored by the `flutter config` tool, which /// are normally in the user's `%HOME` directory (varies by system), while - /// [projectManifest] reads values from the _current_ Flutter project's + /// [_projectManifest] reads values from the _current_ Flutter project's /// `pubspec.yaml` const FlutterFeaturesConfig({ - required Config globalConfig, - required Platform platform, - required FlutterManifest? projectManifest, - }) : _globalConfig = globalConfig, - _platform = platform, - _projectManifest = projectManifest; + required this._globalConfig, + required this._platform, + required this._projectManifest, + }); final Config _globalConfig; final Platform _platform; diff --git a/packages/flutter_tools/lib/src/flutter_manifest.dart b/packages/flutter_tools/lib/src/flutter_manifest.dart index 7c010e71..57e4d347 100644 --- a/packages/flutter_tools/lib/src/flutter_manifest.dart +++ b/packages/flutter_tools/lib/src/flutter_manifest.dart @@ -30,7 +30,7 @@ class PluginPlatformConfig { /// A wrapper around the `flutter` section in the `pubspec.yaml` file. class FlutterManifest { - FlutterManifest._({required Logger logger}) : _logger = logger; + FlutterManifest._({required this._logger}); /// Returns an empty manifest. factory FlutterManifest.empty({required Logger logger}) = FlutterManifest._; @@ -128,6 +128,9 @@ class FlutterManifest { final Logger _logger; + /// The logger associated with this manifest. + Logger get logger => _logger; + /// A map representation of the entire `pubspec.yaml` file. var _descriptor = {}; diff --git a/packages/flutter_tools/lib/src/flutter_plugins.dart b/packages/flutter_tools/lib/src/flutter_plugins.dart index 3af0fb08..1d6c5ffc 100644 --- a/packages/flutter_tools/lib/src/flutter_plugins.dart +++ b/packages/flutter_tools/lib/src/flutter_plugins.dart @@ -13,6 +13,7 @@ import 'android/gradle.dart'; import 'base/common.dart'; import 'base/error_handling_io.dart'; import 'base/file_system.dart'; +import 'base/logger.dart'; import 'base/os.dart'; import 'base/platform.dart'; import 'base/template.dart'; @@ -104,6 +105,7 @@ Future _pluginFromPackage( Uri packageRoot, Set appDependencies, { required bool isDevDependency, + required Logger logger, FileSystem? fileSystem, PubspecCache? pubspecCache, }) async { @@ -123,10 +125,10 @@ Future _pluginFromPackage( final Object? parsed = loadYaml(await pubspecFile.readAsString()); pubspec = parsed is YamlMap ? parsed : null; } on YamlException catch (err) { - globals.printTrace('Failed to parse plugin manifest for $name: $err'); + logger.printTrace('Failed to parse plugin manifest for $name: $err'); // Do nothing, potentially not a plugin. } on FileSystemException catch (err) { - globals.printTrace('Failed to read plugin manifest for $name: $err'); + logger.printTrace('Failed to read plugin manifest for $name: $err'); // Do nothing, potentially not a plugin. } } @@ -143,7 +145,7 @@ Future _pluginFromPackage( : semver.VersionConstraint.parse(flutterConstraintText); final String packageRootPath = fs.path.fromUri(packageRoot); final dependencies = pubspec['dependencies'] as YamlMap?; - globals.printTrace('Found plugin $name at $packageRootPath'); + logger.printTrace('Found plugin $name at $packageRootPath'); return Plugin.fromYaml( name, packageRootPath, @@ -161,10 +163,11 @@ Future _pluginFromPackage( /// If [throwOnError] is `true`, an empty package configuration is an error. Future> findPlugins( FlutterProject project, { - bool throwOnError = true, - PubspecCache? pubspecCache, - PackageGraph? packageGraph, + required Logger logger, PackageConfig? packageConfig, + PackageGraph? packageGraph, + PubspecCache? pubspecCache, + bool throwOnError = true, }) async { final plugins = []; final FileSystem fs = project.directory.fileSystem; @@ -183,7 +186,7 @@ Future> findPlugins( final File packageConfigFile = findPackageConfigFileOrDefault(project.directory); resolvedPackageConfig = await loadPackageConfigWithLogging( packageConfigFile, - logger: globals.logger, + logger: logger, throwOnError: throwOnError, ); } @@ -199,7 +202,7 @@ Future> findPlugins( if (throwOnError) { throwToolExit('Could not locate package:$packageName. Try running `flutter pub get`'); } else { - globals.logger.printTrace('Could not locate package:$packageName'); + logger.printTrace('Could not locate package:$packageName'); continue; } } @@ -208,6 +211,7 @@ Future> findPlugins( dependency.rootUri, project.manifest.dependencies, isDevDependency: dependency.isExclusiveDevDependency, + logger: logger, fileSystem: fs, pubspecCache: pubspecCache, ); @@ -215,7 +219,77 @@ Future> findPlugins( plugins.add(plugin); } } - return plugins; + return sortByDependencies(plugins); +} + +/// Sorts [plugins] according to their dependency graph so that dependencies +/// are listed before dependent plugins. +/// +/// If multiple plugins have no dependency relationship with each other, they are +/// sorted alphabetically by name to keep ordering stable and deterministic. +/// If dependency cycles exist, cycles are broken predictably. +List sortByDependencies(Iterable plugins) { + final pluginMap = {for (final Plugin p in plugins) p.name: p}; + if (pluginMap.length <= 1) { + return plugins.toList(); + } + + final inDegree = {}; + final dependents = >{ + for (final String name in pluginMap.keys) name: [], + }; + + for (final Plugin p in pluginMap.values) { + final Set directDeps = p.dependencies + .where((String dep) => dep != p.name && pluginMap.containsKey(dep)) + .toSet(); + inDegree[p.name] = directDeps.length; + for (final dep in directDeps) { + dependents[dep]!.add(p.name); + } + } + + final List available = + inDegree.entries + .where((MapEntry entry) => entry.value == 0) + .map((MapEntry entry) => entry.key) + .toList() + ..sort(); + + final sortedPlugins = []; + final placed = {}; + + while (placed.length < pluginMap.length) { + if (available.isEmpty) { + // Cycle detected among remaining unplaced plugins. + // Select the unplaced plugin with smallest in-degree, breaking ties alphabetically. + final List unplaced = pluginMap.keys + .where((String k) => !placed.contains(k)) + .toList(); + unplaced.sort((String a, String b) { + final int degComp = inDegree[a]!.compareTo(inDegree[b]!); + return degComp != 0 ? degComp : a.compareTo(b); + }); + available.add(unplaced.first); + } + final String current = available.removeAt(0); + if (placed.add(current)) { + sortedPlugins.add(pluginMap[current]!); + final newlyAvailable = []; + for (final String dependent in dependents[current]!) { + inDegree[dependent] = inDegree[dependent]! - 1; + if (inDegree[dependent] == 0 && !placed.contains(dependent)) { + newlyAvailable.add(dependent); + } + } + if (newlyAvailable.isNotEmpty) { + available.addAll(newlyAvailable); + available.sort(); + } + } + } + + return sortedPlugins; } /// Plugin resolution type to determine the injection mechanism. @@ -401,27 +475,6 @@ bool _writeFlutterPluginsList( } } -/// Checks if the .flutter-plugins-dependencies file has any plugin -/// dev dependencies with platform-specific implementations. -bool flutterPluginsListHasDevDependencies(File pluginsFile) { - final String pluginsString = pluginsFile.readAsStringSync(); - final pluginsJson = json.decode(pluginsString) as Map; - final plugins = pluginsJson[_kFlutterPluginsPluginListKey] as Map; - - for (final MapEntry pluginEntries in plugins.entries) { - final platformPlugins = pluginEntries.value as List; - final bool hasDevDependencies = platformPlugins.cast>().any( - (Map plugin) => plugin[_kFlutterPluginsDevDependencyKey] == true, - ); - - if (hasDevDependencies) { - return true; - } - } - - return false; -} - /// Creates a map representation of the [plugins] for those supported by [platformKey]. /// All given [plugins] must provide an implementation for the [platformKey]. List> _createPluginMapOfPlatform(List plugins, String platformKey) { @@ -1269,8 +1322,26 @@ void _createPlatformPluginSymlinks( final name = pluginInfo[_kFlutterPluginsNameKey]! as String; final path = pluginInfo[_kFlutterPluginsPathKey]! as String; final Link link = symlinkDirectory.childLink(name); - if (link.existsSync()) { - continue; + // Inspect the entity on disk without following links. Link.existsSync() + // only returns true if the entity is specifically a link; if a conflicting + // non-link file or directory occupies link.path, or if an existing link + // points to an outdated target, it must be cleaned up before creating the + // new link to avoid FileSystemException collisions (such as + // ERROR_ALREADY_EXISTS on Windows or EEXIST on POSIX). + final FileSystemEntityType entityType = link.fileSystem.typeSync(link.path, followLinks: false); + if (entityType == FileSystemEntityType.link) { + try { + final String target = link.targetSync(); + if (link.fileSystem.path.canonicalize(target) == link.fileSystem.path.canonicalize(path) && + link.existsSync()) { + continue; + } + } on FileSystemException { + // Fall through to delete and recreate if resolving target throws. + } + ErrorHandlingFileSystem.deleteIfExists(link); + } else if (entityType != FileSystemEntityType.notFound) { + ErrorHandlingFileSystem.deleteIfExists(link, recursive: true); } try { link.createSync(path); @@ -1299,22 +1370,25 @@ Future refreshPluginsList( bool iosPlatform = false, bool macOSPlatform = false, bool forceCocoaPodsOnly = false, + bool forceSwiftPM = false, PubspecCache? pubspecCache, PackageGraph? packageGraph, PackageConfig? packageConfig, }) async { final List plugins = await findPlugins( project, + logger: globals.logger, pubspecCache: pubspecCache, packageGraph: packageGraph, packageConfig: packageConfig, ); - // Sort the plugins by name to keep ordering stable in generated files. - plugins.sort((Plugin left, Plugin right) => left.name.compareTo(right.name)); var swiftPackageManagerEnabledIos = false; var swiftPackageManagerEnabledMacos = false; - if (!forceCocoaPodsOnly) { + if (forceSwiftPM) { + swiftPackageManagerEnabledIos = true; + swiftPackageManagerEnabledMacos = true; + } else if (!forceCocoaPodsOnly) { if (iosPlatform) { swiftPackageManagerEnabledIos = project.ios.usesSwiftPackageManager; } @@ -1360,7 +1434,7 @@ Future injectBuildTimePluginFilesForWebPlatform( FlutterProject project, { required Directory destination, }) async { - final List plugins = await findPlugins(project); + final List plugins = await findPlugins(project, logger: globals.logger); final Map> pluginsByPlatform = _resolvePluginImplementations( plugins, pluginResolutionType: _PluginResolutionType.nativeOrDart, @@ -1399,6 +1473,7 @@ Future injectPlugins( }) async { final List plugins = await findPlugins( project, + logger: globals.logger, pubspecCache: pubspecCache, packageGraph: packageGraph, packageConfig: packageConfig, @@ -1705,9 +1780,8 @@ _resolvePluginImplementationsByPlatform( } } - // Sort the plugins by name to keep ordering stable in generated files. - final List pluginImplementations = pluginResolution.values.toList() - ..sort((Plugin left, Plugin right) => left.name.compareTo(right.name)); + // Sort the plugins by dependency order to ensure dependencies are initialized first. + final List pluginImplementations = sortByDependencies(pluginResolution.values); return (pluginImplementations, hasPluginPubspecError, hasResolutionError); } @@ -1937,7 +2011,7 @@ Future generateMainDartWithPluginRegistrant( PackageConfig packageConfig, File mainFile, ) async { - final List plugins = await findPlugins(rootProject); + final List plugins = await findPlugins(rootProject, logger: globals.logger); final List resolutions = resolvePlatformImplementation( plugins, selectDartPluginsOnly: true, diff --git a/packages/flutter_tools/lib/src/flutter_project_metadata.dart b/packages/flutter_tools/lib/src/flutter_project_metadata.dart index f207d8e8..a91b6bcf 100644 --- a/packages/flutter_tools/lib/src/flutter_project_metadata.dart +++ b/packages/flutter_tools/lib/src/flutter_project_metadata.dart @@ -2,12 +2,14 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter_tools_core/flutter_tools_core.dart'; import 'package:meta/meta.dart'; import 'package:yaml/yaml.dart'; import 'base/file_system.dart'; import 'base/logger.dart'; import 'base/utils.dart'; +import 'experimental/templates.dart'; import 'features.dart'; import 'project.dart'; import 'template.dart'; @@ -15,7 +17,12 @@ import 'version.dart'; /// The result of parsing `--template=` for `flutter create` and related commands. @immutable -sealed class ParsedFlutterTemplateType implements CliEnum { +sealed class ParsedFlutterTemplateType { + const ParsedFlutterTemplateType(); + + String get cliName; + String get helpText; + static const _values = [ ...FlutterTemplateType.values, ...RemovedFlutterTemplateType.values, @@ -23,19 +30,45 @@ sealed class ParsedFlutterTemplateType implements CliEnum { /// Parses and returns a [ParsedFlutterTemplateType], if any, for [cliName]. /// - /// If no match was found `null` is returned. - static ParsedFlutterTemplateType? fromCliName(String cliName) { + /// If no match was found in standard templates, it queries the + /// [ExtensionTemplateManager] to check if it matches a custom template. + /// If no match is found, `null` is returned. + static ParsedFlutterTemplateType? fromCliName( + String cliName, { + required ExtensionTemplateManager? extensionTemplateManager, + }) { for (final ParsedFlutterTemplateType type in _values) { if (cliName == type.cliName) { return type; } } + final manager = extensionTemplateManager; + if (manager != null) { + for (final ProjectTemplate template in manager.cachedTemplates) { + if (template.name == cliName) { + return ExtensionProjectTemplateType(cliName: cliName); + } + } + } return null; } /// Returns template types that are enabled based on the current [featureFlags]. - static List enabledValues(FeatureFlags featureFlags) { - return _values.toList()..retainWhere((ParsedFlutterTemplateType templateType) { + /// + /// Includes custom templates from [ExtensionTemplateManager] if the manager + /// is available and has cached templates. + static List enabledValues( + FeatureFlags featureFlags, { + required ExtensionTemplateManager? extensionTemplateManager, + }) { + final List values = _values.toList(); + final manager = extensionTemplateManager; + if (manager != null) { + for (final ProjectTemplate template in manager.cachedTemplates) { + values.add(ExtensionProjectTemplateType(cliName: template.name)); + } + } + return values..retainWhere((ParsedFlutterTemplateType templateType) { return templateType.isEnabled(featureFlags); }); } @@ -44,6 +77,32 @@ sealed class ParsedFlutterTemplateType implements CliEnum { bool isEnabled(FeatureFlags featureFlags) => true; } +/// A [ParsedFlutterTemplateType] representing a template provided dynamically +/// by a tool extension. +@immutable +class ExtensionProjectTemplateType extends ParsedFlutterTemplateType { + const ExtensionProjectTemplateType({required this.cliName}); + + @override + final String cliName; + + @override + String get helpText => 'Dynamically loaded template from extension.'; + + @override + bool isEnabled(FeatureFlags featureFlags) => true; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ExtensionProjectTemplateType && + runtimeType == other.runtimeType && + cliName == other.cliName; + + @override + int get hashCode => cliName.hashCode; +} + /// A [ParsedFlutterTemplateType] that is no longer operable. /// /// The CLI can give a hint to a developer that the [cliName] _did_ use to exist, @@ -156,7 +215,7 @@ bool _validateMetadataMap(YamlMap map, Map validations, Logger log /// A wrapper around the `.metadata` file. class FlutterProjectMetadata { /// Creates a MigrateConfig by parsing an existing .migrate_config yaml file. - FlutterProjectMetadata(this.file, Logger logger) + FlutterProjectMetadata(this.file, Logger logger, {required this._extensionTemplateManager}) : _logger = logger, migrateConfig = MigrateConfig() { if (!file.existsSync()) { @@ -188,10 +247,12 @@ class FlutterProjectMetadata { if (_validateMetadataMap(yamlRoot, {'project_type': String}, _logger)) { final ParsedFlutterTemplateType? templateType = ParsedFlutterTemplateType.fromCliName( yamlRoot['project_type'] as String, + extensionTemplateManager: _extensionTemplateManager, ); _projectType = switch (templateType) { RemovedFlutterTemplateType() || null => null, FlutterTemplateType() => templateType, + ExtensionProjectTemplateType() => templateType, }; } final Object? migrationYaml = yamlRoot['migration']; @@ -203,15 +264,13 @@ class FlutterProjectMetadata { /// Creates a FlutterProjectMetadata by explicitly providing all values. FlutterProjectMetadata.explicit({ required this.file, - required String? versionRevision, - required String? versionChannel, - required FlutterTemplateType? projectType, + required this._versionRevision, + required this._versionChannel, + required this._projectType, required this.migrateConfig, - required Logger logger, - }) : _logger = logger, - _versionChannel = versionChannel, - _versionRevision = versionRevision, - _projectType = projectType; + required this._logger, + required this._extensionTemplateManager, + }); /// The name of the config file. static const kFileName = '.metadata'; @@ -222,13 +281,14 @@ class FlutterProjectMetadata { String? _versionChannel; String? get versionChannel => _versionChannel; - FlutterTemplateType? _projectType; - FlutterTemplateType? get projectType => _projectType; + ParsedFlutterTemplateType? _projectType; + ParsedFlutterTemplateType? get projectType => _projectType; /// Metadata and configuration for the migrate command. MigrateConfig migrateConfig; final Logger _logger; + final ExtensionTemplateManager? _extensionTemplateManager; final File file; @@ -319,6 +379,11 @@ class MigrateConfig { /// Parses the project for all supported platforms and populates the [MigrateConfig] /// to reflect the project. + /// + /// Platforms listed in [platforms] are the ones (re)generated by the current + /// operation. When a platform is already tracked and [create] is true, its + /// entry is overwritten with the current revisions instead of keeping stale + /// ones or adding a duplicate entry. void populate({ List? platforms, required Directory projectDirectory, @@ -333,7 +398,16 @@ class MigrateConfig { for (final SupportedPlatform platform in platforms) { if (platformConfigs.containsKey(platform)) { - if (update) { + if (create) { + // The platform is being (re)created by this run: overwrite its + // entry so the recorded revisions reflect this run. Creating the + // same platform twice must not produce a duplicate entry. + platformConfigs[platform] = MigratePlatformConfig( + platform: platform, + createRevision: createRevision, + baseRevision: currentRevision, + ); + } else if (update) { platformConfigs[platform]!.baseRevision = currentRevision; } } else { diff --git a/packages/flutter_tools/lib/src/globals.dart b/packages/flutter_tools/lib/src/globals.dart index d1294c13..1eb9006f 100644 --- a/packages/flutter_tools/lib/src/globals.dart +++ b/packages/flutter_tools/lib/src/globals.dart @@ -284,11 +284,6 @@ CustomDevicesConfig get customDevicesConfig => context.get( PreRunValidator get preRunValidator => context.get() ?? const NoOpPreRunValidator(); -// Used to build RegExp instances which can detect the VM service message. -final kVMServiceMessageRegExp = RegExp( - r'The Dart VM service is listening on ((http|//)[a-zA-Z0-9:/=_\-\.\[\]]+)', -); - /// Contains information about the JRE/JDK to use for Java-dependent operations. /// /// A value of `null` indicates that no installation of java could be found on diff --git a/packages/flutter_tools/lib/src/http_host_validator.dart b/packages/flutter_tools/lib/src/http_host_validator.dart index 9702f83b..10bf8c1a 100644 --- a/packages/flutter_tools/lib/src/http_host_validator.dart +++ b/packages/flutter_tools/lib/src/http_host_validator.dart @@ -4,6 +4,8 @@ import 'dart:async'; +import 'package:flutter_tools_core/flutter_tools_core.dart'; + import 'base/io.dart'; import 'base/net.dart'; import 'base/platform.dart'; @@ -23,13 +25,10 @@ const kPubDevOverride = 'PUB_HOSTED_URL'; // https://dart.dev/tools/pub/environm // Validator that checks all provided hosts are reachable and responsive class HttpHostValidator extends DoctorValidator { HttpHostValidator({ - required Platform platform, - required FeatureFlags featureFlags, - required HttpClient httpClient, - }) : _platform = platform, - _featureFlags = featureFlags, - _httpClient = httpClient, - super('Network resources'); + required this._platform, + required this._featureFlags, + required this._httpClient, + }) : super('Network resources'); final Platform _platform; final FeatureFlags _featureFlags; diff --git a/packages/flutter_tools/lib/src/intellij/intellij.dart b/packages/flutter_tools/lib/src/intellij/intellij.dart deleted file mode 100644 index 781728cf..00000000 --- a/packages/flutter_tools/lib/src/intellij/intellij.dart +++ /dev/null @@ -1,148 +0,0 @@ -// Copyright 2014 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 'package:archive/archive.dart'; - -import '../base/file_system.dart'; -import '../base/version.dart'; -import '../convert.dart'; -import '../doctor_validator.dart'; - -/// A parser for the Intellij and Android Studio plugin JAR files. -/// -/// This searches on the provided plugin path for a JAR archive, then -/// unzips it to parse the META-INF/plugin.xml for version information. -/// -/// In particular, the Intellij Flutter plugin has a different structure depending on the version. -/// -/// For flutter-intellij plugin v49 or lower: -/// flutter-intellij/lib/flutter-intellij.jar ( includes META-INF/plugin.xml ) -/// -/// For flutter-intellij plugin v50 or higher and for Intellij 2020.2: -/// flutter-intellij/lib/flutter-intellij-X.Y.Z.jar -/// flutter-idea-X.Y.Z.jar ( includes META-INF/plugin.xml ) -/// flutter-studio-X.Y.Z.jar -/// -/// For flutter-intellij plugin v50 or higher and for Intellij 2020.3: -/// flutter-intellij/lib/flutter-intellij-X.Y.Z.jar -/// flutter-idea-X.Y.Z.jar ( includes META-INF/plugin.xml ) -/// -/// where X.Y.Z is the version number. -/// -/// Intellij Flutter plugin's files can be found here: -/// https://plugins.jetbrains.com/plugin/9212-flutter/versions/stable -class IntelliJPlugins { - IntelliJPlugins(this.pluginsPath, {required FileSystem fileSystem}) : _fileSystem = fileSystem; - - final FileSystem _fileSystem; - final String pluginsPath; - - static final kMinFlutterPluginVersion = Version(16, 0, 0); - static const kIntellijDartPluginUrl = 'https://plugins.jetbrains.com/plugin/6351-dart'; - static const kIntellijFlutterPluginUrl = 'https://plugins.jetbrains.com/plugin/9212-flutter'; - - void validatePackage( - List messages, - List packageNames, - String title, - String url, { - Version? minVersion, - }) { - for (final packageName in packageNames) { - if (!_hasPackage(packageName)) { - continue; - } - - final String? versionText = _readPackageVersion(packageName); - final Version? version = Version.parse(versionText); - if (version != null && minVersion != null && version < minVersion) { - messages.add( - ValidationMessage.error( - '$title plugin version $versionText - the recommended minimum version is $minVersion', - ), - ); - } else { - messages.add( - ValidationMessage('$title plugin ${version != null ? "version $version" : "installed"}'), - ); - } - return; - } - messages.add(ValidationMessage('$title plugin can be installed from:', contextUrl: url)); - } - - bool _hasPackage(String packageName) { - final String packagePath = _fileSystem.path.join(pluginsPath, packageName); - if (packageName.endsWith('.jar')) { - return _fileSystem.isFileSync(packagePath); - } - return _fileSystem.isDirectorySync(packagePath); - } - - ArchiveFile? _findPluginXml(String packageName) { - final mainJarFileList = []; - if (packageName.endsWith('.jar')) { - // package exists (checked in _hasPackage) - mainJarFileList.add(_fileSystem.file(_fileSystem.path.join(pluginsPath, packageName))); - } else { - final String packageLibPath = _fileSystem.path.join(pluginsPath, packageName, 'lib'); - if (!_fileSystem.isDirectorySync(packageLibPath)) { - return null; - } - // Collect the files with a file suffix of .jar/.zip that contains the plugin.xml file - final List pluginJarFiles = _fileSystem - .directory(_fileSystem.path.join(pluginsPath, packageName, 'lib')) - .listSync() - .whereType() - .where((File file) { - final String fileExt = _fileSystem.path.extension(file.path); - return fileExt == '.jar' || fileExt == '.zip'; - }) - .toList(); - - if (pluginJarFiles.isEmpty) { - return null; - } - // Prefer file with the same suffix as the package name - pluginJarFiles.sort((File a, File b) { - final bool aStartWithPackageName = a.basename.toLowerCase().startsWith( - packageName.toLowerCase(), - ); - final bool bStartWithPackageName = b.basename.toLowerCase().startsWith( - packageName.toLowerCase(), - ); - if (bStartWithPackageName != aStartWithPackageName) { - return bStartWithPackageName ? 1 : -1; - } - return a.basename.length - b.basename.length; - }); - mainJarFileList.addAll(pluginJarFiles); - } - - for (final file in mainJarFileList) { - final Archive archive = ZipDecoder().decodeBytes(file.readAsBytesSync()); - final ArchiveFile? archiveFile = archive.findFile('META-INF/plugin.xml'); - if (archiveFile != null) { - return archiveFile; - } - } - return null; - } - - String? _readPackageVersion(String packageName) { - try { - final ArchiveFile? archiveFile = _findPluginXml(packageName); - if (archiveFile == null) { - return null; - } - final String content = utf8.decode(archiveFile.content as List); - const versionStartTag = ''; - final int start = content.indexOf(versionStartTag); - final int end = content.indexOf('', start); - return content.substring(start + versionStartTag.length, end); - } on ArchiveException { - return null; - } - } -} diff --git a/packages/flutter_tools/lib/src/ios/code_signing.dart b/packages/flutter_tools/lib/src/ios/code_signing.dart index c1128156..0f965ba4 100644 --- a/packages/flutter_tools/lib/src/ios/code_signing.dart +++ b/packages/flutter_tools/lib/src/ios/code_signing.dart @@ -7,6 +7,7 @@ library; import 'dart:async'; +import 'package:meta/meta.dart'; import 'package:process/process.dart'; import '../base/common.dart'; @@ -17,6 +18,8 @@ import '../base/logger.dart'; import '../base/platform.dart'; import '../base/process.dart'; import '../base/terminal.dart'; +import '../context/apple_context.dart'; +import '../context/tool_context.dart'; import '../convert.dart' show utf8; import 'plist_parser.dart'; @@ -197,15 +200,15 @@ Future?> getCodeSigningIdentityDevelopmentTeamBuildSetting({ return null; } - final settings = XcodeCodeSigningSettings( + final settings = XcodeCodeSigningSettings.fromParameters( config: config, + fileSystem: fileSystem, + fileSystemUtils: fileSystemUtils, logger: logger, platform: platform, + plistParser: plistParser, processUtils: ProcessUtils(processManager: processManager, logger: logger), - fileSystem: fileSystem, - fileSystemUtils: fileSystemUtils, terminal: terminal, - plistParser: plistParser, ); return settings._getCodeSigningBuildSettings(); @@ -230,15 +233,15 @@ Future getCodeSigningIdentityDevelopmentTeam({ required FileSystemUtils fileSystemUtils, required PlistParser plistParser, }) async { - final settings = XcodeCodeSigningSettings( + final settings = XcodeCodeSigningSettings.fromParameters( config: config, + fileSystem: fileSystem, + fileSystemUtils: fileSystemUtils, logger: logger, platform: platform, + plistParser: plistParser, processUtils: ProcessUtils(processManager: processManager, logger: logger), - fileSystem: fileSystem, - fileSystemUtils: fileSystemUtils, terminal: terminal, - plistParser: plistParser, ); final Map? buildSettings = await settings._getCodeSigningBuildSettings( @@ -276,23 +279,45 @@ Directory? getProvisioningProfileDirectory({ } class XcodeCodeSigningSettings { - XcodeCodeSigningSettings({ + XcodeCodeSigningSettings({required PlistParser plistParser, required ToolContext toolContext}) + : this.fromParameters( + config: toolContext.config, + fileSystem: toolContext.fs, + fileSystemUtils: toolContext.fileSystemUtils, + logger: toolContext.logger, + platform: toolContext.platform, + plistParser: plistParser, + processUtils: toolContext.processUtils, + terminal: toolContext.terminal, + ); + + XcodeCodeSigningSettings.fromContexts({ + required AppleContext appleContext, + required ToolContext toolContext, + }) : this(plistParser: appleContext.plistParser, toolContext: toolContext); + + XcodeCodeSigningSettings.fromParameters({ + required this._config, + required this._fileSystem, + required this._fileSystemUtils, + required this._logger, + required this._platform, + required this._plistParser, + required this._processUtils, + required this._terminal, + }); + + @visibleForTesting + factory XcodeCodeSigningSettings.test({ required Config config, + required FileSystem fileSystem, + required FileSystemUtils fileSystemUtils, required Logger logger, required Platform platform, + required PlistParser plistParser, required ProcessUtils processUtils, - required FileSystem fileSystem, - required FileSystemUtils fileSystemUtils, required Terminal terminal, - required PlistParser plistParser, - }) : _config = config, - _logger = logger, - _platform = platform, - _processUtils = processUtils, - _fileSystem = fileSystem, - _fileSystemUtils = fileSystemUtils, - _plistParser = plistParser, - _terminal = terminal; + }) = XcodeCodeSigningSettings.fromParameters; final Config _config; final Logger _logger; diff --git a/packages/flutter_tools/lib/src/ios/core_devices.dart b/packages/flutter_tools/lib/src/ios/core_devices.dart index 90796f6a..ecb6836b 100644 --- a/packages/flutter_tools/lib/src/ios/core_devices.dart +++ b/packages/flutter_tools/lib/src/ios/core_devices.dart @@ -14,12 +14,14 @@ import '../base/logger.dart'; import '../base/process.dart'; import '../base/template.dart'; import '../base/utils.dart'; +import '../base/version.dart'; import '../build_info.dart'; import '../convert.dart'; import '../device.dart'; import '../macos/xcode.dart'; import '../project.dart'; import 'application_package.dart'; +import 'device_support.dart'; import 'lldb.dart'; import 'xcode_debug.dart'; import 'xcodeproj.dart'; @@ -35,23 +37,22 @@ import 'xcodeproj.dart'; /// - [launchAppWithXcodeDebugger]: Uses Xcode automation to install, launch, and debug the app. class IOSCoreDeviceLauncher { IOSCoreDeviceLauncher({ - required IOSCoreDeviceControl coreDeviceControl, + required this._coreDeviceControl, required Logger logger, - required XcodeDebug xcodeDebug, - required FileSystem fileSystem, + required this._xcodeDebug, + required this._fileSystem, required ProcessUtils processUtils, required XcodeProjectInterpreter xcodeProjectInterpreter, + required Version? deviceVersion, @visibleForTesting LLDB? lldb, - }) : _coreDeviceControl = coreDeviceControl, - _logger = logger, - _xcodeDebug = xcodeDebug, - _fileSystem = fileSystem, + }) : _logger = logger, _lldb = lldb ?? LLDB( logger: logger, processUtils: processUtils, xcodeProjectInterpreter: xcodeProjectInterpreter, + deviceVersion: deviceVersion, ); final IOSCoreDeviceControl _coreDeviceControl; @@ -101,6 +102,7 @@ class IOSCoreDeviceLauncher { /// Requires Xcode 16+. Future launchAppWithLLDBDebugger({ required String deviceId, + required IOSDeviceSupport deviceSupport, required String bundlePath, required String bundleId, required List launchArguments, @@ -150,12 +152,22 @@ class IOSCoreDeviceLauncher { return false; } + // Kill LLDB and devicectl if the CLI shuts down so they do not hang in the background. + shutdownHooks.addShutdownHook(() async { + try { + await stopApp(deviceId: deviceId, processId: processId); + } on Exception { + // ignore any failures + } + }); + // Start LLDB and attach to the device process. final bool attachStatus = await _lldb.attachAndStart( deviceId: deviceId, appProcessId: processId, lldbLogForwarder: lldbLogForwarder, mode: mode, + deviceSupport: deviceSupport, ); // If it fails to attach with lldb, kill the launched process so it doesn't stay hanging. @@ -339,12 +351,10 @@ class IOSCoreDeviceControl { IOSCoreDeviceControl({ required Logger logger, required ProcessManager processManager, - required Xcode xcode, - required FileSystem fileSystem, + required this._xcode, + required this._fileSystem, }) : _logger = logger, - _processUtils = ProcessUtils(logger: logger, processManager: processManager), - _xcode = xcode, - _fileSystem = fileSystem; + _processUtils = ProcessUtils(logger: logger, processManager: processManager); final Logger _logger; final ProcessUtils _processUtils; @@ -717,6 +727,7 @@ class IOSCoreDeviceControl { 'launch', '--device', deviceId, + '--terminate-existing', if (startStopped) '--start-stopped', if (attachToConsole) ...[ '--console', @@ -853,7 +864,7 @@ class IOSCoreDeviceControl { unawaited( launchProcess.exitCode .then((int status) async { - _logger.printTrace('lldb exited with code $status'); + _logger.printTrace('devicectl exited with code $status'); await stdoutSubscription.cancel(); await stderrSubscription.cancel(); }) diff --git a/packages/flutter_tools/lib/src/ios/device_support.dart b/packages/flutter_tools/lib/src/ios/device_support.dart index 03a9ffcb..cf0298cf 100644 --- a/packages/flutter_tools/lib/src/ios/device_support.dart +++ b/packages/flutter_tools/lib/src/ios/device_support.dart @@ -4,6 +4,7 @@ import 'dart:async'; +import '../base/file_system.dart'; import '../base/io.dart'; import '../base/logger.dart'; import '../base/process.dart'; @@ -11,47 +12,87 @@ import '../base/version.dart'; import '../convert.dart'; import '../macos/xcode.dart'; -/// A class to handle preparing the device support for iOS devices. +/// A class to handle preparing the device support for an iOS/iPad device. class IOSDeviceSupport { IOSDeviceSupport({ - required Logger logger, - required ProcessUtils processUtils, - required Xcode? xcode, - }) : _xcode = xcode, - _logger = logger, - _processUtils = processUtils; + required this._logger, + required this._processUtils, + required this._xcode, + required this._deviceId, + required this._homeDirectory, + required this._modelCode, + required this._operatingSystemVersion, + required this._cpuArchitectureString, + }); final Logger _logger; final ProcessUtils _processUtils; final Xcode? _xcode; - /// Calls `xcodebuild -prepareDeviceSupport` for the given [deviceId] and streams the logs when - /// copying is in progress. + final String _deviceId; + final Directory? _homeDirectory; + final String? _modelCode; + final String? _operatingSystemVersion; + final String? _cpuArchitectureString; + + /// The directory that contains iOS Device Support symbols for all devices. + /// + /// Return null if the $HOME directory cannot be found. + late final Directory? _deviceSupportDirectory = _homeDirectory + ?.childDirectory('Library') + .childDirectory('Developer') + .childDirectory('Xcode') + .childDirectory('iOS DeviceSupport'); + + /// Command to copy device support symbols from device to host machine. + List get _prepareDeviceSupportCommand => [ + 'xcrun', + 'xcodebuild', + '-prepareDeviceSupport', + '-destination', + 'id=$_deviceId', + ]; + + /// Whether the prepareDeviceSupport command is available. Available on Xcode 16.3+. + late final bool _prepareDeviceSupportCommandAvailable = () { + final Version? xcodeVersion = _xcode?.currentVersion; + if (xcodeVersion == null || xcodeVersion < Version(16, 3, 0)) { + return false; + } + return true; + }(); + + /// Returns the directory for existing Device Support symbols. If there is not an existing + /// directory, returns null. + Directory? get existingDeviceSupportSymbols { + final (Directory? symbolDirectory, Directory? archSymbolDirectory) = _findSymbolDirectories(); + if (archSymbolDirectory != null && archSymbolDirectory.existsSync()) { + return archSymbolDirectory; + } + if (symbolDirectory != null && symbolDirectory.existsSync()) { + return symbolDirectory; + } + return null; + } + + /// Calls `xcodebuild -prepareDeviceSupport` and streams the logs when copying is in progress. /// /// The command copies symbols from the iOS device to the host machine and stores them in /// $HOME/Library/Developer/Xcode/iOS DeviceSupport. Without these symbols, debugging is /// extremely slow. - Future prepareDeviceSupport(String deviceId) async { - final Version? xcodeVersion = _xcode?.currentVersion; - if (xcodeVersion == null || xcodeVersion < Version(16, 3, 0)) { - // The prepareDeviceSupport command is only available on Xcode 16.3+ + Future prepareDeviceSupport() async { + if (!_prepareDeviceSupportCommandAvailable || existingDeviceSupportSymbols != null) { return; } - final command = [ - 'xcrun', - 'xcodebuild', - '-prepareDeviceSupport', - '-destination', - 'id=$deviceId', - ]; + try { - final Process process = await _processUtils.start(command); + final Process process = await _processUtils.start(_prepareDeviceSupportCommand); final timer = Timer(const Duration(seconds: 10), () { _logger.printError( 'Xcode is taking longer than expected to start preparing Device Support symbols...\n' - 'Connect your device via USB and try running this command manually:\n' - ' "${command.join(' ')}"', + 'Try closing Xcode, connecting your device via USB, and running the following command:\n' + ' "${_prepareDeviceSupportCommand.join(' ')}"', ); }); @@ -63,7 +104,7 @@ class IOSDeviceSupport { printToTrace = false; _logger.printStatus( 'Copying Device Support symbols. This may take several minutes to complete...\n' - 'Please do not connect or disconnect your device until finished.', + 'Please do not connect or disconnect your device or open Xcode until finished.', ); timer.cancel(); } @@ -111,4 +152,92 @@ class IOSDeviceSupport { _logger.printTrace('$stackTrace'); } } + + /// Returns a warning describing the status of Device Support symbols and guided actions to + /// resolve issues. + String? missingSymbolsWarning({bool warnWhenSymbolsExist = false}) { + const reducePerformance = + 'This will likely reduce debugging performance and may cause the app to hang on a white ' + 'screen during launch.'; + const expectedPath = + r'Once Device Support symbols are finished being copied, they are expected to be found at'; + + if (existingDeviceSupportSymbols != null) { + if (warnWhenSymbolsExist) { + final String action; + if (_prepareDeviceSupportCommandAvailable) { + action = + 'Connect the device via USB and trigger a copy:\n' + ' ${_prepareDeviceSupportCommand.join(' ')}'; + } else { + action = + 'Connect the device via USB, close and then re-open Xcode. It may take several ' + 'minutes for Xcode to copy symbols from the device. $expectedPath ${existingDeviceSupportSymbols!.path}'; + } + return 'Xcode Device Support symbols exist for this device, but are being read from ' + 'process memory. $reducePerformance\n' + 'To re-copy symbols from your device, complete the following steps:\n' + ' 1. Remove cached symbols:\n' + ' rm -rf "${existingDeviceSupportSymbols!.parent.path}"\n' + ' 2. $action'; + } + return null; + } + + final (Directory? symbolDirectory, Directory? archSymbolDirectory) = _findSymbolDirectories(); + + const unableToFind = 'Xcode Device Support was not found for this device.'; + const incompleteCopy = 'Xcode has not finished copying Device Support symbols for this device.'; + final String action; + if (_prepareDeviceSupportCommandAvailable) { + action = + 'To trigger Device Symbols to be copied, connect the device via USB, and run this command:\n' + ' "${_prepareDeviceSupportCommand.join(' ')}"'; + } else { + action = + 'To trigger Device Symbols to be copied, connect the device via USB, close and then ' + 're-open Xcode. It may take several minutes for Xcode to copy symbols from the device.'; + } + + const retryAction = 'Please retry "flutter run" once symbols are finished being copied.'; + + // The symbol directory may be null if the home directory could not be determined. + if (symbolDirectory == null) { + return '$unableToFind $reducePerformance\n$action\n$expectedPath ' + '\$HOME/Library/Developer/Xcode/iOS DeviceSupport\n$retryAction'; + } + + // If the device's directory exists, it means Xcode has at least started copying symbols. + final bool deviceDirExists = symbolDirectory.parent.existsSync(); + final status = deviceDirExists ? incompleteCopy : unableToFind; + final archPath = archSymbolDirectory != null ? ' or ${archSymbolDirectory.path}' : ''; + + return '$status $reducePerformance\n$action\n$expectedPath ${symbolDirectory.path}$archPath\n' + '$retryAction'; + } + + /// Helper method to find the Device Support Symbols directories for a given device. + /// + /// Returns a tuple of the symbol directory and the architecture-specific symbol directory. + /// If the paths to the directories cannot be constructed, returns (null, null). + /// + /// The architecture-specific symbol directory is only present on newer versions of iOS (27+). + (Directory?, Directory?) _findSymbolDirectories() { + if (_deviceSupportDirectory == null || _modelCode == null || _operatingSystemVersion == null) { + return (null, null); + } + final Directory deviceDirectory = _deviceSupportDirectory.childDirectory( + '$_modelCode $_operatingSystemVersion', + ); + final Directory symbolDirectory = deviceDirectory.childDirectory('Symbols'); + + // iOS 27+ devices seem to store the symbol directory in a sub-directory + // /Users/username/Library/Developer/Xcode/iOS DeviceSupport/iPad14,3 27.0 (24A5380h)/arm64e/Symbols + final Directory? archSymbolDirectory = + _cpuArchitectureString != null && _cpuArchitectureString.isNotEmpty + ? deviceDirectory.childDirectory(_cpuArchitectureString).childDirectory('Symbols') + : null; + + return (symbolDirectory, archSymbolDirectory); + } } diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart index 62ac4be1..2de2db0d 100644 --- a/packages/flutter_tools/lib/src/ios/devices.dart +++ b/packages/flutter_tools/lib/src/ios/devices.dart @@ -90,14 +90,11 @@ enum IOSDeploymentMethod { class IOSDevices extends PollingDeviceDiscovery { IOSDevices({ - required Platform platform, + required this._platform, required this.xcdevice, - required IOSWorkflow iosWorkflow, - required Logger logger, - }) : _platform = platform, - _iosWorkflow = iosWorkflow, - _logger = logger, - super('iOS devices'); + required this._iosWorkflow, + required this._logger, + }) : super('iOS devices'); final Platform _platform; final IOSWorkflow _iosWorkflow; @@ -308,40 +305,32 @@ class IOSDevices extends PollingDeviceDiscovery { class IOSDevice extends Device { IOSDevice( super.id, { - required FileSystem fileSystem, - required ProcessUtils processUtils, + required this._fileSystem, + required this._fileSystemUtils, + required this._processUtils, required this.name, - required CpuArch cpuArch, + required this._cpuArch, + this._cpuArchitectureString, required this.connectionInterface, required this.isConnected, required this.isPaired, required this.devModeEnabled, required this.isCoreDevice, - String? sdkVersion, - required Platform platform, - required IOSDeploy iosDeploy, - required IMobileDevice iMobileDevice, - required IOSCoreDeviceControl coreDeviceControl, - required IOSCoreDeviceLauncher coreDeviceLauncher, - required XcodeDebug xcodeDebug, + this._sdkVersion, + this._modelCode, + this._operatingSystemVersion, + required this._platform, + required this._iosDeploy, + required this._iMobileDevice, + required this._coreDeviceControl, + required this._coreDeviceLauncher, + required this._xcodeDebug, required IProxy iProxy, required super.logger, - required Analytics analytics, - required Xcode? xcode, - }) : _cpuArch = cpuArch, - _sdkVersion = sdkVersion, - _iosDeploy = iosDeploy, - _iMobileDevice = iMobileDevice, - _coreDeviceControl = coreDeviceControl, - _coreDeviceLauncher = coreDeviceLauncher, - _xcodeDebug = xcodeDebug, - _xcode = xcode, - _iproxy = iProxy, - _fileSystem = fileSystem, - _processUtils = processUtils, + required this._analytics, + required this._xcode, + }) : _iproxy = iProxy, _logger = logger, - _analytics = analytics, - _platform = platform, super(category: Category.mobile, platformType: PlatformType.ios, ephemeral: true) { if (!_platform.isMacOS) { assert(false, 'Control of iOS devices or simulators only supported on Mac OS.'); @@ -350,9 +339,12 @@ class IOSDevice extends Device { } final String? _sdkVersion; + final String? _modelCode; + final String? _operatingSystemVersion; final IOSDeploy _iosDeploy; final Analytics _analytics; final FileSystem _fileSystem; + final FileSystemUtils _fileSystemUtils; final ProcessUtils _processUtils; final Logger _logger; final Platform _platform; @@ -372,6 +364,19 @@ class IOSDevice extends Device { return sdkVersion?.major ?? 0; } + late final IOSDeviceSupport deviceSupport = IOSDeviceSupport( + logger: _logger, + processUtils: _processUtils, + xcode: _xcode, + homeDirectory: _fileSystemUtils.homeDirPath == null + ? null + : _fileSystem.directory(_fileSystemUtils.homeDirPath), + modelCode: _modelCode, + operatingSystemVersion: _operatingSystemVersion, + cpuArchitectureString: _cpuArchitectureString, + deviceId: id, + ); + @override final String name; @@ -380,6 +385,8 @@ class IOSDevice extends Device { final CpuArch _cpuArch; + final String? _cpuArchitectureString; + @override Future get cpuArch async => _cpuArch; @@ -572,11 +579,7 @@ class IOSDevice extends Device { final bool shouldAttachDebugger = shouldAttachLLDBDebugger(debuggingOptions); if (shouldAttachDebugger) { - await IOSDeviceSupport( - logger: _logger, - processUtils: _processUtils, - xcode: _xcode, - ).prepareDeviceSupport(id); + await deviceSupport.prepareDeviceSupport(); } // Step 3: Attempt to install the application on the device. @@ -884,8 +887,8 @@ class IOSDevice extends Device { pattern: RegExp( '`UIScene` lifecycle will soon be required|This process does not adopt UIScene lifecycle', ), - action: () { - globals.printWarning(uisceneWarning); + action: (String message) { + _logger.printWarning(uisceneWarning); }, excludeFromStream: true, ); @@ -895,7 +898,7 @@ class IOSDevice extends Device { final uisceneCrashInterceptor = LogInterceptor( identifier: 'uiscene_crash', pattern: RegExp(r'UIScene life\s?cycle is required'), - action: () { + action: (String message) { throwToolExit(kUISceneMigrationRequiredError); }, excludeFromStream: false, @@ -910,7 +913,7 @@ class IOSDevice extends Device { final appTerminatedInterceptor = LogInterceptor( identifier: 'app_terminated', pattern: RegExp('^App terminated due to signal'), - action: () { + action: (String message) { if (!appTerminatedCompleter.isCompleted) { appTerminatedCompleter.complete(); } @@ -918,6 +921,20 @@ class IOSDevice extends Device { excludeFromStream: false, ); deviceLogReader.addLogInterceptor(appTerminatedInterceptor); + + final missingSymbolsInterceptor = LogInterceptor( + identifier: 'missing_symbols', + pattern: LLDB.missingSymbolsPattern, + action: (String message) { + _logger.printTrace(message); + final String? warning = deviceSupport.missingSymbolsWarning(warnWhenSymbolsExist: true); + if (warning != null) { + _logger.printWarning(warning); + } + }, + excludeFromStream: true, + ); + deviceLogReader.addLogInterceptor(missingSymbolsInterceptor); } /// Find the Dart VM url using ProtocolDiscovery (logs from `idevicesyslog`) @@ -934,13 +951,14 @@ class IOSDevice extends Device { }) async { final bool discoverVMUrlFromLogs = vmServiceDiscovery != null && !isWirelesslyConnected; - // If mDNS fails, don't throw since url may still be findable through vmServiceDiscovery. final Future vmUrlFromMDns = MDnsVmServiceDiscovery.instance!.getVMServiceUriForLaunch( packageId, this, - usesIpv6: debuggingOptions.ipv6, + // If mDNS fails, don't throw if the URL may still be findable through + // log-based discovery (vmServiceDiscovery). + throwOnError: !discoverVMUrlFromLogs, useDeviceIPAsHost: isWirelesslyConnected, - throwOnMissingLocalNetworkPermissionsError: !discoverVMUrlFromLogs, + usesIpv6: debuggingOptions.ipv6, ); final discoveryOptions = >[ @@ -989,7 +1007,7 @@ class IOSDevice extends Device { return LogInterceptor( identifier: kJITCrashLogInterceptorIdentifier, pattern: kJITCrashFailureMessage, - action: () { + action: (String message) { throwToolExit(jitCrashFailureInstructions(deviceSdkVersion)); }, excludeFromStream: false, @@ -1112,6 +1130,7 @@ class IOSDevice extends Device { if (shouldAttachDebugger) { final bool launchSuccess = await _coreDeviceLauncher.launchAppWithLLDBDebugger( deviceId: id, + deviceSupport: deviceSupport, bundlePath: package.deviceBundlePath, bundleId: package.id, launchArguments: launchArguments, @@ -1322,8 +1341,12 @@ class IOSDevice extends Device { appId: appId, deviceVmservicePort: filterDevicePort, hostVmservicePort: expectedHostPort, - usesIpv6: ipv6, + // If compatible with protocol discovery via logs, do not throw on mDNS + // error so DelegateVMServiceDiscoveryForAttach can fall back to log-based + // discovery. + throwOnError: !compatibleWithProtocolDiscovery, useDeviceIPAsHost: isWirelesslyConnected, + usesIpv6: ipv6, ); if (compatibleWithProtocolDiscovery) { @@ -1475,7 +1498,7 @@ class LogInterceptor { final Pattern pattern; /// If the log contain the [pattern], the [action] will be called. - final void Function() action; + final void Function(String message) action; /// If `true`, the log will be excluded from being added to the stream. final bool excludeFromStream; @@ -1513,7 +1536,7 @@ abstract class SharedIOSDeviceLogReader extends DeviceLogReader { bool _interceptLog(String message) { for (final LogInterceptor interceptor in _logInterceptors) { if (message.contains(interceptor.pattern)) { - interceptor.action(); + interceptor.action(message); if (interceptor.excludeFromStream) { return true; } @@ -2033,14 +2056,11 @@ class CoreDeviceLoggingSource { class IOSDevicePortForwarder extends DevicePortForwarder { /// Create a new [IOSDevicePortForwarder]. IOSDevicePortForwarder({ - required Logger logger, - required String id, - required IProxy iproxy, - required OperatingSystemUtils operatingSystemUtils, - }) : _logger = logger, - _id = id, - _iproxy = iproxy, - _operatingSystemUtils = operatingSystemUtils; + required this._logger, + required this._id, + required this._iproxy, + required this._operatingSystemUtils, + }); /// Create a [IOSDevicePortForwarder] for testing. /// diff --git a/packages/flutter_tools/lib/src/ios/ios_deploy.dart b/packages/flutter_tools/lib/src/ios/ios_deploy.dart index e9de491a..752c7e6f 100644 --- a/packages/flutter_tools/lib/src/ios/ios_deploy.dart +++ b/packages/flutter_tools/lib/src/ios/ios_deploy.dart @@ -27,23 +27,22 @@ const unknownAppLaunchError = 'Error 0xe8000022'; class IOSDeploy { IOSDeploy({ - required Artifacts artifacts, - required Cache cache, + required this._artifacts, + required this._cache, required Logger logger, - required Platform platform, + required this._platform, required ProcessManager processManager, - }) : _platform = platform, - _cache = cache, - _processUtils = ProcessUtils(processManager: processManager, logger: logger), - _logger = logger, - _binaryPath = artifacts.getHostArtifact(HostArtifact.iosDeploy).path; + }) : _processUtils = ProcessUtils(processManager: processManager, logger: logger), + _logger = logger; + final Artifacts _artifacts; final Cache _cache; - final String _binaryPath; final Logger _logger; final Platform _platform; final ProcessUtils _processUtils; + String get _binaryPath => _artifacts.getHostArtifact(HostArtifact.iosDeploy).path; + Map get iosDeployEnv { // Push /usr/bin to the front of PATH to pick up default system python, package 'six'. // @@ -214,15 +213,11 @@ enum _IOSDeployDebuggerState { detached, launching, attached } /// Wrapper to launch app and attach the debugger with ios-deploy. class IOSDeployDebugger { IOSDeployDebugger({ - required Logger logger, - required ProcessUtils processUtils, - required List launchCommand, - required Map iosDeployEnv, - }) : _processUtils = processUtils, - _logger = logger, - _launchCommand = launchCommand, - _iosDeployEnv = iosDeployEnv, - _debuggerState = _IOSDeployDebuggerState.detached; + required this._logger, + required this._processUtils, + required this._launchCommand, + required this._iosDeployEnv, + }) : _debuggerState = _IOSDeployDebuggerState.detached; /// Create a [IOSDeployDebugger] for testing. /// diff --git a/packages/flutter_tools/lib/src/ios/ios_workflow.dart b/packages/flutter_tools/lib/src/ios/ios_workflow.dart index a656fd46..9c45e1f4 100644 --- a/packages/flutter_tools/lib/src/ios/ios_workflow.dart +++ b/packages/flutter_tools/lib/src/ios/ios_workflow.dart @@ -8,13 +8,7 @@ import '../features.dart'; import '../macos/xcode.dart'; class IOSWorkflow implements Workflow { - const IOSWorkflow({ - required Platform platform, - required FeatureFlags featureFlags, - required Xcode xcode, - }) : _platform = platform, - _featureFlags = featureFlags, - _xcode = xcode; + const IOSWorkflow({required this._platform, required this._featureFlags, required this._xcode}); final Platform _platform; final FeatureFlags _featureFlags; diff --git a/packages/flutter_tools/lib/src/ios/iproxy.dart b/packages/flutter_tools/lib/src/ios/iproxy.dart index 6890c115..994ec0b7 100644 --- a/packages/flutter_tools/lib/src/ios/iproxy.dart +++ b/packages/flutter_tools/lib/src/ios/iproxy.dart @@ -4,6 +4,7 @@ import 'package:process/process.dart'; +import '../artifacts.dart'; import '../base/io.dart'; import '../base/logger.dart'; import '../base/process.dart'; @@ -13,21 +14,29 @@ import '../base/process.dart'; /// See https://github.com/libimobiledevice/libusbmuxd. class IProxy { IProxy({ - required String iproxyPath, + required Artifacts this._artifacts, required Logger logger, required ProcessManager processManager, - required MapEntry dyLdLibEntry, - }) : _dyLdLibEntry = dyLdLibEntry, - _processUtils = ProcessUtils(processManager: processManager, logger: logger), + required this._dyLdLibEntry, + }) : _processUtils = ProcessUtils(processManager: processManager, logger: logger), _logger = logger, - _iproxyPath = iproxyPath; + _iproxyPath = null; + + IProxy.fromPath({ + required String this._iproxyPath, + required Logger logger, + required ProcessManager processManager, + required this._dyLdLibEntry, + }) : _processUtils = ProcessUtils(processManager: processManager, logger: logger), + _logger = logger, + _artifacts = null; /// Create a [IProxy] for testing. /// /// This specifies the path to iproxy as 'iproxy` and the dyLdLibEntry as /// 'DYLD_LIBRARY_PATH: /path/to/libs'. factory IProxy.test({required Logger logger, required ProcessManager processManager}) { - return IProxy( + return IProxy.fromPath( iproxyPath: 'iproxy', logger: logger, processManager: processManager, @@ -35,15 +44,18 @@ class IProxy { ); } - final String _iproxyPath; + final Artifacts? _artifacts; + final String? _iproxyPath; final ProcessUtils _processUtils; final Logger _logger; final MapEntry _dyLdLibEntry; + String get iproxyPath => _iproxyPath ?? _artifacts!.getHostArtifact(HostArtifact.iproxy).path; + Future forward(int devicePort, int hostPort, String deviceId) { // Usage: iproxy LOCAL_PORT:DEVICE_PORT --udid UDID return _processUtils.start([ - _iproxyPath, + iproxyPath, '$hostPort:$devicePort', '--udid', deviceId, diff --git a/packages/flutter_tools/lib/src/ios/lldb.dart b/packages/flutter_tools/lib/src/ios/lldb.dart index 31f24abb..bd5dfab6 100644 --- a/packages/flutter_tools/lib/src/ios/lldb.dart +++ b/packages/flutter_tools/lib/src/ios/lldb.dart @@ -7,11 +7,14 @@ library; import 'dart:async'; +import '../base/file_system.dart'; import '../base/io.dart'; import '../base/logger.dart'; import '../base/process.dart'; import '../base/utils.dart'; +import '../base/version.dart'; import '../build_info.dart'; +import 'device_support.dart'; import 'xcodeproj.dart'; /// LLDB is the default debugger in Xcode on macOS. Once the application has @@ -20,16 +23,16 @@ import 'xcodeproj.dart'; /// See `xcrun devicectl device process launch --help` for more information. class LLDB { LLDB({ - required Logger logger, - required ProcessUtils processUtils, - required XcodeProjectInterpreter xcodeProjectInterpreter, - }) : _logger = logger, - _processUtils = processUtils, - _xcodeProjectInterpreter = xcodeProjectInterpreter; + required this._logger, + required this._processUtils, + required this._xcodeProjectInterpreter, + required this._deviceVersion, + }); final Logger _logger; final ProcessUtils _processUtils; final XcodeProjectInterpreter _xcodeProjectInterpreter; + final Version? _deviceVersion; _LLDBProcess? _lldbProcess; @@ -39,6 +42,8 @@ class LLDB { /// Whether or not the LLDB process has attached and resumed the application process. var _isAttached = false; + var _warnedAboutMissingSymbols = false; + /// The process id of the application running on the iOS device. int? get appProcessId => _lldbProcess?.appProcessId; @@ -74,12 +79,32 @@ class LLDB { /// Example: "- Hook 1 (thread backtrace all)" static final _stopHookProcessedPattern = RegExp(r'- Hook \d+'); + /// Pattern of lldb log when the application has started. + /// + /// Example: Target 0: (Flutter Gallery) stopped. + static final _targetStoppedPattern = RegExp(r'Target .* stopped.'); + + /// LLDB command to continue execution of all threads in the current process. + static const _processContinueCommand = 'process continue'; + + /// LLDB command to show backtraces of all thread call stacks. + static const _threadBacktraceAllCommand = 'thread backtrace all'; + + /// LLDB command to detach from the current target process. + static const _detachCommand = 'detach'; + /// A list of log patterns to ignore. static final _ignorePatterns = [ RegExp(r'\d+ location added to breakpoint \d+'), _stopHookProcessedPattern, ]; + /// Pattern of lldb log when libobjc.A.dylib is being read from process memory. This indicates + /// that Device Support symbols were not found on the host machine. + static final missingSymbolsPattern = RegExp( + 'warning: libobjc.A.dylib is being read from process memory.', + ); + /// Breakpoint script required for JIT on iOS. /// /// This should match the "handle_new_rx_page" function in [IosProject._lldbPythonHelperTemplate]. @@ -100,8 +125,6 @@ if not error.Success(): print(f'Failed to write into {base}[+{page_len}]', error) return -# If the returned value is False, that tells LLDB not to stop at the breakpoint -return False '''; /// Starts an LLDB process and inputs commands to start debugging the [appProcessId]. @@ -114,34 +137,58 @@ return False required int appProcessId, required LLDBLogForwarder lldbLogForwarder, required BuildMode mode, + required IOSDeviceSupport deviceSupport, }) async { Timer? timer; try { timer = Timer(const Duration(minutes: 1), () { - _logger.printError( - 'LLDB is taking longer than expected to start debugging the app. ' - "LLDB debugging can be disabled for the project by adding the following in the project's pubspec.yaml:\n" - 'flutter:\n' - ' config:\n' - ' enable-lldb-debugging: false\n' - 'Or disable LLDB debugging globally with the following command:\n' - ' "flutter config --no-enable-lldb-debugging"', - ); + final String? warning = deviceSupport.missingSymbolsWarning(); + if (warning != null) { + _logger.printWarning( + 'LLDB is taking longer than expected to start debugging the app. $warning', + ); + _warnedAboutMissingSymbols = true; + } else { + _logger.printError( + 'LLDB is taking longer than expected to start debugging the app. Try again using ' + '--verbose and file a bug including the verbose logs ' + 'at https://github.com/flutter/flutter/issues/new?template=01_activation.yml', + ); + } }); + // iOS 27+ devices sometimes fail to stop at the JIT breakpoint (only used in debug mode) + // when `auto-continue` is enabled. This causes the app to crash. To workaround this, we + // manually listen for stops and continue the process if the stop is due to the breakpoint. + var manualContinue = false; + if (mode == BuildMode.debug && + _deviceVersion != null && + _deviceVersion >= Version(27, 0, 0)) { + manualContinue = true; + } + final bool start = await _startLLDB( appProcessId: appProcessId, lldbLogForwarder: lldbLogForwarder, + manualContinue: manualContinue, ); if (!start) { return false; } await _selectDevice(deviceId); + await _addSymbolSearchPaths(deviceSupport); + if (mode == BuildMode.debug) { - await _setBreakpoint(); + await _setBreakpoint(manualContinue); } await _attachToAppProcess(appProcessId); - await _setupStopHooks(); + + // Stop hooks should only be used when manualContinue is false. Otherwise, it would run on + // every breakpoint stop instead of just crashes. + if (!manualContinue) { + await _setupStopHooks(); + } + await _printDeviceSupportStatus(); await _resumeProcess(mode); _isAttached = true; } on _LLDBError catch (e) { @@ -162,6 +209,7 @@ return False Future _startLLDB({ required int appProcessId, required LLDBLogForwarder lldbLogForwarder, + required bool manualContinue, }) async { if (_lldbProcess != null) { _logger.printTrace( @@ -178,18 +226,63 @@ return False appProcessId: appProcessId, logger: _logger, ); + + void printLine(String line) { + if (_isAttached && !_ignoreLog(line)) { + // Only forwards logs after LLDB is attached. All logs before then are part of the + // attach process. + + lldbLogForwarder.addLog(line); + } else { + _logger.printTrace('[lldb]: $line'); + _logCompleter?.checkForMatch(line); + } + } + + var processIsStoppedAfterAttaching = false; + final List stopLogs = []; final StreamSubscription stdoutSubscription = _lldbProcess!.stdout .transform(utf8LineDecoder) - .listen((String line) { - if (_isAttached && !_ignoreLog(line)) { - // Only forwards logs after LLDB is attached. All logs before then are part of the - // attach process. + .listen((String line) async { + // If not manually handling stops or if not attached yet, print the line directly. + if (!manualContinue || !_isAttached) { + printLine(line); + return; + } - lldbLogForwarder.addLog(line); - } else { - _logger.printTrace('[lldb]: $line'); - _logCompleter?.checkForMatch(line); + // When manually continuing, listen for process stops and then handle the stop by + // either continuing the process or detaching. + // + // When the process stops, collect logs until the target has stopped. Once the target + // has stopped, check if it stopped due to a breakpoint. If it has, continue and + // process and discard the logs after the process resumes. If the stop is not caused by + // a breakpoint, print the collected logs, get the stack trace, and detach. + if (line.contains(_lldbProcessStopped)) { + processIsStoppedAfterAttaching = true; + } + if (processIsStoppedAfterAttaching) { + stopLogs.add(line); + } + if (line.contains(_targetStoppedPattern)) { + if (stopLogs.any((log) => log.contains('stop reason = breakpoint'))) { + await _lldbProcess?.stdinWriteln(_processContinueCommand); + } else { + stopLogs.forEach(printLine); + await _lldbProcess?.stdinWriteln(_threadBacktraceAllCommand); + await _lldbProcess?.stdinWriteln(_detachCommand); + processIsStoppedAfterAttaching = false; + } + return; } + if (processIsStoppedAfterAttaching) { + if (line.contains(_lldbProcessResuming)) { + processIsStoppedAfterAttaching = false; + stopLogs.clear(); + } + return; + } + + printLine(line); }); final StreamSubscription stderrSubscription = _lldbProcess!.stderr @@ -232,6 +325,7 @@ return False } _logCompleter = null; _isAttached = false; + _warnedAboutMissingSymbols = false; return success; } @@ -240,13 +334,32 @@ return False await _lldbProcess?.stdinWriteln('device select $deviceId'); } + /// Adds the symbol search paths to the LLDB process. + Future _addSymbolSearchPaths(IOSDeviceSupport deviceSupport) async { + final Directory? symbolDirectory = deviceSupport.existingDeviceSupportSymbols; + if (symbolDirectory == null) { + return; + } + await _lldbProcess?.stdinWriteln( + 'platform select remote-ios --sysroot "${symbolDirectory.path}"', + ); + } + + /// Prints logs of available Device Support + Future _printDeviceSupportStatus() async { + final Future futureLog = _startWaitingForLog(RegExp(r'\s*Platform:')) + .then((value) => value, onError: _handleAsyncError); + + await _lldbProcess?.stdinWriteln('platform status'); + await futureLog; + } + /// Attaches LLDB to the [appProcessId] running on the device. Future _attachToAppProcess(int appProcessId) async { // Since the app starts stopped (--start-stopped), we expect a stopped state // after attaching. - final Future futureLog = _startWaitingForLog( - _lldbProcessStopped, - ).then((value) => value, onError: _handleAsyncError); + final Future futureLog = _startWaitingForLog(_lldbProcessStopped) + .then((value) => value, onError: _handleAsyncError); await _lldbProcess?.stdinWriteln('device process attach --pid $appProcessId'); await futureLog; @@ -254,14 +367,16 @@ return False /// Sets a breakpoint, waits for it print the breakpoint id, and adds a python /// script command to be executed whenever the breakpoint is hit. - Future _setBreakpoint() async { - final Future futureLog = _startWaitingForLog( - _breakpointPattern, - ).then((value) => value, onError: _handleAsyncError); + Future _setBreakpoint(bool manualContinue) async { + final Future futureLog = _startWaitingForLog(_breakpointPattern) + .then((value) => value, onError: _handleAsyncError); + + final breakpointSetCommand = manualContinue + ? r"breakpoint set --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'" + : r"breakpoint set --auto-continue true --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'"; + + await _lldbProcess?.stdinWriteln(breakpointSetCommand); - await _lldbProcess?.stdinWriteln( - r"breakpoint set --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'", - ); final String log = await futureLog; final Match? match = _breakpointPattern.firstMatch(log); final String? breakpointId = match?.group(1); @@ -290,7 +405,7 @@ return False mode == BuildMode.debug ? _lldbBreakpointAdded : _lldbProcessResuming, ).then((value) => value, onError: _handleAsyncError); - await _lldbProcess?.stdinWriteln('process continue'); + await _lldbProcess?.stdinWriteln(_processContinueCommand); await futureLog; } @@ -299,10 +414,11 @@ return False /// /// Without this, the debugger would remain attached to the process and the app will hang on crash. Future _setupStopHooks() async { - final Future futureLog = _startWaitingForLog( - _stopHookAddedPattern, - ).then((value) => value, onError: _handleAsyncError); - await _lldbProcess?.stdinWriteln('target stop-hook add -o "thread backtrace all" -o "detach"'); + final Future futureLog = _startWaitingForLog(_stopHookAddedPattern) + .then((value) => value, onError: _handleAsyncError); + await _lldbProcess?.stdinWriteln( + 'target stop-hook add -o "$_threadBacktraceAllCommand" -o "$_detachCommand"', + ); await futureLog; } @@ -346,6 +462,13 @@ return False } bool _ignoreLog(String log) { + // We only want to warn about missing symbols once. + if (log.contains(missingSymbolsPattern)) { + if (_warnedAboutMissingSymbols) { + return true; + } + _warnedAboutMissingSymbols = true; + } return _ignorePatterns.any((Pattern pattern) => log.contains(pattern)); } } @@ -384,9 +507,8 @@ class _LLDBLogPatternCompleter { /// A container class for associating a [Process] that is running LLDB with /// the iOS device process of an application. class _LLDBProcess { - _LLDBProcess({required Process process, required this.appProcessId, required Logger logger}) - : _lldbProcess = process, - _logger = logger; + _LLDBProcess({required Process process, required this.appProcessId, required this._logger}) + : _lldbProcess = process; final Process _lldbProcess; final int appProcessId; diff --git a/packages/flutter_tools/lib/src/ios/mac.dart b/packages/flutter_tools/lib/src/ios/mac.dart index 4d30668e..678a2ab2 100644 --- a/packages/flutter_tools/lib/src/ios/mac.dart +++ b/packages/flutter_tools/lib/src/ios/mac.dart @@ -74,12 +74,11 @@ For more information, please visit: class IMobileDevice { IMobileDevice({ - required Artifacts artifacts, + required this._artifacts, required Cache cache, required ProcessManager processManager, required Logger logger, - }) : _idevicesyslogPath = artifacts.getHostArtifact(HostArtifact.idevicesyslog).path, - _dyLdLibEntry = cache.dyLdLibEntry, + }) : _dyLdLibEntry = cache.dyLdLibEntry, _processUtils = ProcessUtils(logger: logger, processManager: processManager); /// Create an [IMobileDevice] for testing. @@ -93,10 +92,12 @@ class IMobileDevice { ); } - final String _idevicesyslogPath; + final Artifacts _artifacts; final MapEntry _dyLdLibEntry; final ProcessUtils _processUtils; + String get _idevicesyslogPath => _artifacts.getHostArtifact(HostArtifact.idevicesyslog).path; + /// Starts `idevicesyslog` and returns the running process. Future startLogger(String deviceID, bool isWirelesslyConnected) { return _processUtils.start([ @@ -147,6 +148,11 @@ Future buildXcodeProject({ fileSystem: globals.fs, plistParser: globals.plistParser, config: globals.config, + analytics: globals.analytics, + hostPlatform: globals.platform, + operatingSystemUtils: globals.os, + flutterVersion: globals.flutterVersion, + reportCrashes: !await globals.isRunningOnBot, ), SwiftPackageManagerGitignoreMigration(project, globals.logger), MetalAPIValidationMigrator.ios(app.project, globals.logger), @@ -180,6 +186,8 @@ Future buildXcodeProject({ fileSystem: globals.fs, logger: globals.logger, cocoapods: globals.cocoaPods, + analytics: globals.analytics, + featureFlags: featureFlags, ); await removeExtendedAttributesForProject( @@ -303,7 +311,7 @@ Future buildXcodeProject({ .fetchDependenciesAndGenerateXcodebuildArgs( app.project, globals.fs.directory(buildDirectoryPath), - skipPackageUpdatesAndValidation: false, + skipPackageValidation: false, ); final buildCommands = [...xcodebuildCommandArgs, '-configuration', configuration]; @@ -909,6 +917,7 @@ Future diagnoseXcodeBuildFailure( platform: platform, logger: logger, fileSystem: fileSystem, + analytics: analytics, device: device, ); @@ -1084,16 +1093,15 @@ _XCResultIssueHandlingResult _handleXCResultIssue({ hasProvisioningProfileIssue: false, duplicateModule: duplicateModule, ); - } else if (message.toLowerCase().contains('not found')) { - final String? missingModule = _parseMissingModule(message); - if (missingModule != null) { - return _XCResultIssueHandlingResult( - requiresProvisioningProfile: false, - hasProvisioningProfileIssue: false, - missingModule: missingModule, - ); - } - } else if (message.toLowerCase().contains('has been modified since')) { + } else if (message.toLowerCase().contains('not found') && _parseMissingModule(message) != null) { + return _XCResultIssueHandlingResult( + requiresProvisioningProfile: false, + hasProvisioningProfileIssue: false, + missingModule: _parseMissingModule(message), + ); + } else if (message.toLowerCase().contains('has been modified since') || + (message.toLowerCase().contains('module map file') && + message.toLowerCase().contains('not found'))) { return _XCResultIssueHandlingResult( requiresProvisioningProfile: false, hasProvisioningProfileIssue: false, @@ -1136,6 +1144,7 @@ Future _handleIssues( required FlutterDarwinPlatform platform, required Logger logger, required FileSystem fileSystem, + required Analytics analytics, Device? device, }) async { var requiresProvisioningProfile = false; @@ -1226,6 +1235,11 @@ Future _handleIssues( final bool usesCocoapods = xcodeProject.podfile.existsSync(); final bool usesSwiftPackageManager = xcodeProject.usesSwiftPackageManager; if (usesCocoapods && usesSwiftPackageManager) { + for (final module in duplicateModules) { + analytics.send( + Event.appleUsageEvent(workflow: 'duplicate-modules-build-failure', parameter: module), + ); + } logger.printError( 'Your project uses both CocoaPods and Swift Package Manager, which can ' 'cause the above error. It may be caused by there being both a CocoaPod ' @@ -1264,7 +1278,7 @@ Future _handleIssues( } else if (modifiedPrecompiledSource) { logger.printError( '════════════════════════════════════════════════════════════════════════════════\n' - 'A precompiled file has been changed since last built. Please run "flutter clean" to clear ' + 'A precompiled file has been changed since last built. Please run "flutter clean --include-xcode-workspace" to clear ' 'the cache.\n' '════════════════════════════════════════════════════════════════════════════════', ); @@ -1532,7 +1546,7 @@ class _XCResultIssueHandlingResult { final String? missingModule; /// An issue indicates that a source file, such as a header in the Flutter framework, has - /// changed since last built. This requires "flutter clean" to resolve. + /// changed since last built. This requires "flutter clean --include-xcode-workspace" to resolve. final bool modifiedPrecompiledSource; final bool unableToFindArmDestination; diff --git a/packages/flutter_tools/lib/src/ios/plist_parser.dart b/packages/flutter_tools/lib/src/ios/plist_parser.dart index ce6f099b..59280693 100644 --- a/packages/flutter_tools/lib/src/ios/plist_parser.dart +++ b/packages/flutter_tools/lib/src/ios/plist_parser.dart @@ -13,11 +13,10 @@ import '../convert.dart'; class PlistParser { PlistParser({ - required FileSystem fileSystem, + required this._fileSystem, required Logger logger, required ProcessManager processManager, - }) : _fileSystem = fileSystem, - _logger = logger, + }) : _logger = logger, _processUtils = ProcessUtils(logger: logger, processManager: processManager); final FileSystem _fileSystem; diff --git a/packages/flutter_tools/lib/src/ios/simulators.dart b/packages/flutter_tools/lib/src/ios/simulators.dart index f91b650e..243d67e7 100644 --- a/packages/flutter_tools/lib/src/ios/simulators.dart +++ b/packages/flutter_tools/lib/src/ios/simulators.dart @@ -37,9 +37,7 @@ import 'plist_parser.dart'; const iosSimulatorId = 'apple_ios_simulator'; class IOSSimulators extends PollingDeviceDiscovery { - IOSSimulators({required IOSSimulatorUtils iosSimulatorUtils}) - : _iosSimulatorUtils = iosSimulatorUtils, - super('iOS simulators'); + IOSSimulators({required this._iosSimulatorUtils}) : super('iOS simulators'); final IOSSimulatorUtils _iosSimulatorUtils; @@ -64,10 +62,9 @@ class IOSSimulatorUtils { required Xcode xcode, required Logger logger, required ProcessManager processManager, - required OperatingSystemUtils operatingSystemUtils, + required this._operatingSystemUtils, }) : _simControl = SimControl(logger: logger, processManager: processManager, xcode: xcode), - _xcode = xcode, - _operatingSystemUtils = operatingSystemUtils; + _xcode = xcode; final SimControl _simControl; final Xcode _xcode; @@ -117,9 +114,8 @@ class IOSSimulatorUtils { /// A wrapper around the `simctl` command line tool. class SimControl { - SimControl({required Logger logger, required ProcessManager processManager, required Xcode xcode}) + SimControl({required Logger logger, required ProcessManager processManager, required this._xcode}) : _logger = logger, - _xcode = xcode, _processUtils = ProcessUtils(processManager: processManager, logger: logger); final Logger _logger; @@ -364,12 +360,10 @@ class IOSSimulator extends Device { super.id, { required this.name, required this.simulatorCategory, - required SimControl simControl, - required CpuArch cpuArch, + required this._simControl, + required this._cpuArch, required super.logger, - }) : _simControl = simControl, - _cpuArch = cpuArch, - super(category: Category.mobile, platformType: PlatformType.ios, ephemeral: true); + }) : super(category: Category.mobile, platformType: PlatformType.ios, ephemeral: true); @override final String name; @@ -409,7 +403,7 @@ class IOSSimulator extends Device { @override Future get cpuArch async => _cpuArch; - final _logReaders = {}; + final _logReaders = {}; _IOSSimulatorDevicePortForwarder? _portForwarder; @override @@ -502,13 +496,25 @@ class IOSSimulator extends Device { ProtocolDiscovery? vmServiceDiscovery; if (debuggingOptions.debuggingEnabled) { + final _IOSSimulatorLogReader logReader = getLogReader(app: package); vmServiceDiscovery = ProtocolDiscovery.vmService( - getLogReader(app: package), + logReader, ipv6: debuggingOptions.ipv6, hostPort: debuggingOptions.hostVmServicePort, devicePort: debuggingOptions.deviceVmServicePort, logger: globals.logger, ); + // Launch the app only after the log stream is ready; otherwise the + // Dart VM Service URL it logs on startup can be missed. + const warningDelay = Duration(seconds: 30); + final timer = Timer(warningDelay, () { + globals.printError( + 'The iOS simulator log stream did not start after ${warningDelay.inSeconds} seconds. ' + 'This is taking much longer than expected...', + ); + }); + await logReader._ready.future; + timer.cancel(); } // Launch the updated application in the simulator. @@ -636,7 +642,7 @@ class IOSSimulator extends Device { } @override - DeviceLogReader getLogReader({covariant IOSApp? app, bool includePastLogs = false}) { + _IOSSimulatorLogReader getLogReader({covariant IOSApp? app, bool includePastLogs = false}) { assert(!includePastLogs, 'Past log reading not supported on iOS simulators.'); return _logReaders.putIfAbsent(app, () => _IOSSimulatorLogReader(this, app)); } @@ -678,8 +684,11 @@ class IOSSimulator extends Device { appId: appId, deviceVmservicePort: filterDevicePort, hostVmservicePort: expectedHostPort, - usesIpv6: ipv6, + // Do not throw on error since DelegateVMServiceDiscoveryForAttach falls + // back to log-based protocol discovery. + throwOnError: false, useDeviceIPAsHost: false, + usesIpv6: ipv6, ); return DelegateVMServiceDiscoveryForAttach([ @@ -843,7 +852,7 @@ class _IOSSimulatorLogReader extends SharedIOSDeviceLogReader { final uisceneCrashInterceptor = LogInterceptor( identifier: 'uiscene_crash', pattern: RegExp(r'UIScene life\s?cycle is required'), - action: () { + action: (String message) { throwToolExit(kUISceneMigrationRequiredError); }, excludeFromStream: false, @@ -860,6 +869,14 @@ class _IOSSimulatorLogReader extends SharedIOSDeviceLogReader { onCancel: _stop, ); + /// Completes once the log stream is ready to deliver events, or has exited. + /// + /// `log stream` is ready once it prints its first line (a "Filtering the log + /// data using ..." header). + /// + /// Recreated on every listen, since each one starts a new `log stream`. + var _ready = Completer(); + @override @visibleForTesting StreamController get linesController => _linesController; @@ -875,10 +892,18 @@ class _IOSSimulatorLogReader extends SharedIOSDeviceLogReader { String get name => device.name; Future _start() async { + // Use a local so that a previous process exiting late cannot complete it. + _ready = Completer(); + final Completer ready = _ready; // Unified logging iOS 11 and greater (introduced in iOS 10). if (await device.sdkMajorVersion >= 11) { _deviceProcess = await launchDeviceUnifiedLogging(device, _appName); - _deviceProcess?.stdout.transform(utf8LineDecoder).listen(_onUnifiedLoggingLine); + _deviceProcess?.stdout.transform(utf8LineDecoder).listen((String line) { + if (!ready.isCompleted) { + ready.complete(); + } + _onUnifiedLoggingLine(line); + }); _deviceProcess?.stderr.transform(utf8LineDecoder).listen(_onUnifiedLoggingLine); } else { // Fall back to syslog parsing. @@ -900,6 +925,9 @@ class _IOSSimulatorLogReader extends SharedIOSDeviceLogReader { // cleanup in the callback. unawaited( _deviceProcess?.exitCode.whenComplete(() { + if (!ready.isCompleted) { + ready.complete(); + } if (_linesController.hasListener) { _linesController.close(); } diff --git a/packages/flutter_tools/lib/src/ios/xcode_build_settings.dart b/packages/flutter_tools/lib/src/ios/xcode_build_settings.dart index 30f8d09d..03b51f81 100644 --- a/packages/flutter_tools/lib/src/ios/xcode_build_settings.dart +++ b/packages/flutter_tools/lib/src/ios/xcode_build_settings.dart @@ -12,15 +12,6 @@ import '../globals.dart' as globals; import '../project.dart'; import 'xcodeproj.dart'; -String flutterMacOSFrameworkDir(BuildMode mode, FileSystem fileSystem, Artifacts artifacts) { - final String flutterMacOSFramework = artifacts.getArtifactPath( - Artifact.flutterMacOSFramework, - platform: TargetPlatform.darwin, - mode: mode, - ); - return fileSystem.path.normalize(fileSystem.path.dirname(flutterMacOSFramework)); -} - /// Writes or rewrites Xcode property files with the specified information. /// /// useMacOSConfig: Optional parameter that controls whether we use the macOS diff --git a/packages/flutter_tools/lib/src/ios/xcode_debug.dart b/packages/flutter_tools/lib/src/ios/xcode_debug.dart index cc7b615a..ea1feafa 100644 --- a/packages/flutter_tools/lib/src/ios/xcode_debug.dart +++ b/packages/flutter_tools/lib/src/ios/xcode_debug.dart @@ -30,12 +30,10 @@ class XcodeDebug { XcodeDebug({ required Logger logger, required ProcessManager processManager, - required Xcode xcode, - required FileSystem fileSystem, + required this._xcode, + required this._fileSystem, }) : _logger = logger, - _processUtils = ProcessUtils(logger: logger, processManager: processManager), - _xcode = xcode, - _fileSystem = fileSystem; + _processUtils = ProcessUtils(logger: logger, processManager: processManager); final ProcessUtils _processUtils; final Logger _logger; diff --git a/packages/flutter_tools/lib/src/ios/xcodeproj.dart b/packages/flutter_tools/lib/src/ios/xcodeproj.dart index 7f14e808..98080644 100644 --- a/packages/flutter_tools/lib/src/ios/xcodeproj.dart +++ b/packages/flutter_tools/lib/src/ios/xcodeproj.dart @@ -26,7 +26,18 @@ import '../xcode_project.dart'; final _settingExpr = RegExp(r'(\w+)\s*=\s*(.*)$'); final _varExpr = RegExp(r'\$\(([^)]*)\)'); -const kSwiftPackageCacheDirectoryName = 'SourcePackages'; + +/// Matches each `XCLocalSwiftPackageReference` object body in a +/// `project.pbxproj` file, capturing its `relativePath` value. +/// +/// Local Swift packages (added directly to the Xcode project rather than +/// fetched as a remote dependency) are recorded this way; unlike remote +/// checkouts, they never appear under the derived data `checkouts` +/// directory, so they must be located by parsing the project file directly. +final _localSwiftPackageReferenceExpr = RegExp( + r'isa\s*=\s*XCLocalSwiftPackageReference\s*;[^}]*relativePath\s*=\s*(?:"([^"]*)"|([^;]+))\s*;', + dotAll: true, +); /// Interpreter of Xcode projects. class XcodeProjectInterpreter { @@ -51,9 +62,9 @@ class XcodeProjectInterpreter { required ProcessManager processManager, required Logger logger, required FileSystem fileSystem, - required Analytics analytics, + required this._analytics, Version? version, - String? build, + this._build, }) : _platform = platform, _fileSystem = fileSystem, _logger = logger, @@ -65,9 +76,7 @@ class XcodeProjectInterpreter { processManager: processManager, ), _version = version, - _build = build, - _versionText = version?.toString(), - _analytics = analytics; + _versionText = version?.toString(); /// Create an [XcodeProjectInterpreter] for testing. /// @@ -189,7 +198,7 @@ class XcodeProjectInterpreter { Future> fetchDependenciesAndGenerateXcodebuildArgs( XcodeBasedProject xcodeProject, Directory buildDirectory, { - bool skipPackageUpdatesAndValidation = true, + bool skipPackageValidation = true, }) async { // All `xcodebuild` project commands will download and resolve Swift packages. // We should always prefetch Swift packages before running any `xcodebuild` project command @@ -198,7 +207,7 @@ class XcodeProjectInterpreter { return _xcodebuildProjectCommandArguments( buildDirectory, - skipPackageUpdatesAndValidation: skipPackageUpdatesAndValidation, + skipPackageValidation: skipPackageValidation, ); } @@ -209,11 +218,11 @@ class XcodeProjectInterpreter { /// Returns a list of required arguments for the `xcodebuild` Xcode project command. /// - /// When [skipPackageUpdatesAndValidation] is true, it uses arguments to attempt skipping any - /// Swift package updates and validation. + /// When [skipPackageValidation] is true, it uses arguments to attempt skipping any Swift + /// package validation. List _xcodebuildProjectCommandArguments( Directory buildDirectory, { - bool skipPackageUpdatesAndValidation = true, + bool skipPackageValidation = true, }) { final String cachePath = swiftPackageCachePath(buildDirectory); return [ @@ -221,8 +230,7 @@ class XcodeProjectInterpreter { 'xcodebuild', '-clonedSourcePackagesDirPath', cachePath, - if (skipPackageUpdatesAndValidation) ...[ - '-skipPackageUpdates', + if (skipPackageValidation) ...[ '-skipPackagePluginValidation', '-skipPackageSignatureValidation', ], @@ -395,9 +403,9 @@ class XcodeProjectInterpreter { await xcodeProject.prefetchSwiftPackages( xcodebuildProjectCommandArguments: _xcodebuildProjectCommandArguments( buildDirectory, - // skipPackageUpdatesAndValidation should be false so that when subsequent xcodebuild + // skipPackageValidation should be false so that when subsequent xcodebuild // commands run, packages should already be resolved, downloaded, updated, and validated. - skipPackageUpdatesAndValidation: false, + skipPackageValidation: false, ), processUtils: _processUtils, logger: _logger, @@ -416,29 +424,33 @@ class XcodeProjectInterpreter { // The exit code returned by 'xcodebuild -list' when the project is corrupted. const corruptedProjectExitCode = 74; bool allowedFailures(int c) => c == missingProjectExitCode || c == corruptedProjectExitCode; - final List xcodebuildCommandArgs = await fetchDependenciesAndGenerateXcodebuildArgs( - xcodeProject, - buildDirectory, - ); - final RunResult result = await _processUtils.run( - [ - ...xcodebuildCommandArgs, - '-list', - if (projectFilename != null) ...['-project', projectFilename], - ], - throwOnError: true, - allowedFailures: allowedFailures, - workingDirectory: xcodeProject.hostAppRoot.path, - ); - if (allowedFailures(result.exitCode)) { - // User configuration error, tool exit instead of crashing. - throwToolExit('Unable to get Xcode project information:\n ${result.stderr}'); - } - return XcodeProjectInfo.fromXcodeBuildOutput( - result.toString(), - _logger, - ignoredSchemes: await _ignoredSwiftPackageSchemes(xcodeProject, buildDirectory), - ); + try { + final List xcodebuildCommandArgs = await fetchDependenciesAndGenerateXcodebuildArgs( + xcodeProject, + buildDirectory, + ); + final RunResult result = await _processUtils.run( + [ + ...xcodebuildCommandArgs, + '-list', + if (projectFilename != null) ...['-project', projectFilename], + ], + throwOnError: true, + allowedFailures: allowedFailures, + workingDirectory: xcodeProject.hostAppRoot.path, + ); + if (allowedFailures(result.exitCode)) { + // User configuration error, tool exit instead of crashing. + throwToolExit('Unable to get Xcode project information:\n ${result.stderr}'); + } + return XcodeProjectInfo.fromXcodeBuildOutput( + result.toString(), + _logger, + ignoredSchemes: await _ignoredSwiftPackageSchemes(xcodeProject, buildDirectory), + ); + } on ProcessException catch (exception) { + throwToolExit('Unable to get Xcode project information:\n $exception'); + } } /// Returns scheme-name candidates for Swift packages that should be excluded from @@ -459,6 +471,7 @@ class XcodeProjectInterpreter { kFlutterGeneratedPluginSwiftPackageName, kFlutterGeneratedFrameworkSwiftPackageTargetName, ..._swiftPackageCheckoutSchemes(buildDirectory), + ..._localSwiftPackageSchemes(xcodeProject), }; try { for (final Plugin plugin in await xcodeProject.getPlugins()) { @@ -471,6 +484,60 @@ class XcodeProjectInterpreter { return ignoredSchemes; } + /// Returns scheme names contributed by local Swift package references + /// declared directly in the host project's `project.pbxproj`. + /// + /// Like remote checkouts (see [_swiftPackageCheckoutSchemes]), a local + /// Swift package that ships its own `.swiftpm/xcode/xcshareddata/xcschemes/` + /// directory has those schemes auto-merged into the host project's scheme + /// list by Xcode, despite not being declared as a scheme in the host + /// `.xcodeproj` itself. + Set _localSwiftPackageSchemes(XcodeBasedProject xcodeProject) { + final File pbxprojFile = xcodeProject.xcodeProjectInfoFile; + if (!pbxprojFile.existsSync()) { + return const {}; + } + final schemes = {}; + String contents; + try { + contents = pbxprojFile.readAsStringSync(); + } on FileSystemException catch (error) { + _logger.printTrace( + 'Failed to read ${pbxprojFile.path} while filtering Xcode schemes: $error', + ); + return const {}; + } + for (final RegExpMatch match in _localSwiftPackageReferenceExpr.allMatches(contents)) { + final String? relativePath = (match.group(1) ?? match.group(2))?.trim(); + if (relativePath == null || relativePath.isEmpty) { + continue; + } + final Directory packageDirectory = xcodeProject.hostAppRoot.childDirectory(relativePath); + schemes.addAll(_schemesFromSwiftPackageSchemeDirectory(packageDirectory)); + } + return schemes; + } + + /// Returns the names of any `.xcscheme` files found in [packageDirectory]'s + /// `.swiftpm/xcode/xcshareddata/xcschemes` directory, if present. + Set _schemesFromSwiftPackageSchemeDirectory(Directory packageDirectory) { + final Directory schemeDirectory = packageDirectory + .childDirectory('.swiftpm') + .childDirectory('xcode') + .childDirectory('xcshareddata') + .childDirectory('xcschemes'); + if (!schemeDirectory.existsSync()) { + return const {}; + } + final schemes = {}; + for (final File schemeFile in schemeDirectory.listSync().whereType()) { + if (_fileSystem.path.extension(schemeFile.path) == '.xcscheme') { + schemes.add(_fileSystem.path.basenameWithoutExtension(schemeFile.path)); + } + } + return schemes; + } + /// Returns scheme names contributed by direct and transitive Swift package checkouts. /// /// When a Swift package ships its own `.swiftpm/xcode/xcshareddata/xcschemes/` @@ -488,19 +555,7 @@ class XcodeProjectInterpreter { final schemes = {}; for (final Directory checkoutDirectory in checkoutsDirectory.listSync().whereType()) { - final Directory schemeDirectory = checkoutDirectory - .childDirectory('.swiftpm') - .childDirectory('xcode') - .childDirectory('xcshareddata') - .childDirectory('xcschemes'); - if (!schemeDirectory.existsSync()) { - continue; - } - for (final File schemeFile in schemeDirectory.listSync().whereType()) { - if (_fileSystem.path.extension(schemeFile.path) == '.xcscheme') { - schemes.add(_fileSystem.path.basenameWithoutExtension(schemeFile.path)); - } - } + schemes.addAll(_schemesFromSwiftPackageSchemeDirectory(checkoutDirectory)); } return schemes; } diff --git a/packages/flutter_tools/lib/src/isolated/devfs_web.dart b/packages/flutter_tools/lib/src/isolated/devfs_web.dart index bd8702f5..5c7e262f 100644 --- a/packages/flutter_tools/lib/src/isolated/devfs_web.dart +++ b/packages/flutter_tools/lib/src/isolated/devfs_web.dart @@ -45,12 +45,11 @@ class ConnectionResult { final vm_service.VmService vmService; } -typedef VmServiceFactory = - Future Function( - Uri, { - CompressionOptions compression, - required Logger logger, - }); +typedef VmServiceFactory = Future Function( + Uri, { + CompressionOptions compression, + required Logger logger, +}); /// The web specific DevFS implementation. class WebDevFS implements DevFS { @@ -71,8 +70,6 @@ class WebDevFS implements DevFS { required this.expressionCompiler, required this.chromiumLauncher, required this.nativeNullAssertions, - required this.ddcModuleSystem, - required this.canaryFeatures, required this.webDevServerConfig, required this.webRenderer, required this.isWasm, @@ -84,13 +81,8 @@ class WebDevFS implements DevFS { required this.logger, required this.platform, this.testMode = false, - Map webDefines = const {}, - }) : _webDefines = webDefines { - // TODO(srujzs): Remove this assertion when the library bundle format is - // supported without canary mode. - if (ddcModuleSystem) { - assert(canaryFeatures); - } + this._webDefines = const {}, + }) { _assetTransformer = DevelopmentAssetTransformer( transformer: AssetTransformer( processManager: globals.processManager, @@ -113,8 +105,6 @@ class WebDevFS implements DevFS { final bool enableDwds; final DartDevelopmentServiceConfiguration ddsConfig; final bool testMode; - final bool ddcModuleSystem; - final bool canaryFeatures; final ExpressionCompiler? expressionCompiler; final ChromiumLauncher? chromiumLauncher; final bool nativeNullAssertions; @@ -134,10 +124,6 @@ class WebDevFS implements DevFS { Dwds get dwds => webAssetServer.dwds; - /// Whether middleware should be enabled for this web development server. - /// Middleware is enabled when using Chrome device or DDC module system. - bool get shouldEnableMiddleware => chromiumLauncher != null || ddcModuleSystem; - // A flag to indicate whether we have called `setAssetDirectory` on the target device. @override bool hasSetAssetDirectory = false; @@ -229,20 +215,27 @@ class WebDevFS implements DevFS { isWasm: isWasm, useLocalCanvasKit: useLocalCanvasKit, testMode: testMode, - ddcModuleSystem: ddcModuleSystem, - canaryFeatures: canaryFeatures, webDevServerConfig: webDevServerConfig, useDwdsWebSocketConnection: useDwdsWebSocketConnection, fileSystem: fileSystem, logger: logger, platform: platform, crossOriginIsolation: webCrossOriginIsolation, - shouldEnableMiddleware: shouldEnableMiddleware, webDefines: _webDefines, ); return baseUri; } + /// Signal that the underlying web asset server is ready to handle requests. + /// + /// The HTTP server starts listening early during [create] so the port and URI + /// are known before compilation. However, incoming HTTP requests are held + /// until initial compilation completes and the DWDS connection listener is + /// registered so clients do not load incomplete assets or miss connection events. + void markReady() { + webAssetServer.markReady(); + } + @override Future destroy() async { await webAssetServer.dispose(); @@ -302,11 +295,7 @@ class WebDevFS implements DevFS { generator.addFileSystemRoot(outputDirectoryPath); final String entrypoint = fileSystem.path.basename(mainFile.path); webAssetServer.writeBytes(entrypoint, mainFile.readAsBytesSync()); - if (ddcModuleSystem) { - webAssetServer.writeBytes('ddc_module_loader.js', ddcModuleLoaderJS.readAsBytesSync()); - } else { - webAssetServer.writeBytes('require.js', requireJS.readAsBytesSync()); - } + webAssetServer.writeBytes('ddc_module_loader.js', ddcModuleLoaderJS.readAsBytesSync()); webAssetServer.writeBytes('flutter.js', flutterJs.readAsBytesSync()); webAssetServer.writeBytes('stack_trace_mapper.js', stackTraceMapper.readAsBytesSync()); webAssetServer.writeFile('manifest.json', '{"info":"manifest not generated in run mode."}'); @@ -317,38 +306,23 @@ class WebDevFS implements DevFS { webAssetServer.writeFile('version.json', FlutterProject.current().getVersionInfo()); webAssetServer.writeFile( 'main.dart.js', - ddcModuleSystem - ? generateDDCLibraryBundleBootstrapScript( - entrypoint: entrypoint, - ddcModuleLoaderUrl: 'ddc_module_loader.js', - mapperUrl: 'stack_trace_mapper.js', - generateLoadingIndicator: shouldEnableMiddleware, - isWindows: platform.isWindows, - ) - : generateBootstrapScript( - requireUrl: 'require.js', - mapperUrl: 'stack_trace_mapper.js', - generateLoadingIndicator: shouldEnableMiddleware, - ), + generateDDCLibraryBundleBootstrapScript( + ddcModuleLoaderUrl: 'ddc_module_loader.js', + mapperUrl: 'stack_trace_mapper.js', + generateLoadingIndicator: true, + isWindows: platform.isWindows, + ), ); const onLoadEndBootstrap = 'on_load_end_bootstrap.js'; - if (ddcModuleSystem) { - webAssetServer.writeFile(onLoadEndBootstrap, generateDDCLibraryBundleOnLoadEndBootstrap()); - } + webAssetServer.writeFile(onLoadEndBootstrap, generateDDCLibraryBundleOnLoadEndBootstrap()); webAssetServer.writeFile( 'main_module.bootstrap.js', - ddcModuleSystem - ? generateDDCLibraryBundleMainModule( - entrypoint: entrypoint, - nativeNullAssertions: nativeNullAssertions, - onLoadEndBootstrap: onLoadEndBootstrap, - isCi: platform.environment.containsKey(kLuciEnvName), - ) - : generateMainModule( - entrypoint: entrypoint, - nativeNullAssertions: nativeNullAssertions, - loaderRootDirectory: baseUri.toString(), - ), + generateDDCLibraryBundleMainModule( + entrypoint: entrypoint, + nativeNullAssertions: nativeNullAssertions, + onLoadEndBootstrap: onLoadEndBootstrap, + isCi: platform.environment.containsKey(kLuciEnvName), + ), ); } var syncedBytes = 0; @@ -393,9 +367,8 @@ class WebDevFS implements DevFS { return UpdateFSReport(); } if (dirtyEntries.isNotEmpty) { - await LocalDevFSWriter( - fileSystem: fileSystem, - ).write(dirtyEntries, fileSystem.path.toUri(assetDirectory)); + await LocalDevFSWriter(fileSystem: fileSystem) + .write(dirtyEntries, fileSystem.path.toUri(assetDirectory)); } } await _validateTemplateFile('index.html'); @@ -465,7 +438,7 @@ class WebDevFS implements DevFS { throwToolExit('Failed to load recompiled sources:\n$err'); } webAssetServer.updateModulesAndDigests(modules); - if (!bundleFirstUpload && ddcModuleSystem) { + if (!bundleFirstUpload) { webAssetServer.writeReloadedSources(modules); } return UpdateFSReport( @@ -475,20 +448,6 @@ class WebDevFS implements DevFS { ); } - @visibleForTesting - File get requireJS => fileSystem.file( - fileSystem.path.join( - globals.artifacts!.getArtifactPath( - Artifact.engineDartSdkPath, - platform: TargetPlatform.web_javascript, - ), - 'lib', - 'dev_compiler', - 'amd', - 'require.js', - ), - ); - @visibleForTesting File get ddcModuleLoaderJS => fileSystem.file( fileSystem.path.join( diff --git a/packages/flutter_tools/lib/src/isolated/native_assets/android/native_assets.dart b/packages/flutter_tools/lib/src/isolated/native_assets/android/native_assets.dart index 4d63584a..85021e93 100644 --- a/packages/flutter_tools/lib/src/isolated/native_assets/android/native_assets.dart +++ b/packages/flutter_tools/lib/src/isolated/native_assets/android/native_assets.dart @@ -3,14 +3,15 @@ // found in the LICENSE file. import 'package:code_assets/code_assets.dart'; -import 'package:hooks_runner/hooks_runner.dart'; import '../../../android/android_sdk.dart'; import '../../../android/gradle_utils.dart'; import '../../../base/common.dart'; import '../../../base/file_system.dart'; import '../../../build_info.dart'; +import '../../../context_runner.dart'; import '../native_assets.dart'; +import '../native_assets_manifest.dart'; int targetAndroidNdkApi(Map environmentDefines) { return int.parse(environmentDefines[kMinSdkVersion] ?? minSdkVersion); @@ -18,7 +19,7 @@ int targetAndroidNdkApi(Map environmentDefines) { Future> copyNativeCodeAssetsAndroid( Uri targetUri, - Map assetTargetLocations, + Map assetTargetLocations, FileSystem fileSystem, ) async { assert(assetTargetLocations.isNotEmpty); @@ -31,10 +32,11 @@ Future> copyNativeCodeAssetsAndroid( final Uri archUri = targetUri.resolve('jniLibs/lib/$jniArchDir/'); await fileSystem.directory(archUri).create(recursive: true); } - for (final MapEntry assetMapping in assetTargetLocations.entries) { + for (final MapEntry assetMapping + in assetTargetLocations.entries) { final Uri source = assetMapping.key.codeAsset.file!; - final Uri target = (assetMapping.value.path as KernelAssetAbsolutePath).uri; - final CpuArch cpuArch = _getAndroidArch(assetMapping.value.target.architecture); + final Uri target = assetMapping.value.bundlePath!; + final CpuArch cpuArch = _getAndroidArch(assetMapping.key.architecture); final String jniArchDir = cpuArch.androidArchName; final Uri archUri = targetUri.resolve('jniLibs/lib/$jniArchDir/'); final Uri assetTargetUri = archUri.resolveUri(target); @@ -68,35 +70,22 @@ CpuArch _getAndroidArch(Architecture architecture) { }; } -Map assetTargetLocationsAndroid( +Map assetTargetLocationsAndroid( List nativeAssets, ) { - return { - for (final FlutterCodeAsset asset in nativeAssets) asset: _targetLocationAndroid(asset), + return { + for (final FlutterCodeAsset asset in nativeAssets) + asset: targetLocationForCodeAsset(asset, (FlutterCodeAsset asset) { + final String fileName = asset.codeAsset.file!.pathSegments.last; + final uri = Uri(path: fileName); + return FlutterCodeAssetTargetLocation( + runtimePath: NativeAssetAbsolutePath(fileName), + bundlePath: uri, + ); + }), }; } -/// Converts the `path` of [asset] as output from a `build.dart` invocation to -/// the path used inside the Flutter app bundle. -KernelAsset _targetLocationAndroid(FlutterCodeAsset asset) { - final LinkMode linkMode = asset.codeAsset.linkMode; - final KernelAssetPath kernelAssetPath; - switch (linkMode) { - case DynamicLoadingSystem _: - kernelAssetPath = KernelAssetSystemPath(linkMode.uri); - case LookupInExecutable _: - kernelAssetPath = KernelAssetInExecutable(); - case LookupInProcess _: - kernelAssetPath = KernelAssetInProcess(); - case DynamicLoadingBundled _: - final String fileName = asset.codeAsset.file!.pathSegments.last; - kernelAssetPath = KernelAssetAbsolutePath(Uri(path: fileName)); - default: - throw Exception('Unsupported asset link mode $linkMode in asset $asset'); - } - return KernelAsset(id: asset.codeAsset.id, target: asset.target, path: kernelAssetPath); -} - /// Looks the NDK clang compiler tools. /// /// Returns `null` if the NDK cannot be found. @@ -104,7 +93,9 @@ KernelAsset _targetLocationAndroid(FlutterCodeAsset asset) { /// Typically the Flutter Gradle Plugin will install an NDK. This method will /// return the newest NDK if multiple NDKs are found on the system. Future cCompilerConfigAndroid() async { - final AndroidSdk? androidSdk = AndroidSdk.locateAndroidSdk(); + final AndroidSdk? androidSdk = AndroidSdk.locateAndroidSdk( + toolContext: const GlobalToolContext(), + ); if (androidSdk == null) { throwToolExit('Android SDK could not be found.'); } diff --git a/packages/flutter_tools/lib/src/isolated/native_assets/dart_hook_result.dart b/packages/flutter_tools/lib/src/isolated/native_assets/dart_hook_result.dart index 25d5b115..f9c4abc5 100644 --- a/packages/flutter_tools/lib/src/isolated/native_assets/dart_hook_result.dart +++ b/packages/flutter_tools/lib/src/isolated/native_assets/dart_hook_result.dart @@ -5,7 +5,7 @@ import 'package:code_assets/code_assets.dart'; import 'package:data_assets/data_assets.dart'; import 'package:hooks/hooks.dart'; -import 'package:hooks_runner/hooks_runner.dart'; + import '../../asset.dart' show FlutterHookResult, HookAsset; import 'native_assets.dart' show FlutterCodeAsset; @@ -44,18 +44,21 @@ final class DartHooksResult { Uri.parse(encodedUri! as String), ]; final codeAssets = [ - for (final (Map codeAsset, String target) in codeAssetsList.map( + for (final (Map codeAsset, String os, String arch) in codeAssetsList.map( (Object? codeJson) => switch (codeJson) { - {_assetKey: final Map codeAsset, _targetKey: final String target} => ( - codeAsset, - target, - ), + { + _assetKey: final Map codeAsset, + _osKey: final String os, + _archKey: final String arch, + } => + (codeAsset, os, arch), _ => throw UnimplementedError(), }, )) FlutterCodeAsset( codeAsset: CodeAsset.fromEncoded(EncodedAsset.fromJson(codeAsset)), - target: Target.fromString(target), + os: OS.fromString(os), + architecture: Architecture.fromString(arch), ), ]; final dataAssets = [ @@ -92,7 +95,8 @@ final class DartHooksResult { for (final FlutterCodeAsset code in codeAssets) { _assetKey: code.codeAsset.encode().toJson(), - _targetKey: code.target.toString(), + _osKey: code.os.name, + _archKey: code.architecture.name, }, ], _dataAssetsKey: [for (final DataAsset asset in dataAssets) asset.encode().toJson()], @@ -104,7 +108,8 @@ final class DartHooksResult { static const _codeAssetsKey = 'code_assets'; static const _dataAssetsKey = 'data_assets'; static const _assetKey = 'asset'; - static const _targetKey = 'target'; + static const _osKey = 'os'; + static const _archKey = 'arch'; /// The files that eventually should be bundled with the app. List get filesToBeBundled => [ diff --git a/packages/flutter_tools/lib/src/isolated/native_assets/ios/native_assets.dart b/packages/flutter_tools/lib/src/isolated/native_assets/ios/native_assets.dart index 68680a92..6772be73 100644 --- a/packages/flutter_tools/lib/src/isolated/native_assets/ios/native_assets.dart +++ b/packages/flutter_tools/lib/src/isolated/native_assets/ios/native_assets.dart @@ -3,15 +3,15 @@ // found in the LICENSE file. import 'package:code_assets/code_assets.dart'; -import 'package:hooks_runner/hooks_runner.dart'; import '../../../base/file_system.dart'; import '../../../build_info.dart'; import '../macos/native_assets_host.dart'; import '../native_assets.dart'; +import '../native_assets_manifest.dart'; // TODO(dcharkes): Fetch minimum iOS version from somewhere. https://github.com/flutter/flutter/issues/145104 -const targetIOSVersion = 13; +const targetIOSVersion = 15; IOSSdk getIOSSdk(EnvironmentType environmentType) { return switch (environmentType) { @@ -34,47 +34,14 @@ Architecture getNativeIOSArchitecture(CpuArch cpuArch) { /// Groups native assets by their target framework path for iOS /// multi-architecture bundling. -Map> fatAssetTargetLocationsIOS( - List nativeAssets, -) { - return fatAssetTargetLocations( - nativeAssets, - (FlutterCodeAsset asset, Set alreadyTakenNames) => - _targetLocationIOS(asset, alreadyTakenNames), - ); -} - -Map assetTargetLocationsIOS(List nativeAssets) { - final alreadyTakenNames = {}; - final idToPath = {}; - final result = {}; - for (final asset in nativeAssets) { - final String assetId = asset.codeAsset.id; - final KernelAssetPath path = - idToPath[assetId] ?? _targetLocationIOS(asset, alreadyTakenNames).path; - idToPath[assetId] = path; - result[asset] = KernelAsset(id: assetId, target: asset.target, path: path); - } - return result; +Map> fatAssetTargetLocationsIOS(List nativeAssets) { + return fatAssetTargetLocations(assetTargetLocationsIOS(nativeAssets)); } -KernelAsset _targetLocationIOS(FlutterCodeAsset asset, Set alreadyTakenNames) { - final LinkMode linkMode = asset.codeAsset.linkMode; - final KernelAssetPath kernelAssetPath; - switch (linkMode) { - case DynamicLoadingSystem _: - kernelAssetPath = KernelAssetSystemPath(linkMode.uri); - case LookupInExecutable _: - kernelAssetPath = KernelAssetInExecutable(); - case LookupInProcess _: - kernelAssetPath = KernelAssetInProcess(); - case DynamicLoadingBundled _: - final String fileName = asset.codeAsset.file!.pathSegments.last; - kernelAssetPath = KernelAssetAbsolutePath(frameworkUri(fileName, alreadyTakenNames)); - default: - throw Exception('Unsupported asset link mode $linkMode in asset $asset'); - } - return KernelAsset(id: asset.codeAsset.id, target: asset.target, path: kernelAssetPath); +Map assetTargetLocationsIOS( + List nativeAssets, +) { + return assetTargetLocationsApple(nativeAssets); } /// Copies native assets into a framework per dynamic library. @@ -91,7 +58,7 @@ KernelAsset _targetLocationIOS(FlutterCodeAsset asset, Set alreadyTakenN /// in xcode_backend.dart. Future> copyNativeCodeAssetsIOS( Uri targetUri, - Map> assetTargetLocations, + Map> assetTargetLocations, String? codesignIdentity, BuildMode buildMode, FileSystem fileSystem, @@ -101,9 +68,8 @@ Future> copyNativeCodeAssetsIOS( final oldToNewInstallNames = {}; final dylibs = <(File, String, Directory)>[]; - for (final MapEntry> assetMapping - in assetTargetLocations.entries) { - final Uri target = (assetMapping.key as KernelAssetAbsolutePath).uri; + for (final MapEntry> assetMapping in assetTargetLocations.entries) { + final Uri target = assetMapping.key; final sources = [ for (final FlutterCodeAsset source in assetMapping.value) fileSystem.file(source.codeAsset.file), @@ -126,8 +92,7 @@ Future> copyNativeCodeAssetsIOS( ); } - final String dylibFileName = dylibFile.basename; - final newInstallName = '@rpath/$dylibFileName.framework/$dylibFileName'; + final String newInstallName = frameworkInstallName(target); final Set oldInstallNames = await getInstallNamesDylib(dylibFile); for (final oldInstallName in oldInstallNames) { oldToNewInstallNames[oldInstallName] = newInstallName; diff --git a/packages/flutter_tools/lib/src/isolated/native_assets/linux/native_assets.dart b/packages/flutter_tools/lib/src/isolated/native_assets/linux/native_assets.dart index 546889ec..4bc67433 100644 --- a/packages/flutter_tools/lib/src/isolated/native_assets/linux/native_assets.dart +++ b/packages/flutter_tools/lib/src/isolated/native_assets/linux/native_assets.dart @@ -7,7 +7,6 @@ import 'package:code_assets/code_assets.dart'; import '../../../base/common.dart'; import '../../../base/file_system.dart'; import '../../../convert.dart'; -import '../../../globals.dart' as globals; /// Returns a [CCompilerConfig] suitable for compiling code assets for Linux apps. /// @@ -27,6 +26,8 @@ Future cCompilerConfigLinux({ return null; } + final FileSystem fileSystem = cmakeDirectory.fileSystem; + // For app builds, use the same compiler as the native/GTK parts of the app. final File cmakeCacheTxt = cmakeDirectory.childFile('CMakeCache.txt'); if (!cmakeCacheTxt.existsSync()) { @@ -72,7 +73,7 @@ Future cCompilerConfigLinux({ throwToolExit('Expected ${cmakeCacheTxt.path} to contain an entry for $variableName'); } - final File file = globals.fs.file(found); + final File file = fileSystem.file(found); if (!file.existsSync()) { throwToolExit( 'Expected ${file.path} (read from $variableName in ${cmakeCacheTxt.path}) to exist.', @@ -84,8 +85,8 @@ Future cCompilerConfigLinux({ try { // Find clang next to the clang++ we use in CMake - File clangPpFile = globals.fs.file(requireTool(cxxCompiler, compilerVariable)); - clangPpFile = globals.fs.file(await clangPpFile.resolveSymbolicLinks()); + File clangPpFile = fileSystem.file(requireTool(cxxCompiler, compilerVariable)); + clangPpFile = fileSystem.file(await clangPpFile.resolveSymbolicLinks()); final File clangFile = clangPpFile.parent.childFile('clang'); if (!clangFile.existsSync()) { throwToolExit('Expected to find clang next to ${clangPpFile.path}'); diff --git a/packages/flutter_tools/lib/src/isolated/native_assets/macos/native_assets.dart b/packages/flutter_tools/lib/src/isolated/native_assets/macos/native_assets.dart index 4a1d0640..cf627b26 100644 --- a/packages/flutter_tools/lib/src/isolated/native_assets/macos/native_assets.dart +++ b/packages/flutter_tools/lib/src/isolated/native_assets/macos/native_assets.dart @@ -3,11 +3,11 @@ // found in the LICENSE file. import 'package:code_assets/code_assets.dart'; -import 'package:hooks_runner/hooks_runner.dart'; import '../../../base/file_system.dart'; import '../../../build_info.dart'; import '../native_assets.dart'; +import '../native_assets_manifest.dart'; import 'native_assets_host.dart'; // TODO(dcharkes): Fetch minimum MacOS version from somewhere. https://github.com/flutter/flutter/issues/145104 @@ -27,65 +27,18 @@ Architecture getNativeMacOSArchitecture(CpuArch cpuArch) { /// Groups native assets by their target framework path for macOS /// multi-architecture bundling. -Map> fatAssetTargetLocationsMacOS( +Map> fatAssetTargetLocationsMacOS( List nativeAssets, Uri? absolutePath, ) { - return fatAssetTargetLocations( - nativeAssets, - (FlutterCodeAsset asset, Set alreadyTakenNames) => - _targetLocationMacOS(asset, absolutePath, alreadyTakenNames), - ); + return fatAssetTargetLocations(assetTargetLocationsMacOS(nativeAssets, absolutePath)); } -Map assetTargetLocationsMacOS( +Map assetTargetLocationsMacOS( List nativeAssets, Uri? absolutePath, ) { - final alreadyTakenNames = {}; - final idToPath = {}; - final result = {}; - for (final asset in nativeAssets) { - final String assetId = asset.codeAsset.id; - final KernelAssetPath path = - idToPath[assetId] ?? _targetLocationMacOS(asset, absolutePath, alreadyTakenNames).path; - idToPath[assetId] = path; - result[asset] = KernelAsset(id: assetId, target: asset.target, path: path); - } - return result; -} - -KernelAsset _targetLocationMacOS( - FlutterCodeAsset asset, - Uri? absolutePath, - Set alreadyTakenNames, -) { - final LinkMode linkMode = asset.codeAsset.linkMode; - final KernelAssetPath kernelAssetPath; - switch (linkMode) { - case DynamicLoadingSystem _: - kernelAssetPath = KernelAssetSystemPath(linkMode.uri); - case LookupInExecutable _: - kernelAssetPath = KernelAssetInExecutable(); - case LookupInProcess _: - kernelAssetPath = KernelAssetInProcess(); - case DynamicLoadingBundled _: - final String fileName = asset.codeAsset.file!.pathSegments.last; - Uri uri; - if (absolutePath != null) { - // Flutter tester needs full host paths. - uri = absolutePath.resolve(fileName); - } else { - // Flutter Desktop needs "absolute" paths inside the app. - // "relative" in the context of native assets would be relative to the - // kernel or aot snapshot. - uri = frameworkUri(fileName, alreadyTakenNames); - } - kernelAssetPath = KernelAssetAbsolutePath(uri); - default: - throw Exception('Unsupported asset link mode $linkMode in asset $asset'); - } - return KernelAsset(id: asset.codeAsset.id, target: asset.target, path: kernelAssetPath); + return assetTargetLocationsApple(nativeAssets, absolutePath: absolutePath); } /// Copies native assets into a framework per dynamic library. @@ -105,7 +58,7 @@ KernelAsset _targetLocationMacOS( /// in macos_assemble.sh. Future> copyNativeCodeAssetsMacOS( Uri targetUri, - Map> assetTargetLocations, + Map> assetTargetLocations, String? codesignIdentity, BuildMode buildMode, FileSystem fileSystem, @@ -115,9 +68,8 @@ Future> copyNativeCodeAssetsMacOS( final oldToNewInstallNames = {}; final dylibs = <(File, String, Directory)>[]; - for (final MapEntry> assetMapping - in assetTargetLocations.entries) { - final Uri target = (assetMapping.key as KernelAssetAbsolutePath).uri; + for (final MapEntry> assetMapping in assetTargetLocations.entries) { + final Uri target = assetMapping.key; final sources = [ for (final FlutterCodeAsset source in assetMapping.value) fileSystem.file(source.codeAsset.file), @@ -172,8 +124,7 @@ Future> copyNativeCodeAssetsMacOS( ), ); - final String dylibFileName = dylibFile.basename; - final newInstallName = '@rpath/$dylibFileName.framework/$dylibFileName'; + final String newInstallName = frameworkInstallName(target); final Set oldInstallNames = await getInstallNamesDylib(dylibFile); for (final oldInstallName in oldInstallNames) { oldToNewInstallNames[oldInstallName] = newInstallName; @@ -209,7 +160,7 @@ Future> copyNativeCodeAssetsMacOS( /// Code signing is also done here. Future> copyNativeCodeAssetsMacOSFlutterTester( Uri targetUri, - Map> assetTargetLocations, + Map> assetTargetLocations, String? codesignIdentity, BuildMode buildMode, FileSystem fileSystem, @@ -219,9 +170,8 @@ Future> copyNativeCodeAssetsMacOSFlutterTester( final oldToNewInstallNames = {}; final dylibs = <(File, String)>[]; - for (final MapEntry> assetMapping - in assetTargetLocations.entries) { - final Uri target = (assetMapping.key as KernelAssetAbsolutePath).uri; + for (final MapEntry> assetMapping in assetTargetLocations.entries) { + final Uri target = assetMapping.key; final sources = [ for (final FlutterCodeAsset source in assetMapping.value) fileSystem.file(source.codeAsset.file), 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 d307818d..da97c464 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 @@ -5,7 +5,6 @@ // Shared logic between iOS and macOS implementations of native assets. import 'package:code_assets/code_assets.dart'; -import 'package:hooks_runner/hooks_runner.dart'; import '../../../base/common.dart'; import '../../../base/file_system.dart'; @@ -14,6 +13,7 @@ import '../../../build_info.dart'; import '../../../build_system/targets/darwin.dart'; import '../../../globals.dart' as globals; import '../native_assets.dart'; +import '../native_assets_manifest.dart'; /// Create an `Info.plist` in [target] for a framework with a single dylib. /// @@ -267,10 +267,7 @@ Uri frameworkUri(String fileName, Set alreadyTakenNames) { if (isDylib && fileName.startsWith('lib')) { fileName = fileName.replaceFirst('lib', ''); } - fileName = fileName.replaceAll( - RegExp(r'[^A-Za-z0-9._-]'), - '', - ); // Allow period chars in framework names, as per Apple's syntax rules (fixes issue https://github.com/dart-lang/native/issues/3268#issue-4152803855) + fileName = fileName.replaceAll(RegExp(r'[^A-Za-z0-9._-]'), ''); // Allow period chars in framework names, as per Apple's syntax rules (fixes issue https://github.com/dart-lang/native/issues/3268#issue-4152803855) if (alreadyTakenNames.contains(fileName)) { final prefixName = fileName; for (var i = 1; i < 1000; i++) { @@ -287,6 +284,17 @@ Uri frameworkUri(String fileName, Set alreadyTakenNames) { return Uri(path: '$fileName.framework/$fileName'); } +/// The install name stamped into the framework bundled at [frameworkUri], which +/// is also the name it has to be loaded with at runtime. +/// +/// `dlopen` recognizes a library it has already loaded by the name it is opened +/// with, and only falls back to identifying the file on disk when that name +/// matches no install name it knows. A rebuild replaces that file underneath a +/// debug instance that is still running, so opening an asset by anything other +/// than its install name maps a second copy of it into the process, and the two +/// copies do not share the library's global state. +String frameworkInstallName(Uri frameworkUri) => '@rpath/${frameworkUri.path}'; + Map> parseOtoolArchitectureSections(String output) { // The output of `otool -D`, for example, looks like below. For each // architecture, there is a separate section. @@ -336,8 +344,8 @@ Map> parseOtoolArchitectureSections(String output) { return architectureSections; } -/// Groups native assets by their target framework path for multi-architecture -/// bundling. +/// Computes the [FlutterCodeAssetTargetLocation] for each [FlutterCodeAsset] on +/// Apple platforms (macOS and iOS). /// /// On macOS and iOS, architecture-specific binaries for the same Asset ID are /// combined into a single "fat" (universal) binary using `lipo`. This function @@ -346,27 +354,45 @@ Map> parseOtoolArchitectureSections(String output) { /// If different architectures for the same Asset ID have different framework /// names, a warning is issued, and the name of the first encountered /// architecture is used. -Map> fatAssetTargetLocations( - List nativeAssets, - KernelAsset Function(FlutterCodeAsset asset, Set alreadyTakenNames) - targetLocationCallback, -) { - final alreadyTakenNamesPerTarget = >{}; - final result = >{}; - final idToPath = {}; +Map assetTargetLocationsApple( + List nativeAssets, { + Uri? absolutePath, +}) { + final alreadyTakenNamesPerArch = >{}; + final idToLocation = {}; + final result = {}; + 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 FlutterCodeAssetTargetLocation? existingLocation = idToLocation[assetId]; + final Set alreadyTakenNames = alreadyTakenNamesPerArch.putIfAbsent( + asset.architecture, () => {}, ); - final KernelAssetPath currentPath = targetLocationCallback(asset, alreadyTakenNames).path; + final FlutterCodeAssetTargetLocation currentLocation = targetLocationForCodeAsset(asset, ( + FlutterCodeAsset asset, + ) { + final String fileName = asset.codeAsset.file!.pathSegments.last; + if (absolutePath != null) { + // Flutter tester needs full host paths. + return FlutterCodeAssetTargetLocation( + runtimePath: NativeAssetAbsolutePath.fromFileUri(absolutePath.resolve(fileName)), + bundlePath: Uri(path: fileName), + ); + } + final Uri bundlePath = frameworkUri(fileName, alreadyTakenNames); + return FlutterCodeAssetTargetLocation( + runtimePath: NativeAssetAbsolutePath(frameworkInstallName(bundlePath)), + bundlePath: bundlePath, + ); + }); - if (existingPath != null && existingPath != currentPath) { - final String existingName = (existingPath as KernelAssetAbsolutePath).uri.pathSegments.first; - final String currentName = (currentPath as KernelAssetAbsolutePath).uri.pathSegments.first; + if (existingLocation != null && + existingLocation.bundlePath != null && + currentLocation.bundlePath != null && + existingLocation.bundlePath != currentLocation.bundlePath) { + final String existingName = existingLocation.bundlePath!.pathSegments.first; + final String currentName = currentLocation.bundlePath!.pathSegments.first; printXcodeWarning( 'Code asset "$assetId" has different framework names for ' 'different architectures. Picking "$existingName" and ' @@ -377,10 +403,24 @@ Map> fatAssetTargetLocations( ); } - final KernelAssetPath path = existingPath ?? currentPath; - idToPath[assetId] = path; - result[path] ??= []; - result[path]!.add(asset); + final FlutterCodeAssetTargetLocation location = existingLocation ?? currentLocation; + idToLocation[assetId] = location; + result[asset] = location; + } + return result; +} + +/// Groups bundled native assets by their target bundle [Uri] for universal +/// binary (`lipo`) creation on macOS and iOS. +Map> fatAssetTargetLocations( + Map assetTargetLocations, +) { + final result = >{}; + for (final MapEntry(key: asset, value: location) + in assetTargetLocations.entries) { + if (location.bundlePath case final Uri bundlePath) { + (result[bundlePath] ??= []).add(asset); + } } return result; } 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 f021e638..2ea21a04 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 @@ -19,29 +19,34 @@ import '../../base/platform.dart'; import '../../build_info.dart'; import '../../build_system/exceptions.dart'; import '../../cache.dart'; -import '../../convert.dart'; import '../../features.dart'; import '../../globals.dart' as globals; import 'android/native_assets.dart'; import 'dart_hook_result.dart'; import 'ios/native_assets.dart'; import 'macos/native_assets.dart'; +import 'macos/native_assets_host.dart'; +import 'native_assets_manifest.dart'; import 'targets.dart'; /// The serialized results of running build hooks for all packages. typedef SerializedBuildResults = Map>; -/// A [CodeAsset] for a specific [target]. +/// A [CodeAsset] for a specific target [os] and [architecture]. /// /// Flutter builds [CodeAsset]s for multiple architectures (on MacOS and iOS). /// This class distinguishes the (otherwise identical) [codeAsset]s on different -/// [target]s. These are then later combined into a single [KernelAsset] before -/// being added to the native assets manifest. +/// architectures. class FlutterCodeAsset { - FlutterCodeAsset({required this.codeAsset, required this.target}); + FlutterCodeAsset({required this.codeAsset, required this.os, required this.architecture}); final CodeAsset codeAsset; - final Target target; + final OS os; + final Architecture architecture; + + /// The target string identifier (e.g. `macos_arm64`, `android_arm64`) used + /// in `NativeAssetsManifest.json`. + String get targetString => '${os.name}_${architecture.name}'; } /// Matching [CodeAsset] and [DataAsset] in native assets - but Flutter could @@ -430,7 +435,8 @@ void _decodeAssets({ if (target is CodeAssetTarget) { final Iterable filteredCode = _filterCodeAssets( encodedAssets, - Target.fromArchitectureAndOS(target.architecture, target.os), + os: target.os, + architecture: target.architecture, ); codeAssetsAccumulator?.addAll(filteredCode); } @@ -460,10 +466,10 @@ void _checkForDuplicateAssets({ ); } - final codeAssetIds = <(String, Target)>{}; + final codeAssetIds = <(String, OS, Architecture)>{}; final duplicateCodeAssetIds = {}; for (final asset in codeAssets) { - if (!codeAssetIds.add((asset.codeAsset.id, asset.target))) { + if (!codeAssetIds.add((asset.codeAsset.id, asset.os, asset.architecture))) { duplicateCodeAssetIds.add(asset.codeAsset.id); } } @@ -490,12 +496,8 @@ Future> installCodeAssets({ final BuildMode buildMode = _getBuildMode(environmentDefines, flutterTester); final String? codesignIdentity = environmentDefines[kCodesignIdentity]; - final Map assetTargetLocations = assetTargetLocationsForOS( - targetOS, - dartHookResult.codeAssets, - flutterTester, - targetUri, - ); + final Map assetTargetLocations = + assetTargetLocationsForOS(targetOS, dartHookResult.codeAssets, flutterTester, targetUri); final List installedFiles = await _copyNativeCodeAssetsForOS( targetOS, targetUri, @@ -505,11 +507,8 @@ Future> installCodeAssets({ codesignIdentity, flutterTester, ); - await _writeNativeAssetsJson( - assetTargetLocations.values.toList(), - nativeAssetsFileUri, - fileSystem, - ); + final manifest = NativeAssetsManifest.fromTargetLocations(assetTargetLocations); + await _writeNativeAssetsJson(manifest, nativeAssetsFileUri, fileSystem); return [fileSystem.file(nativeAssetsFileUri), ...installedFiles]; } @@ -544,6 +543,7 @@ class FlutterNativeAssetsBuildRunnerImpl implements FlutterNativeAssetsBuildRunn this.packageConfig, this.fileSystem, this.logger, + this.platform, this.runPackageName, this.pubspecPath, { required this.includeDevDependencies, @@ -554,6 +554,7 @@ class FlutterNativeAssetsBuildRunnerImpl implements FlutterNativeAssetsBuildRunn final PackageConfig packageConfig; final FileSystem fileSystem; final Logger logger; + final Platform platform; final String runPackageName; /// Include the dev dependencies of [runPackageName]. @@ -582,7 +583,7 @@ class FlutterNativeAssetsBuildRunnerImpl implements FlutterNativeAssetsBuildRunn late final Uri _dartExecutable = fileSystem .directory(Cache.flutterRoot) .uri - .resolve('bin/cache/dart-sdk/bin/dart'); + .resolve('bin/cache/dart-sdk/bin/dart${platform.isWindows ? '.exe' : ''}'); late final packageLayout = PackageLayout.fromPackageConfig( fileSystem, @@ -647,12 +648,12 @@ class FlutterNativeAssetsBuildRunnerImpl implements FlutterNativeAssetsBuildRunn } Future _writeNativeAssetsJson( - List assets, + NativeAssetsManifest manifest, Uri nativeAssetsJsonUri, FileSystem fileSystem, ) async { globals.logger.printTrace('Writing native assets json to $nativeAssetsJsonUri.'); - final String nativeAssetsDartContents = _toNativeAssetsJsonFile(assets); + final String nativeAssetsDartContents = manifest.toJsonString(); final File nativeAssetsFile = fileSystem.file(nativeAssetsJsonUri); final Directory parentDirectory = nativeAssetsFile.parent; if (!parentDirectory.existsSync()) { @@ -663,29 +664,6 @@ Future _writeNativeAssetsJson( return nativeAssetsFile.uri; } -String _toNativeAssetsJsonFile(List kernelAssets) { - final assetsPerTarget = >{}; - for (final asset in kernelAssets) { - assetsPerTarget.putIfAbsent(asset.target, () => []).add(asset); - } - - const formatVersionKey = 'format-version'; - const nativeAssetsKey = 'native-assets'; - - // See assets/native_assets.cc in the engine for the expected format. - final jsonContents = { - formatVersionKey: const [1, 0, 0], - nativeAssetsKey: >>{ - for (final MapEntry> entry in assetsPerTarget.entries) - entry.key.toString(): >{ - for (final KernelAsset e in entry.value) e.id: e.path.toJson(), - }, - }, - }; - - return jsonEncode(jsonContents); -} - /// Whether link hooks should be run. /// /// Link hooks should only be run for AOT Dart builds, which is the non-debug @@ -746,66 +724,38 @@ Future ensureNoNativeAssetsOrOsIsSupported( ); } -Map _assetTargetLocationsWindowsLinux( +Map _assetTargetLocationsWindowsLinux( List assets, Uri? absolutePath, ) { - return { + return { for (final FlutterCodeAsset asset in assets) - asset: _targetLocationSingleArchitecture(asset, absolutePath), + asset: targetLocationForCodeAsset(asset, (FlutterCodeAsset asset) { + final String fileName = asset.codeAsset.file!.pathSegments.last; + final bundlePath = Uri(path: fileName); + final Uri runtimeUri = absolutePath != null ? absolutePath.resolve(fileName) : bundlePath; + return FlutterCodeAssetTargetLocation( + runtimePath: NativeAssetAbsolutePath.fromFileUri(runtimeUri), + bundlePath: bundlePath, + ); + }), }; } -KernelAsset _targetLocationSingleArchitecture(FlutterCodeAsset asset, Uri? absolutePath) { - final LinkMode linkMode = asset.codeAsset.linkMode; - final KernelAssetPath kernelAssetPath; - switch (linkMode) { - case DynamicLoadingSystem _: - kernelAssetPath = KernelAssetSystemPath(linkMode.uri); - case LookupInExecutable _: - kernelAssetPath = KernelAssetInExecutable(); - case LookupInProcess _: - kernelAssetPath = KernelAssetInProcess(); - case DynamicLoadingBundled _: - final String fileName = asset.codeAsset.file!.pathSegments.last; - Uri uri; - if (absolutePath != null) { - // Flutter tester needs full host paths. - uri = absolutePath.resolve(fileName); - } else { - // Flutter Desktop needs "absolute" paths inside the app. - // "relative" in the context of native assets would be relative to the - // kernel or aot snapshot. - uri = Uri(path: fileName); - } - kernelAssetPath = KernelAssetAbsolutePath(uri); - default: - throw Exception('Unsupported asset link mode ${linkMode.runtimeType} in asset $asset'); - } - return KernelAsset(id: asset.codeAsset.id, target: asset.target, path: kernelAssetPath); -} - -Map assetTargetLocationsForOS( +Map assetTargetLocationsForOS( OS targetOS, List codeAssets, bool flutterTester, Uri buildUri, ) { - switch (targetOS) { - case OS.windows: - case OS.linux: - final Uri? absolutePath = flutterTester ? buildUri : null; - return _assetTargetLocationsWindowsLinux(codeAssets, absolutePath); - case OS.macOS: - final Uri? absolutePath = flutterTester ? buildUri : null; - return assetTargetLocationsMacOS(codeAssets, absolutePath); - case OS.iOS: - return assetTargetLocationsIOS(codeAssets); - case OS.android: - return assetTargetLocationsAndroid(codeAssets); - default: - throw UnimplementedError('This should be unreachable.'); - } + final Uri? absolutePath = flutterTester ? buildUri : null; + return switch (targetOS) { + OS.windows || OS.linux => _assetTargetLocationsWindowsLinux(codeAssets, absolutePath), + OS.macOS => assetTargetLocationsMacOS(codeAssets, absolutePath), + OS.iOS => assetTargetLocationsIOS(codeAssets), + OS.android => assetTargetLocationsAndroid(codeAssets), + _ => throw UnimplementedError('This should be unreachable.'), + }; } Future> _copyNativeCodeAssetsForOS( @@ -813,7 +763,7 @@ Future> _copyNativeCodeAssetsForOS( Uri targetUri, BuildMode buildMode, FileSystem fileSystem, - Map assetTargetLocations, + Map assetTargetLocations, String? codesignIdentity, bool flutterTester, ) async { @@ -821,7 +771,7 @@ Future> _copyNativeCodeAssetsForOS( // If a code asset that use a linking mode of [LookupInProcess], // [LookupInExecutable] or [DynamicLoadingSystem] do not have anything to // bundle as part of the app. - assetTargetLocations = { + assetTargetLocations = { for (final FlutterCodeAsset codeAsset in assetTargetLocations.keys) if (codeAsset.codeAsset.linkMode is DynamicLoadingBundled) codeAsset: assetTargetLocations[codeAsset]!, @@ -840,7 +790,6 @@ Future> _copyNativeCodeAssetsForOS( } globals.logger.printTrace('Copying native assets to ${targetUri.toFilePath()}.'); - final List codeAssets = assetTargetLocations.keys.toList(); final List installedFiles; switch (targetOS) { case OS.windows: @@ -856,7 +805,7 @@ Future> _copyNativeCodeAssetsForOS( if (flutterTester) { installedFiles = await copyNativeCodeAssetsMacOSFlutterTester( targetUri, - fatAssetTargetLocationsMacOS(codeAssets, targetUri), + fatAssetTargetLocations(assetTargetLocations), codesignIdentity, buildMode, fileSystem, @@ -864,7 +813,7 @@ Future> _copyNativeCodeAssetsForOS( } else { installedFiles = await copyNativeCodeAssetsMacOS( targetUri, - fatAssetTargetLocationsMacOS(codeAssets, null), + fatAssetTargetLocations(assetTargetLocations), codesignIdentity, buildMode, fileSystem, @@ -873,7 +822,7 @@ Future> _copyNativeCodeAssetsForOS( case OS.iOS: installedFiles = await copyNativeCodeAssetsIOS( targetUri, - fatAssetTargetLocationsIOS(codeAssets), + fatAssetTargetLocations(assetTargetLocations), codesignIdentity, buildMode, fileSystem, @@ -892,11 +841,15 @@ Future> _copyNativeCodeAssetsForOS( return installedFiles; } -Iterable _filterCodeAssets(Iterable assets, Target target) => assets +Iterable _filterCodeAssets( + Iterable assets, { + required OS os, + required Architecture architecture, +}) => assets .where((EncodedAsset asset) => asset.isCodeAsset) .map( (EncodedAsset encodedAsset) => - FlutterCodeAsset(codeAsset: encodedAsset.asCodeAsset, target: target), + FlutterCodeAsset(codeAsset: encodedAsset.asCodeAsset, os: os, architecture: architecture), ); Iterable _filterDataAssets(Iterable assets) => @@ -936,16 +889,17 @@ Future _link( Future> _copyNativeCodeAssetsToBundleOnWindowsLinux( Uri targetUri, - Map assetTargetLocations, + Map assetTargetLocations, BuildMode buildMode, FileSystem fileSystem, ) async { assert(assetTargetLocations.isNotEmpty); final installedFiles = []; - for (final MapEntry assetMapping in assetTargetLocations.entries) { + for (final MapEntry assetMapping + in assetTargetLocations.entries) { final Uri source = assetMapping.key.codeAsset.file!; - final Uri target = (assetMapping.value.path as KernelAssetAbsolutePath).uri; + final Uri target = assetMapping.value.bundlePath!; final Uri assetTargetUri = targetUri.resolveUri(target); final String targetFullPath = assetTargetUri.toFilePath(); final File installedFile = await fileSystem.file(source).copy(targetFullPath); @@ -963,27 +917,20 @@ Never _throwNativeAssetsLinkFailed() { } OS getNativeOSFromTargetPlatform(TargetPlatform platform) { - switch (platform) { - case TargetPlatform.ios: + switch (platform.os) { + case .ios: return OS.iOS; - case TargetPlatform.darwin: + case .macos: return OS.macOS; - case TargetPlatform.linux_x64: - case TargetPlatform.linux_arm64: - case TargetPlatform.linux_riscv64: + case .linux: return OS.linux; - case TargetPlatform.windows_x64: - case TargetPlatform.windows_arm64: + case .windows: return OS.windows; - case TargetPlatform.fuchsia_arm64: - case TargetPlatform.fuchsia_x64: + case .fuchsia: return OS.fuchsia; - case TargetPlatform.android: - case TargetPlatform.android_arm: - case TargetPlatform.android_arm64: - case TargetPlatform.android_x64: + case .android: return OS.android; - case TargetPlatform.tester: + case .tester: if (const LocalPlatform().isMacOS) { return OS.macOS; } else if (const LocalPlatform().isLinux) { @@ -993,39 +940,13 @@ OS getNativeOSFromTargetPlatform(TargetPlatform platform) { } else { throw StateError('Unknown operating system'); } - case TargetPlatform.web_javascript: + case .web: throw StateError('No dart builds for web yet.'); - case TargetPlatform.unsupported: + case .unsupported: TargetPlatform.throwUnsupportedTarget(); } } -extension OSArchitectures on OS { - Set get architectures => _osTargets[this]!; -} - -const _osTargets = >{ - OS.android: { - Architecture.arm, - Architecture.arm64, - Architecture.ia32, - Architecture.x64, - Architecture.riscv64, - }, - OS.fuchsia: {Architecture.arm64, Architecture.x64}, - OS.iOS: {Architecture.arm, Architecture.arm64, Architecture.x64}, - OS.linux: { - Architecture.arm, - Architecture.arm64, - Architecture.ia32, - Architecture.riscv32, - Architecture.riscv64, - Architecture.x64, - }, - OS.macOS: {Architecture.arm64, Architecture.x64}, - OS.windows: {Architecture.arm64, Architecture.ia32, Architecture.x64}, -}; - BuildMode _getBuildMode(Map environmentDefines, bool isFlutterTester) { if (isFlutterTester) { return BuildMode.debug; diff --git a/packages/flutter_tools/lib/src/isolated/native_assets/native_assets_manifest.dart b/packages/flutter_tools/lib/src/isolated/native_assets/native_assets_manifest.dart new file mode 100644 index 00000000..69ad9cd4 --- /dev/null +++ b/packages/flutter_tools/lib/src/isolated/native_assets/native_assets_manifest.dart @@ -0,0 +1,223 @@ +// Copyright 2014 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 'package:code_assets/code_assets.dart'; +import 'package:meta/meta.dart'; + +import '../../convert.dart'; +import 'native_assets.dart'; + +/// A path representation for a native code asset in `NativeAssetsManifest.json`. +/// +/// See `engine/src/flutter/assets/native_assets.cc` and +/// `engine/src/flutter/runtime/dart_isolate.cc` for the expected engine format. +@immutable +sealed class NativeAssetPath { + const NativeAssetPath(); + + factory NativeAssetPath.fromJson(List json) { + return switch (json) { + ['absolute', final String path] => NativeAssetAbsolutePath(path), + ['system', final String path] => NativeAssetSystemPath(path), + ['process'] => const NativeAssetInProcess(), + ['executable'] => const NativeAssetInExecutable(), + _ => throw FormatException('Invalid native asset path JSON: $json'), + }; + } + + List toJson(); +} + +/// Asset at an absolute or bundle-relative [path] on the target device. +/// +/// The path is a path on the target device, which is not necessarily the host +/// on which the tool runs. It is therefore stored as an opaque string rather +/// than as a [Uri], so that it is not reinterpreted with the host's path +/// semantics (e.g. `@rpath/Foo.framework/Foo` must not become +/// `@rpath\Foo.framework\Foo` when the tool runs on Windows). +final class NativeAssetAbsolutePath extends NativeAssetPath { + const NativeAssetAbsolutePath(this.path); + + /// The path on the target device, as a host [Uri]. + /// + /// Only use this for assets that are loaded from the host file system, such + /// as assets for the Flutter tester, or when the host and target file system + /// layout match. + NativeAssetAbsolutePath.fromFileUri(Uri uri) : path = uri.toFilePath(); + + final String path; + + static const _pathTypeValue = 'absolute'; + + @override + bool operator ==(Object other) => + identical(this, other) || (other is NativeAssetAbsolutePath && other.path == path); + + @override + int get hashCode => path.hashCode; + + @override + String toString() => 'NativeAssetAbsolutePath($path)'; + + @override + List toJson() => [_pathTypeValue, path]; +} + +/// Asset available on the target system's dynamic library search path (`PATH` / `LD_LIBRARY_PATH`). +/// +/// As with [NativeAssetAbsolutePath], [path] is a target device path and is +/// stored as an opaque string. +final class NativeAssetSystemPath extends NativeAssetPath { + const NativeAssetSystemPath(this.path); + + /// See [NativeAssetAbsolutePath.fromFileUri]. + NativeAssetSystemPath.fromFileUri(Uri uri) : path = uri.toFilePath(); + + final String path; + + static const _pathTypeValue = 'system'; + + @override + bool operator ==(Object other) => + identical(this, other) || (other is NativeAssetSystemPath && other.path == path); + + @override + int get hashCode => path.hashCode; + + @override + String toString() => 'NativeAssetSystemPath($path)'; + + @override + List toJson() => [_pathTypeValue, path]; +} + +/// Asset loaded in the process and available through `DynamicLibrary.process()`. +final class NativeAssetInProcess extends NativeAssetPath { + const NativeAssetInProcess(); + + static const _pathTypeValue = 'process'; + + @override + List toJson() => const [_pathTypeValue]; +} + +/// Asset embedded in the executable and available through `DynamicLibrary.executable()`. +final class NativeAssetInExecutable extends NativeAssetPath { + const NativeAssetInExecutable(); + + static const _pathTypeValue = 'executable'; + + @override + List toJson() => const [_pathTypeValue]; +} + +/// The target location of a [FlutterCodeAsset] in a Flutter build. +final class FlutterCodeAssetTargetLocation { + const FlutterCodeAssetTargetLocation({required this.runtimePath, this.bundlePath}); + + /// The path recorded in `NativeAssetsManifest.json` for runtime lookup. + final NativeAssetPath runtimePath; + + /// The path relative to the native assets build output directory where the + /// bundled binary/framework should be copied (null if not bundled). + final Uri? bundlePath; +} + +/// Computes the [FlutterCodeAssetTargetLocation] for a [FlutterCodeAsset]. +/// +/// For [DynamicLoadingBundled] assets, calls [bundledLocationCallback] to +/// determine the OS-specific bundle and runtime paths. +FlutterCodeAssetTargetLocation targetLocationForCodeAsset( + FlutterCodeAsset asset, + FlutterCodeAssetTargetLocation Function(FlutterCodeAsset asset) bundledLocationCallback, +) { + final LinkMode linkMode = asset.codeAsset.linkMode; + return switch (linkMode) { + DynamicLoadingSystem(:final Uri uri) => FlutterCodeAssetTargetLocation( + runtimePath: NativeAssetSystemPath.fromFileUri(uri), + ), + LookupInExecutable() => const FlutterCodeAssetTargetLocation( + runtimePath: NativeAssetInExecutable(), + ), + LookupInProcess() => const FlutterCodeAssetTargetLocation(runtimePath: NativeAssetInProcess()), + DynamicLoadingBundled() => bundledLocationCallback(asset), + StaticLinking() => throw Exception( + 'Unsupported asset link mode ${linkMode.runtimeType} in asset $asset', + ), + }; +} + +/// Model for `NativeAssetsManifest.json` bundled inside `flutter_assets/`. +final class NativeAssetsManifest { + const NativeAssetsManifest({required this.assets}); + + factory NativeAssetsManifest.fromTargetLocations( + Map targetLocations, + ) { + final assetsPerTarget = >{}; + for (final MapEntry( + key: asset, + value: location, + ) + in targetLocations.entries) { + final Map targetMap = assetsPerTarget.putIfAbsent( + asset.targetString, + () => {}, + ); + targetMap[asset.codeAsset.id] = location.runtimePath; + } + return NativeAssetsManifest(assets: assetsPerTarget); + } + + factory NativeAssetsManifest.fromJson(Map json) { + final Object? nativeAssetsJson = json[_nativeAssetsKey]; + if (nativeAssetsJson is! Map) { + return const NativeAssetsManifest(assets: >{}); + } + final assets = >{}; + for (final MapEntry(key: targetString, value: targetAssets) + in nativeAssetsJson.entries) { + if (targetAssets is! Map) { + throw FormatException( + 'Invalid native assets map for target "$targetString": $targetAssets', + ); + } + final targetMap = {}; + for (final MapEntry(key: assetId, value: pathInfo) in targetAssets.entries) { + if (pathInfo is! List) { + throw FormatException('Invalid path info for asset "$assetId": $pathInfo'); + } + targetMap[assetId] = NativeAssetPath.fromJson(pathInfo); + } + assets[targetString] = targetMap; + } + return NativeAssetsManifest(assets: assets); + } + + static const _formatVersion = [1, 0, 0]; + static const _formatVersionKey = 'format-version'; + static const _nativeAssetsKey = 'native-assets'; + + /// Mapping from target string (e.g. `macos_arm64`) to a map from asset ID + /// to its runtime [NativeAssetPath]. + final Map> assets; + + Map toJson() => { + _formatVersionKey: _formatVersion, + _nativeAssetsKey: >>{ + for (final MapEntry>( + key: targetString, + value: targetAssets, + ) + in assets.entries) + targetString: >{ + for (final MapEntry(key: assetId, value: path) + in targetAssets.entries) + assetId: path.toJson(), + }, + }, + }; + + String toJsonString() => jsonEncode(toJson()); +} diff --git a/packages/flutter_tools/lib/src/isolated/native_assets/targets.dart b/packages/flutter_tools/lib/src/isolated/native_assets/targets.dart index afe0ed36..8795cbfa 100644 --- a/packages/flutter_tools/lib/src/isolated/native_assets/targets.dart +++ b/packages/flutter_tools/lib/src/isolated/native_assets/targets.dart @@ -308,8 +308,8 @@ final class IOSAssetTarget extends CodeAssetTarget { if (sdkRoot == null) { throw MissingDefineException(kSdkRoot, 'native_assets'); } - final EnvironmentType? environmentType = xcode.environmentTypeFromSdkroot(sdkRoot, fileSystem); - return IOSCodeConfig(targetVersion: targetIOSVersion, targetSdk: getIOSSdk(environmentType!)); + final EnvironmentType environmentType = xcode.environmentTypeFromSdkroot(sdkRoot, fileSystem); + return IOSCodeConfig(targetVersion: targetIOSVersion, targetSdk: getIOSSdk(environmentType)); } @override diff --git a/packages/flutter_tools/lib/src/isolated/native_assets/test/native_assets.dart b/packages/flutter_tools/lib/src/isolated/native_assets/test/native_assets.dart index 73bc965e..72715635 100644 --- a/packages/flutter_tools/lib/src/isolated/native_assets/test/native_assets.dart +++ b/packages/flutter_tools/lib/src/isolated/native_assets/test/native_assets.dart @@ -19,7 +19,12 @@ class TestCompilerNativeAssetsBuilderImpl implements TestCompilerNativeAssetsBui const TestCompilerNativeAssetsBuilderImpl(); @override - Future build(BuildInfo buildInfo) => testCompilerBuildNativeAssets(buildInfo); + Future build(BuildInfo buildInfo) async => + (await buildWithHookResult(buildInfo)).nativeAssetsManifest; + + @override + Future buildWithHookResult(BuildInfo buildInfo) => + testCompilerBuildNativeAssets(buildInfo); @override String windowsBuildDirectory(FlutterProject project) { @@ -28,22 +33,25 @@ class TestCompilerNativeAssetsBuilderImpl implements TestCompilerNativeAssetsBui } } -Future testCompilerBuildNativeAssets(BuildInfo buildInfo) async { +Future testCompilerBuildNativeAssets( + BuildInfo buildInfo, +) async { if (!buildInfo.buildNativeAssets) { - return null; + return (nativeAssetsManifest: null, flutterHookResult: null); } final Uri projectUri = FlutterProject.current().directory.uri; final String runPackageName = buildInfo.packageConfig.packages .firstWhere((Package p) => p.root == projectUri) .name; - final String pubspecPath = Uri.file( - buildInfo.packageConfigPath, - ).resolve('../pubspec.yaml').toFilePath(); + final String pubspecPath = Uri.file(buildInfo.packageConfigPath) + .resolve('../pubspec.yaml') + .toFilePath(); final FlutterNativeAssetsBuildRunner buildRunner = FlutterNativeAssetsBuildRunnerImpl( buildInfo.packageConfigPath, buildInfo.packageConfig, globals.fs, globals.logger, + globals.platform, runPackageName, includeDevDependencies: true, pubspecPath, @@ -56,7 +64,7 @@ Future testCompilerBuildNativeAssets(BuildInfo buildInfo) async { globals.fs, buildRunner, ); - return null; + return (nativeAssetsManifest: null, flutterHookResult: null); } // Only `flutter test` uses the @@ -97,5 +105,8 @@ Future testCompilerBuildNativeAssets(BuildInfo buildInfo) async { ); assert(globals.fs.file(nativeAssetsFileUri).existsSync()); - return nativeAssetsFileUri; + return ( + nativeAssetsManifest: nativeAssetsFileUri, + flutterHookResult: dartHookResult.asFlutterResult, + ); } diff --git a/packages/flutter_tools/lib/src/isolated/release_asset_server.dart b/packages/flutter_tools/lib/src/isolated/release_asset_server.dart index 22d2e338..e0a6f9d3 100644 --- a/packages/flutter_tools/lib/src/isolated/release_asset_server.dart +++ b/packages/flutter_tools/lib/src/isolated/release_asset_server.dart @@ -18,17 +18,13 @@ import 'web_server_utilities.dart'; class ReleaseAssetServer { ReleaseAssetServer( this.entrypoint, { - required FileSystem fileSystem, - required String? webBuildDirectory, - required String? flutterRoot, - required Platform platform, - required bool needsCoopCoep, + required this._fileSystem, + required this._webBuildDirectory, + required this._flutterRoot, + required this._platform, + required this._needsCoopCoep, this.basePath = '', - }) : _fileSystem = fileSystem, - _platform = platform, - _flutterRoot = flutterRoot, - _webBuildDirectory = webBuildDirectory, - _needsCoopCoep = needsCoopCoep; + }); final Uri entrypoint; final String? _flutterRoot; @@ -52,6 +48,16 @@ class ReleaseAssetServer { // contains generated, publishable assets. static const Set _sourceMapExtensions = {'.dart', '.map'}; + static const Set _staticAssetExtensions = { + '.wasm', + '.mjs', + '.js', + '.css', + '.json', + '.ico', + '.map', + }; + // Locations where source files, assets, or source maps may be located, paired // with the set of file extensions allowed to be served from each location. An // empty set means no extension restriction is applied. @@ -111,7 +117,24 @@ class ReleaseAssetServer { ); } + // Requests for specific compiled web build artifacts (.wasm, .mjs, .js, assets/, canvaskit/) + // that do not exist on disk must return 404 Not Found so browser entrypoint fallback + // functions properly. SPA index.html fallback is preserved for navigation routes and + // unallowed project path fallthroughs. + final String cleanPath = requestPath.startsWith('/') ? requestPath.substring(1) : requestPath; + final String extension = _fileSystem.path.extension(cleanPath); + final bool isMissingStaticAsset = + _staticAssetExtensions.contains(extension) || + cleanPath.startsWith('assets/') || + cleanPath.startsWith('canvaskit/'); + if (isMissingStaticAsset) { + return shelf.Response.notFound(''); + } + final File file = _fileSystem.file(_fileSystem.path.join(_webBuildDirectory!, 'index.html')); + if (!file.existsSync()) { + return shelf.Response.notFound(''); + } return shelf.Response.ok( file.readAsBytesSync(), headers: { diff --git a/packages/flutter_tools/lib/src/isolated/resident_web_runner.dart b/packages/flutter_tools/lib/src/isolated/resident_web_runner.dart index 8f76a084..2a893d26 100644 --- a/packages/flutter_tools/lib/src/isolated/resident_web_runner.dart +++ b/packages/flutter_tools/lib/src/isolated/resident_web_runner.dart @@ -13,24 +13,23 @@ import 'package:webkit_inspection_protocol/webkit_inspection_protocol.dart' hide import '../application_package.dart'; import '../base/async_guard.dart'; -import '../base/command_help.dart'; import '../base/common.dart'; import '../base/file_system.dart'; import '../base/io.dart'; import '../base/logger.dart'; import '../base/net.dart'; -import '../base/platform.dart'; -import '../base/terminal.dart'; import '../base/time.dart'; import '../base/utils.dart'; import '../build_info.dart'; +import '../build_system/build_system.dart'; +import '../build_system/build_targets.dart'; import '../cache.dart'; +import '../context/tool_context.dart'; import '../dart/language_version.dart'; import '../dart/package_map.dart'; import '../devfs.dart'; import '../device.dart'; import '../flutter_plugins.dart'; -import '../globals.dart' as globals; import '../hook_runner.dart' show hookRunner; import '../project.dart'; import '../resident_runner.dart'; @@ -51,38 +50,32 @@ class DwdsWebRunnerFactory extends WebRunnerFactory { @override ResidentRunner createWebRunner( FlutterDevice device, { - String? target, - required bool stayResident, - required FlutterProject flutterProject, + required Analytics analytics, + required BuildSystem buildSystem, + required BuildTargets buildTargets, required DebuggingOptions debuggingOptions, + required FlutterProject flutterProject, + required bool stayResident, + required ToolContext toolContext, + bool machine = false, Map platformArgs = const {}, + String? target, UrlTunneller? urlTunneller, - required Logger logger, - required Terminal terminal, - required Platform platform, - required OutputPreferences outputPreferences, - required FileSystem fileSystem, - required SystemClock systemClock, - required Analytics analytics, - bool machine = false, Map webDefines = const {}, }) { return ResidentWebRunner( device, - target: target, - flutterProject: flutterProject, + analytics: analytics, + buildSystem: buildSystem, + buildTargets: buildTargets, debuggingOptions: debuggingOptions, + flutterProject: flutterProject, + toolContext: toolContext, + machine: machine, platformArgs: platformArgs, stayResident: stayResident, + target: target, urlTunneller: urlTunneller, - machine: machine, - analytics: analytics, - systemClock: systemClock, - fileSystem: fileSystem, - logger: logger, - terminal: terminal, - platform: platform, - outputPreferences: outputPreferences, webDefines: webDefines, ); } @@ -98,56 +91,30 @@ const kNoClientConnectedMessage = 'Recompile complete. No client connected.'; class ResidentWebRunner extends ResidentRunner { ResidentWebRunner( FlutterDevice device, { - String? target, - super.stayResident = true, - super.machine = false, - super.projectRootPath, - required this.flutterProject, + required super.analytics, + required super.buildSystem, + required super.buildTargets, required super.debuggingOptions, + required this.flutterProject, + required super.toolContext, + super.machine = false, this.platformArgs = const {}, - required FileSystem fileSystem, - required Logger logger, - required Terminal terminal, - required Platform platform, - required OutputPreferences outputPreferences, - required SystemClock systemClock, - required Analytics analytics, - UrlTunneller? urlTunneller, - Map webDefines = const {}, - }) : _fileSystem = fileSystem, - _logger = logger, - _platform = platform, - _systemClock = systemClock, - _analytics = analytics, - _urlTunneller = urlTunneller, - _webDefines = webDefines, - super( + super.projectRootPath, + super.stayResident = true, + String? target, + this._urlTunneller, + this._webDefines = const {}, + }) : super( [device], - target: target ?? fileSystem.path.join('lib', 'main.dart'), - commandHelp: CommandHelp( - logger: logger, - terminal: terminal, - platform: platform, - outputPreferences: outputPreferences, - ), + target: target ?? toolContext.fs.path.join('lib', 'main.dart'), + xcode: null, dartBuilder: hookRunner, ); - final FileSystem _fileSystem; - final Logger _logger; - final Platform _platform; - final SystemClock _systemClock; - final Analytics _analytics; final UrlTunneller? _urlTunneller; final Map _webDefines; final Map platformArgs; - @override - Logger get logger => _logger; - - @override - FileSystem get fileSystem => _fileSystem; - FlutterDevice? get flutterDevice => flutterDevices.first; final FlutterProject flutterProject; @@ -161,7 +128,7 @@ class ResidentWebRunner extends ResidentRunner { // Only non-wasm debug builds of the web support the service protocol. @override late final bool supportsServiceProtocol = - !debuggingOptions.webUseWasm && isRunningDebug && _deviceIsDebuggable; + !debuggingOptions.webConfig.useWasm && isRunningDebug && _deviceIsDebuggable; /// Device is debuggable if not a WebServer device, or if running with /// --start-paused or using DWDS WebSocket connection (WebServer device). @@ -170,22 +137,27 @@ class ResidentWebRunner extends ResidentRunner { debuggingOptions.startPaused || useDwdsWebSocketConnection; - late final useDwdsWebSocketConnection = flutterDevice!.device is! ChromiumDevice; + /// Whether the tool launches (and therefore owns a DevTools protocol + /// connection to) a Chromium instance for this run. + /// + /// This is false for non-Chromium devices and when the caller passes + /// `no-launch-chrome` (e.g. `flutter drive`, where WebDriver launches the + /// browser). In that case [ChromiumLauncher.connectedInstance] never + /// completes, so nothing may wait on it. + late final bool _toolLaunchesChromium = + flutterDevice?.device is ChromiumDevice && platformArgs['no-launch-chrome'] != true; + + /// Chrome-based DWDS debugging requires a DevTools protocol connection to a + /// browser launched by the tool. Otherwise, use the DWDS WebSocket + /// connection. + late final bool useDwdsWebSocketConnection = !_toolLaunchesChromium; @override // Web uses a different plugin registry. bool get generateDartPluginRegistry => false; @override - bool get reloadIsRestart => - debuggingOptions.webUseWasm || - // Web behavior when not using the DDC library bundle format is to restart - // when a reload is issued. We can't use `canHotReload` to signal this - // since we still want a reload command to succeed, but to do a hot - // restart. - debuggingOptions.buildInfo.ddcModuleFormat != DdcModuleFormat.ddc || - !debuggingOptions.buildInfo.canaryFeatures; - + bool get reloadIsRestart => debuggingOptions.webConfig.useWasm; @override bool get supportsDetach => stopAppDuringCleanup; @@ -195,6 +167,7 @@ class ResidentWebRunner extends ResidentRunner { StreamSubscription? _serviceSub; StreamSubscription? _extensionEventSub; var _exited = false; + var _isRestarting = false; WipConnection? _wipConnection; ChromiumLauncher? _chromiumLauncher; @@ -238,7 +211,7 @@ class ResidentWebRunner extends ResidentRunner { _generatedEntrypointDirectory?.deleteSync(recursive: true); } on FileSystemException { // Best effort to clean up temp dirs. - _logger.printTrace( + logger.printTrace( 'Failed to clean up temp directory: ${_generatedEntrypointDirectory!.path}', ); } @@ -268,12 +241,12 @@ class ResidentWebRunner extends ResidentRunner { buildInfo: debuggingOptions.buildInfo, ); if (package == null) { - _logger.printStatus('This application is not configured to build on the web.'); - _logger.printStatus('To add web support to a project, run `flutter create .`.'); + logger.printStatus('This application is not configured to build on the web.'); + logger.printStatus('To add web support to a project, run `flutter create .`.'); } final String modeName = debuggingOptions.buildInfo.mode.friendlyName; - _logger.printStatus( - 'Launching ${getDisplayPath(target, _fileSystem)} ' + logger.printStatus( + 'Launching ${getDisplayPath(target, fileSystem)} ' 'on ${flutterDevice!.device!.displayName} in $modeName mode...', ); if (flutterDevice!.device is ChromiumDevice) { @@ -282,15 +255,24 @@ class ResidentWebRunner extends ResidentRunner { try { return await asyncGuard(() async { - final WebDevServerConfig originalConfig = - debuggingOptions.webDevServerConfig ?? const WebDevServerConfig(); + final WebDebugConfig( + :WebDevServerConfig? devServerConfig, + :bool enableExpressionEvaluation, + :bool useSseForDebugProxy, + :bool useSseForDebugBackend, + :bool useSseForInjectedClient, + :WebRendererMode renderer, + :bool useWasm, + :bool crossOriginIsolation, + ) = debuggingOptions.webConfig; - final int resolvedPort = await resolvePort(originalConfig.port, globals.os); + final WebDevServerConfig originalConfig = devServerConfig ?? const WebDevServerConfig(); + + final int resolvedPort = await resolvePort(originalConfig.port, osUtils); final WebDevServerConfig updatedConfig = originalConfig.copyWith(port: resolvedPort); - final ExpressionCompiler? expressionCompiler = - debuggingOptions.webEnableExpressionEvaluation - ? WebExpressionCompiler(flutterDevice!.generator!, fileSystem: _fileSystem) + final ExpressionCompiler? expressionCompiler = enableExpressionEvaluation + ? WebExpressionCompiler(flutterDevice!.generator!, fileSystem: fileSystem) : null; flutterDevice!.developmentShaderCompiler.configureCompiler(TargetPlatform.web_javascript); @@ -299,9 +281,9 @@ class ResidentWebRunner extends ResidentRunner { webDevServerConfig: updatedConfig, packagesFilePath: packagesFilePath, urlTunneller: _urlTunneller, - useSseForDebugProxy: debuggingOptions.webUseSseForDebugProxy, - useSseForDebugBackend: debuggingOptions.webUseSseForDebugBackend, - useSseForInjectedClient: debuggingOptions.webUseSseForInjectedClient, + useSseForDebugProxy: useSseForDebugProxy, + useSseForDebugBackend: useSseForDebugBackend, + useSseForInjectedClient: useSseForInjectedClient, buildInfo: debuggingOptions.buildInfo, enableDwds: supportsServiceProtocol, ddsConfig: DartDevelopmentServiceConfiguration( @@ -310,32 +292,30 @@ class ResidentWebRunner extends ResidentRunner { serveDevTools: debuggingOptions.enableDevTools, devToolsServerAddress: debuggingOptions.devToolsServerAddress, ), - entrypoint: _fileSystem.file(target).uri, + entrypoint: fileSystem.file(target).uri, expressionCompiler: expressionCompiler, chromiumLauncher: _chromiumLauncher, nativeNullAssertions: debuggingOptions.nativeNullAssertions, - ddcModuleSystem: debuggingOptions.buildInfo.ddcModuleFormat == DdcModuleFormat.ddc, - canaryFeatures: debuggingOptions.buildInfo.canaryFeatures, - webRenderer: debuggingOptions.webRenderer, - isWasm: debuggingOptions.webUseWasm, + webRenderer: renderer, + isWasm: useWasm, useLocalCanvasKit: debuggingOptions.buildInfo.useLocalCanvasKit, rootDirectory: fileSystem.directory(projectRootPath), useDwdsWebSocketConnection: useDwdsWebSocketConnection, - webCrossOriginIsolation: debuggingOptions.webCrossOriginIsolation, + webCrossOriginIsolation: crossOriginIsolation, fileSystem: fileSystem, logger: logger, - platform: _platform, + platform: platform, webDefines: _webDefines, ); Uri url = await flutterDevice!.devFS!.create(); if (updatedConfig.https?.certKeyPath != null && updatedConfig.https?.certPath != null) { url = url.replace(scheme: 'https'); } - if (debuggingOptions.buildInfo.isDebug && !debuggingOptions.webUseWasm) { + if (debuggingOptions.buildInfo.isDebug && !useWasm) { await runSourceGenerators(); final UpdateFSReport report = await _updateDevFS(fullRestart: true, resetCompiler: true); if (!report.success) { - _logger.printError('Failed to compile application.'); + logger.printError('Failed to compile application.'); appFailedToStart(); return 1; } @@ -343,12 +323,18 @@ class ResidentWebRunner extends ResidentRunner { cacheInitialDillCompilation(); } else { final webBuilder = WebBuilder( - logger: _logger, - processManager: globals.processManager, - buildSystem: globals.buildSystem, - fileSystem: _fileSystem, - flutterVersion: globals.flutterVersion, - analytics: globals.analytics, + logger: logger, + processManager: processManager, + buildSystem: buildSystem, + fileSystem: fileSystem, + flutterVersion: flutterVersion, + analytics: analytics, + artifacts: artifacts, + buildTargets: buildTargets, + cache: cache, + config: config, + platform: platform, + terminal: terminal, ); await webBuilder.buildWeb( flutterProject, @@ -367,6 +353,13 @@ class ResidentWebRunner extends ResidentRunner { final Future? connectDebug = supportsServiceProtocol ? webDevFS.connect(useDebugExtension) : null; + // Unpause incoming HTTP requests now that: + // 1. Initial compilation has finished and WebMemoryFS has received the + // compiled modules and merged metadata (preventing DWDS from memoizing + // an empty module list). + // 2. `webDevFS.connect()` has begun listening for connected applications + // so early connections are not dropped. + webDevFS.markReady(); await flutterDevice!.device!.startApp( package, mainPath: target, @@ -381,39 +374,39 @@ class ResidentWebRunner extends ResidentRunner { }); } on WebSocketException catch (error, stackTrace) { appFailedToStart(); - _logger.printError(error.toString(), stackTrace: stackTrace); + logger.printError(error.toString(), stackTrace: stackTrace); throwToolExit(kExitMessage); } on ChromeDebugException catch (error, stackTrace) { appFailedToStart(); - _logger.printError(error.toString(), stackTrace: stackTrace); + logger.printError(error.toString(), stackTrace: stackTrace); throwToolExit(kExitMessage); } on AppConnectionException catch (error, stackTrace) { appFailedToStart(); - _logger.printError(error.toString(), stackTrace: stackTrace); + logger.printError(error.toString(), stackTrace: stackTrace); throwToolExit(kExitMessage); } on SocketException catch (error, stackTrace) { appFailedToStart(); - _logger.printError(error.toString(), stackTrace: stackTrace); + logger.printError(error.toString(), stackTrace: stackTrace); throwToolExit(kExitMessage); } on HttpException catch (error, stackTrace) { appFailedToStart(); - _logger.printError(error.toString(), stackTrace: stackTrace); + logger.printError(error.toString(), stackTrace: stackTrace); throwToolExit(kExitMessage); } on TimeoutException catch (error, stackTrace) { appFailedToStart(); - _logger.printError( + logger.printError( 'Failed to establish connection with the web debug service: $error', stackTrace: stackTrace, ); throwToolExit('Failed to connect to the web debug service.'); - } on DartDevelopmentServiceException catch (error) { + } on DartDevelopmentServiceException catch (error, stackTrace) { // The application may have started shutting down before DDS was able to finish establishing // its connection to DWDS. Don't treat this as an unhandled exception. appFailedToStart(); - if (error.errorCode == DartDevelopmentServiceException.failedToStartError) { - throwToolExit(kExitMessage); + if (error.errorCode != DartDevelopmentServiceException.failedToStartError) { + logger.printError(error.message, stackTrace: stackTrace); } - rethrow; + throwToolExit(kExitMessage); } on Exception { appFailedToStart(); rethrow; @@ -421,23 +414,19 @@ class ResidentWebRunner extends ResidentRunner { } WebCompilerConfig get _compilerConfig { - if (debuggingOptions.webUseWasm) { - return WasmCompilerConfig( - optimizationLevel: 0, - stripWasm: false, - renderer: debuggingOptions.webRenderer, - ); + if (debuggingOptions.webConfig.useWasm) { + return WasmCompilerConfig(renderer: debuggingOptions.webConfig.renderer); } return JsCompilerConfig.run( nativeNullAssertions: debuggingOptions.nativeNullAssertions, - renderer: debuggingOptions.webRenderer, + renderer: debuggingOptions.webConfig.renderer, + deprecatedJsInterop: debuggingOptions.buildInfo.deprecatedJsInterop, ); } /// Handles the no clients available scenario gracefully. - OperationResult _handleNoClientsAvailable(Status status) { - status.stop(); - _logger.printStatus(kNoClientConnectedMessage); + OperationResult _handleNoClientsAvailable() { + logger.printStatus(kNoClientConnectedMessage); return OperationResult.ok; } @@ -448,185 +437,195 @@ class ResidentWebRunner extends ResidentRunner { String? reason, bool benchmarkMode = false, }) async { - final DateTime start = _systemClock.now(); - final Status status; - if (debuggingOptions.buildInfo.ddcModuleFormat != DdcModuleFormat.ddc || - !debuggingOptions.buildInfo.canaryFeatures) { - // Triggering hot reload performed hot restart for the old module formats - // historically. Keep that behavior and only perform hot reload when the - // new module format is used. - fullRestart = true; + if (_exited) { + return OperationResult(1, 'Application has exited.'); + } + if (_isRestarting) { + return OperationResult(1, 'A restart is already in progress.'); } + _isRestarting = true; + final SystemClock systemClock = toolContext.systemClock; + final DateTime start = systemClock.now(); + final Status status; if (fullRestart) { - status = _logger.startProgress('Performing hot restart...', progressId: 'hot.restart'); + status = logger.startProgress('Performing hot restart...', progressId: 'hot.restart'); } else { - status = _logger.startProgress('Performing hot reload...', progressId: 'hot.reload'); + status = logger.startProgress('Performing hot reload...', progressId: 'hot.reload'); } final String targetPlatform = TargetPlatform.web_javascript.getName(); - final String sdkName = await flutterDevice!.device!.sdkNameAndVersion; - + final String sdkName; // Will be null if there is no report. final UpdateFSReport? report; - if (debuggingOptions.buildInfo.isDebug && !debuggingOptions.webUseWasm) { - await runSourceGenerators(); - // Don't reset the resident compiler for web, since the extra recompile is - // wasteful. - report = await _updateDevFS(fullRestart: fullRestart, resetCompiler: false); - if (report.success) { - flutterDevice!.generator!.accept(); + // Both will be null when not assigned. + Duration? reloadDuration; + Duration? reassembleDuration; + try { + sdkName = await flutterDevice!.device!.sdkNameAndVersion; + + if (debuggingOptions.buildInfo.isDebug && !debuggingOptions.webConfig.useWasm) { + await runSourceGenerators(); + // Don't reset the resident compiler for web, since the extra recompile is + // wasteful. + report = await _updateDevFS(fullRestart: fullRestart, resetCompiler: false); + if (report.success) { + flutterDevice!.generator!.accept(); + } else { + await flutterDevice!.generator!.reject(); + if (report.hotReloadRejected) { + // We cannot capture the reason why the reload was rejected as it may + // contain user information. + analytics.send( + Event.hotRunnerInfo( + label: 'reload-reject', + targetPlatform: targetPlatform, + sdkName: sdkName, + emulator: false, + fullRestart: fullRestart, + ), + ); + } + return OperationResult(1, 'Failed to recompile application.'); + } } else { - status.stop(); - await flutterDevice!.generator!.reject(); - if (report.hotReloadRejected) { - // We cannot capture the reason why the reload was rejected as it may - // contain user information. - _analytics.send( - Event.hotRunnerInfo( - label: 'reload-reject', - targetPlatform: targetPlatform, - sdkName: sdkName, - emulator: false, - fullRestart: fullRestart, - ), + report = null; + try { + final webBuilder = WebBuilder( + logger: logger, + processManager: processManager, + buildSystem: buildSystem, + fileSystem: fileSystem, + flutterVersion: flutterVersion, + analytics: analytics, + artifacts: artifacts, + buildTargets: buildTargets, + cache: cache, + config: config, + platform: platform, + terminal: terminal, + ); + await webBuilder.buildWeb( + flutterProject, + target, + debuggingOptions.buildInfo, + ServiceWorkerStrategy.none, + compilerConfigs: [_compilerConfig], + webDefines: _webDefines, ); + } on ToolExit { + return OperationResult(1, 'Failed to recompile application.'); } - return OperationResult(1, 'Failed to recompile application.'); - } - } else { - report = null; - try { - final webBuilder = WebBuilder( - logger: _logger, - processManager: globals.processManager, - buildSystem: globals.buildSystem, - fileSystem: _fileSystem, - flutterVersion: globals.flutterVersion, - analytics: globals.analytics, - ); - await webBuilder.buildWeb( - flutterProject, - target, - debuggingOptions.buildInfo, - ServiceWorkerStrategy.none, - compilerConfigs: [_compilerConfig], - webDefines: _webDefines, - ); - } on ToolExit { - return OperationResult(1, 'Failed to recompile application.'); } - } - if (supportsServiceProtocol && _connectionResult == null) { - return _handleNoClientsAvailable(status); - } + if (supportsServiceProtocol && _connectionResult == null) { + return _handleNoClientsAvailable(); + } - // Both will be null when not assigned. - Duration? reloadDuration; - Duration? reassembleDuration; - try { - if (!_deviceIsDebuggable) { - _logger.printStatus('Recompile complete. Page requires refresh.'); - } else if (isRunningDebug) { - if (fullRestart) { - // If the hot-restart service extension method is registered, then use - // it. Otherwise, default to calling "hotRestart" without a namespace. - final String hotRestartMethod = - _registeredMethodsForService['hotRestart'] ?? 'hotRestart'; + try { + if (!_deviceIsDebuggable) { + logger.printStatus('Recompile complete. Page requires refresh.'); + } else if (isRunningDebug) { + if (fullRestart) { + // If the hot-restart service extension method is registered, then use + // it. Otherwise, default to calling "hotRestart" without a namespace. + final String hotRestartMethod = + _registeredMethodsForService['hotRestart'] ?? 'hotRestart'; - try { - await _vmService.service.callMethod(hotRestartMethod); - } on vmservice.RPCError catch (e) { - // DWDS throws an RPC error with kIsolateCannotReload code when there are no - // browser clients currently connected during a hot restart operation. - - // TODO(61757): Remove this temporary workaround once vm_service is fixed. - // There's a bug in vm_service where it re-encodes RPCErrors as kServerError - // instead of preserving the original error code. Until that's fixed, we need - // to check for both kIsolateCannotReload and kServerError for this method. - if (e.callingMethod == hotRestartMethod && - (e.code == vmservice.RPCErrorKind.kIsolateCannotReload.code || - e.code == vmservice.RPCErrorKind.kServerError.code)) { - return _handleNoClientsAvailable(status); + try { + await _vmService.service.callMethod(hotRestartMethod); + } on vmservice.RPCError catch (e) { + // DWDS throws an RPC error with kIsolateCannotReload code when there are no + // browser clients currently connected during a hot restart operation. + + // TODO(61757): Remove this temporary workaround once vm_service is fixed. + // There's a bug in vm_service where it re-encodes RPCErrors as kServerError + // instead of preserving the original error code. Until that's fixed, we need + // to check for both kIsolateCannotReload and kServerError for this method. + if (e.callingMethod == hotRestartMethod && + (e.code == vmservice.RPCErrorKind.kIsolateCannotReload.code || + e.code == vmservice.RPCErrorKind.kServerError.code)) { + return _handleNoClientsAvailable(); + } + // Re-throw other RPC errors + rethrow; + } + } else { + final DateTime reloadStart = systemClock.now(); + final vmservice.VM vm = await _vmService.service.getVM(); + final String hotReloadMethod = + _registeredMethodsForService['reloadSources'] ?? 'reloadSources'; + + // Check if there are any isolates available + if (vm.isolates == null || vm.isolates!.isEmpty) { + logger.printTrace('No isolates available for hot reload'); + return _handleNoClientsAvailable(); } - // Re-throw other RPC errors - rethrow; - } - } else { - final DateTime reloadStart = _systemClock.now(); - final vmservice.VM vm = await _vmService.service.getVM(); - final String hotReloadMethod = - _registeredMethodsForService['reloadSources'] ?? 'reloadSources'; - - // Check if there are any isolates available - if (vm.isolates == null || vm.isolates!.isEmpty) { - _logger.printTrace('No isolates available for hot reload'); - return _handleNoClientsAvailable(status); - } - vmservice.ReloadReport report; - try { - report = await _vmService.service.reloadSources(vm.isolates!.first.id!); - } on vmservice.RPCError catch (e) { - // DWDS throws an RPC error with kIsolateCannotReload code when there are no - // browser clients currently connected during a hot reload operation. - if (e.callingMethod == hotReloadMethod && - e.code == vmservice.RPCErrorKind.kIsolateCannotReload.code) { - return _handleNoClientsAvailable(status); + vmservice.ReloadReport report; + try { + report = await _vmService.service.reloadSources(vm.isolates!.first.id!); + } on vmservice.RPCError catch (e) { + // DWDS throws an RPC error with kIsolateCannotReload code when there are no + // browser clients currently connected during a hot reload operation. + if (e.callingMethod == hotReloadMethod && + e.code == vmservice.RPCErrorKind.kIsolateCannotReload.code) { + return _handleNoClientsAvailable(); + } + // Re-throw other RPC errors + rethrow; } - // Re-throw other RPC errors - rethrow; - } - reloadDuration = _systemClock.now().difference(reloadStart); - final contents = ReloadReportContents.fromReloadReport(report); - final bool success = contents.success ?? false; - if (!success) { - // Rejections happen at compile-time for the web, so in theory, - // nothing should go wrong here. However, if DWDS or the DDC runtime - // has some internal error, we should still surface it to make - // debugging easier. - var reloadFailedMessage = 'Hot reload failed:'; - _logger.printError(reloadFailedMessage); - for (final ReasonForCancelling reason in contents.notices) { - reloadFailedMessage += reason.toString(); - _logger.printError(reason.toString()); + reloadDuration = systemClock.now().difference(reloadStart); + final contents = ReloadReportContents.fromReloadReport(report); + final bool success = contents.success ?? false; + if (!success) { + // Rejections happen at compile-time for the web, so in theory, + // nothing should go wrong here. However, if DWDS or the DDC runtime + // has some internal error, we should still surface it to make + // debugging easier. + var reloadFailedMessage = 'Hot reload failed:'; + logger.printError(reloadFailedMessage); + for (final ReasonForCancelling reason in contents.notices) { + reloadFailedMessage += reason.toString(); + logger.printError(reason.toString()); + } + return OperationResult(1, reloadFailedMessage); + } + await evictDirtyAssets(); + String? failedReassemble; + final DateTime reassembleStart = systemClock.now(); + await _vmService + .flutterReassemble(isolateId: null) + .then( + (Object? o) => o, + onError: (Object error, StackTrace stackTrace) { + failedReassemble = 'Reassembling failed: $error\n$stackTrace'; + logger.printError(failedReassemble!); + }, + ); + reassembleDuration = systemClock.now().difference(reassembleStart); + if (failedReassemble != null) { + return OperationResult(1, failedReassemble!); } - return OperationResult(1, reloadFailedMessage); - } - await evictDirtyAssets(); - String? failedReassemble; - final DateTime reassembleStart = _systemClock.now(); - await _vmService - .flutterReassemble(isolateId: null) - .then( - (Object? o) => o, - onError: (Object error, StackTrace stackTrace) { - failedReassemble = 'Reassembling failed: $error\n$stackTrace'; - _logger.printError(failedReassemble!); - }, - ); - reassembleDuration = _systemClock.now().difference(reassembleStart); - if (failedReassemble != null) { - return OperationResult(1, failedReassemble!); } + } else { + // On non-debug builds, a hard refresh is required to ensure the + // up to date sources are loaded. + await _wipConnection?.sendCommand('Page.reload', { + 'ignoreCache': !debuggingOptions.buildInfo.isDebug, + }); } - } else { - // On non-debug builds, a hard refresh is required to ensure the - // up to date sources are loaded. - await _wipConnection?.sendCommand('Page.reload', { - 'ignoreCache': !debuggingOptions.buildInfo.isDebug, - }); + } on Exception catch (err) { + return OperationResult(1, err.toString(), fatal: true); } - } on Exception catch (err) { - return OperationResult(1, err.toString(), fatal: true); } finally { + _isRestarting = false; status.stop(); } - final Duration elapsed = _systemClock.now().difference(start); + final Duration elapsed = systemClock.now().difference(start); final String elapsedMS = getElapsedAsMilliseconds(elapsed); - _logger.printStatus('${fullRestart ? 'Restarted' : 'Reloaded'} application in $elapsedMS.'); + logger.printStatus('${fullRestart ? 'Restarted' : 'Reloaded'} application in $elapsedMS.'); if (fullRestart) { for (final FlutterDevice? device in flutterDevices) { @@ -637,14 +636,14 @@ class ResidentWebRunner extends ResidentRunner { // Don't track restart times for dart2js builds or web-server devices. if (debuggingOptions.buildInfo.isDebug && _deviceIsDebuggable) { if (fullRestart) { - _analytics.send( + analytics.send( Event.timing( workflow: 'hot', variableName: 'web-incremental-restart', elapsedMilliseconds: elapsed.inMilliseconds, ), ); - _analytics.send( + analytics.send( Event.hotRunnerInfo( label: 'restart', targetPlatform: targetPlatform, @@ -662,14 +661,14 @@ class ResidentWebRunner extends ResidentRunner { ), ); } else { - _analytics.send( + analytics.send( Event.timing( workflow: 'hot', variableName: 'reload', elapsedMilliseconds: elapsed.inMilliseconds, ), ); - _analytics.send( + analytics.send( Event.hotRunnerInfo( label: 'reload', targetPlatform: targetPlatform, @@ -699,7 +698,7 @@ class ResidentWebRunner extends ResidentRunner { Future _generateEntrypoint(Uri mainUri, PackageConfig? packageConfig) async { File? result = _generatedEntrypointDirectory?.childFile('web_entrypoint.dart'); if (_generatedEntrypointDirectory == null) { - _generatedEntrypointDirectory ??= _fileSystem.systemTempDirectory.createTempSync( + _generatedEntrypointDirectory ??= fileSystem.systemTempDirectory.createTempSync( 'flutter_tools.', )..createSync(); result = _generatedEntrypointDirectory!.childFile('web_entrypoint.dart'); @@ -716,10 +715,10 @@ class ResidentWebRunner extends ResidentRunner { Uri? importedEntrypoint = packageConfig!.toPackageUriForWorkspace(mainUri); // Special handling for entrypoints that are not under lib, such as test scripts. if (importedEntrypoint == null) { - final String parent = _fileSystem.file(mainUri).parent.path; + final String parent = fileSystem.file(mainUri).parent.path; flutterDevices.first.generator! ..addFileSystemRoot(parent) - ..addFileSystemRoot(_fileSystem.directory('test').absolute.path); + ..addFileSystemRoot(fileSystem.directory('test').absolute.path); importedEntrypoint = Uri( scheme: 'org-dartlang-app', host: '', @@ -727,7 +726,7 @@ class ResidentWebRunner extends ResidentRunner { ); } final LanguageVersion languageVersion = determineLanguageVersion( - _fileSystem.file(mainUri), + fileSystem.file(mainUri), packageConfig[flutterProject.manifest.appName], Cache.flutterRoot!, ); @@ -750,12 +749,12 @@ class ResidentWebRunner extends ResidentRunner { final bool isFirstUpload = !assetBundle.wasBuiltOnce(); final bool rebuildBundle = assetBundle.needsBuild(); if (rebuildBundle) { - _logger.printTrace('Updating assets'); + logger.printTrace('Updating assets'); final int result = await assetBundle.build( flutterHookResult: await dartBuilder?.runHooks( targetPlatform: TargetPlatform.web_javascript, environment: environment, - logger: _logger, + logger: logger, ), packageConfigPath: debuggingOptions.buildInfo.packageConfigPath, targetPlatform: TargetPlatform.web_javascript, @@ -764,6 +763,11 @@ class ResidentWebRunner extends ResidentRunner { return UpdateFSReport(); } } + final projectFileInvalidator = ProjectFileInvalidator( + fileSystem: fileSystem, + platform: platform, + logger: logger, + ); final InvalidationResult invalidationResult = await projectFileInvalidator.findInvalidated( lastCompiled: flutterDevice!.devFS!.lastCompiled, urisToMonitor: flutterDevice!.devFS!.sources, @@ -771,13 +775,13 @@ class ResidentWebRunner extends ResidentRunner { packageConfig: flutterDevice!.devFS!.lastPackageConfig ?? debuggingOptions.buildInfo.packageConfig, ); - final Status devFSStatus = _logger.startProgress( + final Status devFSStatus = logger.startProgress( 'Waiting for connection from debug service on ' '${flutterDevice!.device!.displayName}...', ); final UpdateFSReport report = await flutterDevice!.devFS!.update( mainUri: await _generateEntrypoint( - _fileSystem.file(mainPath).absolute.uri, + fileSystem.file(mainPath).absolute.uri, invalidationResult.packageConfig, ), target: target, @@ -794,7 +798,7 @@ class ResidentWebRunner extends ResidentRunner { shaderCompiler: flutterDevice!.developmentShaderCompiler, ); devFSStatus.stop(); - _logger.printTrace('Synced ${getSizeAsPlatformMB(report.syncedBytes)}.'); + logger.printTrace('Synced ${getSizeAsPlatformMB(report.syncedBytes)}.'); return report; } @@ -805,7 +809,7 @@ class ResidentWebRunner extends ResidentRunner { Future? connectDebug, bool needsFullRestart = true, }) async { - if (_chromiumLauncher != null) { + if (_chromiumLauncher != null && _toolLaunchesChromium) { final Chromium chrome = await _chromiumLauncher!.connectedInstance; final ChromeTab? chromeTab = await getChromeTabGuarded( chrome.chromeConnection, @@ -815,7 +819,7 @@ class ResidentWebRunner extends ResidentRunner { retryFor: const Duration(seconds: 5), onIoError: (Object error, StackTrace stackTrace) { // We were unable to unable to communicate with Chrome. - _logger.printError(error.toString(), stackTrace: stackTrace); + logger.printError(error.toString(), stackTrace: stackTrace); }, ); if (chromeTab == null) { @@ -834,7 +838,7 @@ class ResidentWebRunner extends ResidentRunner { void onLogEvent(vmservice.Event event) { final String message = processVmServiceMessage(event); - _logger.printStatus(message); + logger.printStatus(message); } // This flag is needed to manage breakpoints properly. @@ -845,10 +849,10 @@ class ResidentWebRunner extends ResidentRunner { 'true', ); if (result is! vmservice.Success) { - _logger.printError('setFlag failure: $result'); + logger.printError('setFlag failure: $result'); } } on Exception catch (e) { - _logger.printError( + logger.printError( 'Failed to set pause_isolates_on_start=true, proceeding. ' 'Error: $e', ); @@ -917,13 +921,13 @@ class ResidentWebRunner extends ResidentRunner { } if (debuggingOptions.vmserviceOutFile != null) { - _fileSystem.file(debuggingOptions.vmserviceOutFile) + fileSystem.file(debuggingOptions.vmserviceOutFile) ..createSync(recursive: true) ..writeAsStringSync(websocketUri.toString()); } // TODO(bkonyi): consider removing this log message and using only the standard VM // service message instead. - _logger.printStatus('Debug service listening on $websocketUri'); + logger.printStatus('Debug service listening on $websocketUri'); final connectionInfo = DebugConnectionInfo( wsUri: websocketUri, devToolsUri: debugConnection.devToolsUri?.toUri(), diff --git a/packages/flutter_tools/lib/src/isolated/web_asset_server.dart b/packages/flutter_tools/lib/src/isolated/web_asset_server.dart index 640925b9..2ac98a78 100644 --- a/packages/flutter_tools/lib/src/isolated/web_asset_server.dart +++ b/packages/flutter_tools/lib/src/isolated/web_asset_server.dart @@ -24,6 +24,7 @@ import '../base/net.dart'; import '../base/platform.dart'; import '../build_info.dart'; import '../cache.dart'; +import '../compile.dart' show kDdcCanaryFeatures; import '../convert.dart'; import '../dart/package_map.dart'; import '../globals.dart' as globals; @@ -54,16 +55,13 @@ const _kDefaultIndex = ''' '''; -typedef DwdsLauncher = - Future Function({ - required AssetReader assetReader, - required Stream buildResults, - required ConnectionProvider chromeConnection, - required ToolConfiguration toolConfiguration, - bool useDwdsWebSocketConnection, - }); - -const kLuciEnvName = 'LUCI_CONTEXT'; +typedef DwdsLauncher = Future Function({ + required AssetReader assetReader, + required Stream buildResults, + required ConnectionProvider chromeConnection, + required ToolConfiguration toolConfiguration, + bool useDwdsWebSocketConnection, +}); /// A web server which handles serving JavaScript and assets. /// @@ -75,24 +73,14 @@ class WebAssetServer implements AssetReader { this._packages, this.internetAddress, this._modules, - this._digests, - this._ddcModuleSystem, - this._canaryFeatures, { + this._digests, { required this.webRenderer, required this.useLocalCanvasKit, required this.fileSystem, required this.logger, - String? baseHref, - Map webDefines = const {}, - }) : basePath = WebTemplate.baseHref(htmlTemplate(fileSystem, 'index.html', _kDefaultIndex)), - _baseHref = baseHref, - _webDefines = webDefines { - // TODO(srujzs): Remove this assertion when the library bundle format is - // supported without canary mode. - if (_ddcModuleSystem) { - assert(_canaryFeatures); - } - } + this._baseHref, + this._webDefines = const {}, + }) : basePath = WebTemplate.baseHref(htmlTemplate(fileSystem, 'index.html', _kDefaultIndex)); // Fallback to "application/octet-stream" on null which // makes no claims as to the structure of the data. @@ -101,6 +89,23 @@ class WebAssetServer implements AssetReader { final Map _modules; final Map _digests; + final Completer _readyCompleter = Completer(); + + /// A future that completes when the server is ready to handle requests. + /// + /// Requests received before this future completes are paused by middleware. + Future get isReady => _readyCompleter.future; + + /// Signal that the server is ready to handle incoming requests. + /// + /// This unpauses any HTTP requests that were received while the server was + /// initializing or waiting for initial compilation to complete. + void markReady() { + if (!_readyCompleter.isCompleted) { + _readyCompleter.complete(); + } + } + int get selectedPort => _httpServer.port; /// Given a list of [modules] that need to be loaded, compute module names and @@ -127,8 +132,8 @@ class WebAssetServer implements AssetReader { /// restart or hot reload, writes a file that contains a list of objects each /// with three fields: /// - /// `src`: A string that corresponds to the file path relative to the app base - /// URL root that contains the DDC library bundle. + /// `src`: A root-relative URL path (including [basePath], i.e. the + /// `` of the app) that contains the DDC library bundle. /// `module`: The name of the library bundle in `src`. /// `libraries`: An array of strings containing the libraries that were /// compiled in `src`. @@ -137,7 +142,7 @@ class WebAssetServer implements AssetReader { /// ```json /// [ /// { - /// "src": "/", + /// "src": "//", /// "module": "", /// "libraries": ["", ""], /// }, @@ -151,18 +156,24 @@ class WebAssetServer implements AssetReader { for (final relativeModulePath in modulePaths) { final metadata = ModuleMetadata.fromJson( json.decode( - utf8.decode(_webMemoryFS.metadataFiles['$relativeModulePath.metadata']!.toList()), - ) - as Map, + utf8.decode(_webMemoryFS.metadataFiles['$relativeModulePath.metadata']!.toList()), + ) as Map, ); final List libraries = metadata.libraries.keys.toList(); moduleToLibrary.add({ // Use only the path for the module so the app can still find it even if // it's in a different domain than the server. + // + // The path must include `basePath` so that the browser requests the + // module through the same `` prefix as the initial load + // (e.g. behind a reverse proxy that only forwards `//**`), + // and so that DWDS can strip the same `basePath` off the resulting + // script URL when mapping it back to a module. + // // TODO(srujzs): We use a `/` prefix to match the path that DWDS gets // when parsing the parsed URL. It may be cleaner to just remove the `/` // in DWDS rather than add it here. - 'src': '/$relativeModulePath', + 'src': basePath.isEmpty ? '/$relativeModulePath' : '/$basePath/$relativeModulePath', 'module': metadata.name, 'libraries': libraries, }); @@ -203,14 +214,10 @@ class WebAssetServer implements AssetReader { required bool useLocalCanvasKit, bool testMode = false, DwdsLauncher dwdsLauncher = Dwds.start, - // TODO(markzipan): Make sure this default value aligns with that in the debugger options. - bool ddcModuleSystem = false, - bool canaryFeatures = false, bool useDwdsWebSocketConnection = false, required FileSystem fileSystem, required Logger logger, required Platform platform, - bool shouldEnableMiddleware = true, Map webDefines = const {}, }) async { final String hostname = webDevServerConfig.host; @@ -219,11 +226,6 @@ class WebAssetServer implements AssetReader { final Map extraHeaders = webDevServerConfig.headers; final List proxy = webDevServerConfig.proxy; - // TODO(srujzs): Remove this assertion when the library bundle format is - // supported without canary mode. - if (ddcModuleSystem) { - assert(canaryFeatures); - } final InternetAddress address; if (hostname == webDevAnyHostDefault) { address = InternetAddress.anyIPv4; @@ -274,8 +276,6 @@ class WebAssetServer implements AssetReader { address, modules, digests, - ddcModuleSystem, - canaryFeatures, webRenderer: webRenderer, useLocalCanvasKit: useLocalCanvasKit, fileSystem: fileSystem, @@ -300,6 +300,13 @@ class WebAssetServer implements AssetReader { return server; } + shelf.Handler waitMiddleware(shelf.Handler innerHandler) { + return (shelf.Request request) async { + await server.isReady; + return innerHandler(request); + }; + } + // In release builds (or wasm builds) deploy a simpler proxy server. if (buildInfo.mode != BuildMode.debug || isWasm) { final releaseAssetServer = ReleaseAssetServer( @@ -307,13 +314,17 @@ class WebAssetServer implements AssetReader { fileSystem: fileSystem, platform: platform, flutterRoot: Cache.flutterRoot, - webBuildDirectory: getWebBuildDirectory(), + webBuildDirectory: getWebBuildDirectory(config: globals.config, fileSystem: fileSystem), basePath: server.basePath, needsCoopCoep: crossOriginIsolation, ); + final shelf.Handler releaseHandler = const shelf.Pipeline() + .addMiddleware(waitMiddleware) + .addMiddleware(proxyMiddleware(proxy, logger)) + .addHandler(releaseAssetServer.handle); runZonedGuarded( () { - shelf.serveRequests(httpServer!, releaseAssetServer.handle); + shelf.serveRequests(httpServer!, releaseHandler); }, (Object e, StackTrace s) { logger.printTrace('Release asset server: error serving requests: $e:$s'); @@ -341,34 +352,20 @@ class WebAssetServer implements AssetReader { return chromium.chromeConnection; }, toolConfiguration: ToolConfiguration( - loadStrategy: ddcModuleSystem - ? FrontendServerDdcLibraryBundleStrategyProvider( - ReloadConfiguration.none, - server, - PackageUriMapper(packageConfig), - digestProvider, - BuildSettings( - appEntrypoint: packageConfig.toPackageUriForWorkspace( - fileSystem.file(entrypoint).absolute.uri, - ), - canaryFeatures: canaryFeatures, - ), - packageConfigPath: buildInfo.packageConfigPath, - reloadedSourcesUri: reloadedSourcesUri, - ).strategy - : FrontendServerRequireStrategyProvider( - ReloadConfiguration.none, - server, - PackageUriMapper(packageConfig), - digestProvider, - BuildSettings( - appEntrypoint: packageConfig.toPackageUriForWorkspace( - fileSystem.file(entrypoint).absolute.uri, - ), - canaryFeatures: canaryFeatures, - ), - packageConfigPath: buildInfo.packageConfigPath, - ).strategy, + loadStrategy: FrontendServerDdcLibraryBundleStrategyProvider( + ReloadConfiguration.none, + server, + PackageUriMapper(packageConfig), + digestProvider, + BuildSettings( + appEntrypoint: packageConfig.toPackageUriForWorkspace( + fileSystem.file(entrypoint).absolute.uri, + ), + canaryFeatures: kDdcCanaryFeatures, + ), + packageConfigPath: buildInfo.packageConfigPath, + reloadedSourcesUri: reloadedSourcesUri, + ).strategy, debugSettings: DebugSettings( enableDebugExtension: true, urlEncoder: urlTunneller, @@ -383,16 +380,17 @@ class WebAssetServer implements AssetReader { // Use DWDS WebSocket-based connection instead of Chrome-based connection for debugging useDwdsWebSocketConnection: useDwdsWebSocketConnection, ); - var pipeline = const shelf.Pipeline(); - if (shouldEnableMiddleware) { - pipeline = pipeline.addMiddleware(dwds.middleware); - } - pipeline = pipeline.addMiddleware(proxyMiddleware(proxy, globals.logger)); - final shelf.Handler dwdsHandler = pipeline.addHandler(server.handleRequest); + final shelf.Handler dwdsHandler = const shelf.Pipeline() + .addMiddleware(dwds.middleware) + .addMiddleware(proxyMiddleware(proxy, logger)) + .addHandler(server.handleRequest); final shelf.Cascade cascade = shelf.Cascade().add(dwds.handler).add(dwdsHandler); + final shelf.Handler serverHandler = const shelf.Pipeline() + .addMiddleware(waitMiddleware) + .addHandler(cascade.handler); runZonedGuarded( () { - shelf.serveRequests(httpServer!, cascade.handler); + shelf.serveRequests(httpServer!, serverHandler); }, (Object e, StackTrace s) { logger.printTrace('Dwds server: error serving requests: $e:$s'); @@ -403,8 +401,6 @@ class WebAssetServer implements AssetReader { return server; } - final bool _ddcModuleSystem; - final bool _canaryFeatures; final Map _webDefines; final String? _baseHref; final HttpServer _httpServer; @@ -526,7 +522,7 @@ class WebAssetServer implements AssetReader { // Try and resolve the path relative to the built asset directory. if (!file.existsSync()) { final Uri potential = fileSystem - .directory(getAssetBuildDirectory()) + .directory(getAssetBuildDirectory(null, fileSystem)) .uri .resolve(requestPath.replaceFirst('assets/', '')); file = fileSystem.file(potential); @@ -576,6 +572,9 @@ class WebAssetServer implements AssetReader { /// Tear down the http server running. Future dispose() async { + if (!_readyCompleter.isCompleted) { + _readyCompleter.complete(); + } if (_dwdsInit) { await dwds.stop(); } @@ -692,7 +691,11 @@ _flutter.buildConfig = ${jsonEncode(buildConfig)}; ); } - // Attempt to resolve `path` to a dart file. + /// File extensions that may legitimately be requested from the project and + /// Flutter SDK roots for source-map resolution. + static const _sourceMapExtensions = {'.dart', '.map'}; + + /// Attempts to resolve [path] to a dart file. File _resolveDartFile(String path) { // Return the actual file objects so that local engine changes are automatically picked up. switch (path) { @@ -700,74 +703,71 @@ _flutter.buildConfig = ${jsonEncode(buildConfig)}; return _resolveDartSdkJsFile; case 'dart_sdk.js.map': return _resolveDartSdkJsMapFile; - } - // This is the special generated entrypoint. - if (path == 'web_entrypoint.dart') { - return entrypointCacheDirectory.childFile('web_entrypoint.dart'); - } - - // If this is a dart file, it must be on the local file system and is - // likely coming from a source map request. The tool doesn't currently - // consider the case of Dart files as assets. - final File dartFile = fileSystem.file(fileSystem.currentDirectory.uri.resolve(path)); - if (dartFile.existsSync()) { - return dartFile; - } + case 'web_entrypoint.dart': + return entrypointCacheDirectory.childFile('web_entrypoint.dart'); + } + + final String extension = fileSystem.path.extension(path); + if (_sourceMapExtensions.contains(extension)) { + // If this is a dart file, it must be on the local file system and is + // likely coming from a source map request. The tool doesn't currently + // consider the case of Dart files as assets. + final File dartFile = fileSystem.file(fileSystem.currentDirectory.uri.resolve(path)); + if (dartFile.existsSync()) { + return dartFile; + } - final List segments = path.split('/'); - if (segments.first.isEmpty) { - segments.removeAt(0); - } + final List segments = path.split('/'); + if (segments.first.isEmpty) { + segments.removeAt(0); + } - // The file might have been a package file which is signaled by a - // `/packages//` request. - if (segments.first == 'packages') { - final Uri? filePath = _packages.resolve( - Uri(scheme: 'package', pathSegments: segments.skip(1)), - ); - if (filePath != null) { - final File packageFile = fileSystem.file(filePath); - if (packageFile.existsSync()) { - return packageFile; + // The file might have been a package file which is signaled by a + // `/packages//` request. + if (segments.first == 'packages') { + final Uri? filePath = _packages.resolve( + Uri(scheme: 'package', pathSegments: segments.skip(1)), + ); + if (filePath != null) { + final File packageFile = fileSystem.file(filePath); + if (packageFile.existsSync()) { + return packageFile; + } } } - } - // Otherwise it must be a Dart SDK source or a Flutter Web SDK source. - final Directory dartSdkParent = fileSystem - .directory( - globals.artifacts!.getArtifactPath( - Artifact.engineDartSdkPath, - platform: TargetPlatform.web_javascript, - ), - ) - .parent; - final File dartSdkFile = fileSystem.file(dartSdkParent.uri.resolve(path)); - if (dartSdkFile.existsSync()) { - return dartSdkFile; - } + // Otherwise it must be a Dart SDK source or a Flutter Web SDK source. + final Directory dartSdkParent = fileSystem + .directory( + globals.artifacts!.getArtifactPath( + Artifact.engineDartSdkPath, + platform: TargetPlatform.web_javascript, + ), + ) + .parent; + final File dartSdkFile = fileSystem.file(dartSdkParent.uri.resolve(path)); + if (dartSdkFile.existsSync()) { + return dartSdkFile; + } - final Directory flutterWebSdk = fileSystem.directory( - globals.artifacts!.getHostArtifact(HostArtifact.flutterWebSdk), - ); - final File webSdkFile = fileSystem.file(flutterWebSdk.uri.resolve(path)); + final Directory flutterWebSdk = fileSystem.directory( + globals.artifacts!.getHostArtifact(HostArtifact.flutterWebSdk), + ); + final File webSdkFile = fileSystem.file(flutterWebSdk.uri.resolve(path)); + if (webSdkFile.existsSync()) { + return webSdkFile; + } + } - return webSdkFile; + return fileSystem.currentDirectory.childFile('.non_existent_file'); } - File get _resolveDartSdkJsFile { - final Map dartSdkArtifactMap = _ddcModuleSystem - ? kDdcLibraryBundleDartSdkJsArtifactMap - : kAmdDartSdkJsArtifactMap; - return fileSystem.file(globals.artifacts!.getHostArtifact(dartSdkArtifactMap[webRenderer]!)); - } + File get _resolveDartSdkJsFile => + fileSystem.file(globals.artifacts!.getHostArtifact(kDDCSdkArtifactMap[webRenderer]!)); - File get _resolveDartSdkJsMapFile { - final Map dartSdkArtifactMap = _ddcModuleSystem - ? kDdcLibraryBundleDartSdkJsMapArtifactMap - : kAmdDartSdkJsMapArtifactMap; - return fileSystem.file(globals.artifacts!.getHostArtifact(dartSdkArtifactMap[webRenderer]!)); - } + File get _resolveDartSdkJsMapFile => fileSystem.file( + globals.artifacts!.getHostArtifact(kDDCSdkSourcemapsArtifactMap[webRenderer]!), + ); @override Future dartSourceContents(String serverPath) async { diff --git a/packages/flutter_tools/lib/src/isolated/web_expression_compiler.dart b/packages/flutter_tools/lib/src/isolated/web_expression_compiler.dart index aff54aef..a1cca231 100644 --- a/packages/flutter_tools/lib/src/isolated/web_expression_compiler.dart +++ b/packages/flutter_tools/lib/src/isolated/web_expression_compiler.dart @@ -15,8 +15,7 @@ import '../convert.dart'; /// /// This is only used in development mode. class WebExpressionCompiler implements ExpressionCompiler { - WebExpressionCompiler(this._generator, {required FileSystem fileSystem}) - : _fileSystem = fileSystem; + WebExpressionCompiler(this._generator, {required this._fileSystem}); final ResidentCompiler _generator; final FileSystem _fileSystem; diff --git a/packages/flutter_tools/lib/src/isolated/web_server_utilities.dart b/packages/flutter_tools/lib/src/isolated/web_server_utilities.dart index 8f6d5721..8c7f230c 100644 --- a/packages/flutter_tools/lib/src/isolated/web_server_utilities.dart +++ b/packages/flutter_tools/lib/src/isolated/web_server_utilities.dart @@ -2,14 +2,10 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:async'; - import 'package:logging/logging.dart' as logging; -import 'package:package_config/package_config.dart'; import '../base/file_system.dart'; import '../base/logger.dart'; -import '../dart/package_map.dart'; import '../web_template.dart'; /// Logs [event] to [logger]. @@ -28,15 +24,6 @@ void log(Logger logger, logging.LogRecord event) { } } -/// Finds and returns the directory of the Dart Web Development Service (DWDS). -/// -/// This function locates the `dwds` package directory using the current -/// package configuration. -Future loadDwdsDirectory(FileSystem fileSystem, Logger logger) async { - final PackageConfig packageConfig = await currentPackageConfig(); - return fileSystem.directory(packageConfig['dwds']!.packageUriRoot); -} - /// Removes the [basePath] from the beginning of [path]. /// /// If [path] does not start with [basePath], this function returns null. diff --git a/packages/flutter_tools/lib/src/license_collector.dart b/packages/flutter_tools/lib/src/license_collector.dart index 820d3678..f625b7b4 100644 --- a/packages/flutter_tools/lib/src/license_collector.dart +++ b/packages/flutter_tools/lib/src/license_collector.dart @@ -24,7 +24,7 @@ import 'base/file_system.dart'; /// example, a package might itself contain code from multiple third-party /// sources, and might need to include a license for each one. class LicenseCollector { - LicenseCollector({required FileSystem fileSystem}) : _fileSystem = fileSystem; + LicenseCollector({required this._fileSystem}); final FileSystem _fileSystem; diff --git a/packages/flutter_tools/lib/src/linux/application_package.dart b/packages/flutter_tools/lib/src/linux/application_package.dart index 6ac5a612..9cb92690 100644 --- a/packages/flutter_tools/lib/src/linux/application_package.dart +++ b/packages/flutter_tools/lib/src/linux/application_package.dart @@ -7,7 +7,6 @@ import '../base/file_system.dart'; import '../build_info.dart'; import '../cmake.dart'; import '../cmake_project.dart'; -import '../globals.dart' as globals; abstract class LinuxApp extends ApplicationPackage { LinuxApp({required String projectBundleId}) : super(id: projectBundleId); @@ -53,7 +52,7 @@ class BuildableLinuxApp extends LinuxApp { @override String executable(BuildMode buildMode, [String? flavor]) { final String? binaryName = getCmakeExecutableName(project); - return globals.fs.path.join( + return project.cmakeFile.fileSystem.path.join( getLinuxBuildDirectory(null, flavor), buildMode.cliName, 'bundle', diff --git a/packages/flutter_tools/lib/src/linux/linux_device.dart b/packages/flutter_tools/lib/src/linux/linux_device.dart index 13a3a738..d84e10bd 100644 --- a/packages/flutter_tools/lib/src/linux/linux_device.dart +++ b/packages/flutter_tools/lib/src/linux/linux_device.dart @@ -83,16 +83,12 @@ class LinuxDevices extends PollingDeviceDiscovery { LinuxDevices({ required Platform platform, required FeatureFlags featureFlags, - required OperatingSystemUtils operatingSystemUtils, - required FileSystem fileSystem, - required ProcessManager processManager, - required Logger logger, + required this._operatingSystemUtils, + required this._fileSystem, + required this._processManager, + required this._logger, }) : _platform = platform, _linuxWorkflow = LinuxWorkflow(platform: platform, featureFlags: featureFlags), - _fileSystem = fileSystem, - _logger = logger, - _processManager = processManager, - _operatingSystemUtils = operatingSystemUtils, super('linux devices'); final Platform _platform; diff --git a/packages/flutter_tools/lib/src/linux/linux_doctor.dart b/packages/flutter_tools/lib/src/linux/linux_doctor.dart index 2024b92c..7213307f 100644 --- a/packages/flutter_tools/lib/src/linux/linux_doctor.dart +++ b/packages/flutter_tools/lib/src/linux/linux_doctor.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:flutter_tools_core/flutter_tools_core.dart'; import 'package:process/process.dart'; import '../base/io.dart'; @@ -17,9 +18,9 @@ class _VersionInfo { /// This should contain a version number. For example: /// "clang version 9.0.1-6+build1" _VersionInfo(this.description) { - final String? versionString = RegExp( - r'[0-9]+\.[0-9]+(?:\.[0-9]+)?', - ).firstMatch(description)?.group(0); + final String? versionString = RegExp(r'[0-9]+\.[0-9]+(?:\.[0-9]+)?') + .firstMatch(description) + ?.group(0); number = Version.parse(versionString); } @@ -32,7 +33,7 @@ class _VersionInfo { /// Information about graphics drivers. class _DriverInformation { - _DriverInformation({required ProcessManager processManager}) : _processManager = processManager; + _DriverInformation({required this._processManager}); final ProcessManager _processManager; var _sections = >[]; @@ -117,10 +118,8 @@ class _DriverInformation { /// A validator that checks for Clang and Make build dependencies. class LinuxDoctorValidator extends DoctorValidator { - LinuxDoctorValidator({required ProcessManager processManager, required UserMessages userMessages}) - : _processManager = processManager, - _userMessages = userMessages, - super('Linux toolchain - develop for Linux desktop'); + LinuxDoctorValidator({required this._processManager, required this._userMessages}) + : super('Linux toolchain - develop for Linux desktop'); final ProcessManager _processManager; final UserMessages _userMessages; @@ -137,7 +136,7 @@ class LinuxDoctorValidator extends DoctorValidator { kPkgConfigBinary: Version(0, 29, 0), }; - final _requiredGLibLibraries = ['glib-2.0', 'gio-2.0']; + final _requiredGtkLibraries = ['gtk+-3.0', 'glib-2.0', 'gio-2.0']; @override Future validateImpl() async { @@ -234,7 +233,7 @@ class LinuxDoctorValidator extends DoctorValidator { // Messages for libraries. { var libraryMissing = false; - for (final String library in _requiredGLibLibraries) { + for (final String library in _requiredGtkLibraries) { if (!await _libraryIsPresent(library)) { libraryMissing = true; break; diff --git a/packages/flutter_tools/lib/src/linux/linux_workflow.dart b/packages/flutter_tools/lib/src/linux/linux_workflow.dart index ccfd301e..80a7be30 100644 --- a/packages/flutter_tools/lib/src/linux/linux_workflow.dart +++ b/packages/flutter_tools/lib/src/linux/linux_workflow.dart @@ -11,9 +11,7 @@ import '../features.dart'; /// This workflow requires the flutter-desktop-embedding as a sibling /// repository to the flutter repo. class LinuxWorkflow implements Workflow { - const LinuxWorkflow({required Platform platform, required FeatureFlags featureFlags}) - : _platform = platform, - _featureFlags = featureFlags; + const LinuxWorkflow({required this._platform, required this._featureFlags}); final Platform _platform; final FeatureFlags _featureFlags; diff --git a/packages/flutter_tools/lib/src/localizations/gen_l10n.dart b/packages/flutter_tools/lib/src/localizations/gen_l10n.dart index 6cf02b5b..5c66dcb9 100644 --- a/packages/flutter_tools/lib/src/localizations/gen_l10n.dart +++ b/packages/flutter_tools/lib/src/localizations/gen_l10n.dart @@ -132,11 +132,6 @@ List generateMethodParameters( } // Similar to above, but is used for passing arguments into helper functions. -List generateMethodArguments(Message message) { - return message.templatePlaceholders.values - .map((Placeholder placeholder) => placeholder.name) - .toList(); -} String generateDateFormattingLogic(Message message, LocaleInfo locale) { if (message.templatePlaceholders.isEmpty) { @@ -350,9 +345,8 @@ String _generateLookupByScriptCode( _addSpaces( localesWithScriptCodes .map((LocaleInfo locale) { - return generateSwitchClauseTemplate( - locale, - ).replaceAll('@(case)', locale.scriptCode!); + return generateSwitchClauseTemplate(locale) + .replaceAll('@(case)', locale.scriptCode!); }) .join('\n'), spaces: 8, @@ -394,9 +388,8 @@ String _generateLookupByCountryCode( _addSpaces( localesWithCountryCodes .map((LocaleInfo locale) { - return generateSwitchClauseTemplate( - locale, - ).replaceAll('@(case)', locale.countryCode!); + return generateSwitchClauseTemplate(locale) + .replaceAll('@(case)', locale.countryCode!); }) .join('\n'), spaces: 4, @@ -1056,16 +1049,26 @@ class LocalizationsGenerator { String _generateSubclass(String className, AppResourceBundle bundle) { final LocaleInfo locale = bundle.locale; - final baseClassName = '$className${LocaleInfo.fromString(locale.languageCode).camelCase()}'; + final languageLocale = LocaleInfo.fromString(locale.languageCode); + var parentLocale = languageLocale; + if (locale.scriptCode != null && locale.countryCode != null) { + parentLocale = _allBundles + .localesForLanguage(locale.languageCode) + .firstWhere( + (LocaleInfo candidate) => + candidate.scriptCode == locale.scriptCode && candidate.countryCode == null, + orElse: () => languageLocale, + ); + } + final baseClassName = '$className${parentLocale.camelCase()}'; - // Only mark a message as unimplemented/untranslated for a regional subclass - // (e.g. en_US) if it is also missing in the parent base locale (e.g. en). - // If it is present in the parent locale, it will be correctly inherited - // at runtime via Dart class inheritance, so it shouldn't be reported as missing. - final parentLocale = LocaleInfo.fromString(locale.languageCode); + // Only mark a message as unimplemented/untranslated if it is missing from + // every locale in the subclass's inheritance chain. _allMessages .where((Message message) { - return message.messages[locale] == null && message.messages[parentLocale] == null; + return message.messages[locale] == null && + message.messages[parentLocale] == null && + message.messages[languageLocale] == null; }) .forEach((Message message) { _addUnimplementedMessage(locale, message.resourceId); @@ -1075,12 +1078,28 @@ class LocalizationsGenerator { .where((Message message) => message.parsedMessages[locale] != null) .map((Message message) => _generateMethod(message, locale)); + final bool hasRegionalSubclass = + locale.scriptCode != null && + locale.countryCode == null && + _allBundles.localesForLanguage(locale.languageCode).any((LocaleInfo candidate) { + return candidate.scriptCode == locale.scriptCode && candidate.countryCode != null; + }); + final parentConstructor = parentLocale == languageLocale ? 'super' : 'super._withLocale'; + final subclassName = '$className${locale.camelCase()}'; + // Preserve the public zero-argument constructor while allowing a regional + // subclass to initialize the locale name through its script parent. + final classMembers = [ + if (hasRegionalSubclass) ' $subclassName._withLocale(String locale): super(locale);', + ...methods, + ]; + return subclassTemplate + .replaceFirst("super('@(localeName)')", "$parentConstructor('@(localeName)')") .replaceAll('@(language)', describeLocale(locale.toString())) .replaceAll('@(baseLanguageClassName)', baseClassName) - .replaceAll('@(class)', '$className${locale.camelCase()}') + .replaceAll('@(class)', subclassName) .replaceAll('@(localeName)', locale.toString()) - .replaceAll('@(methods)', methods.join('\n\n')); + .replaceAll('@(methods)', classMembers.join('\n\n')); } // Generate the AppLocalizations class, its LocalizationsDelegate subclass, diff --git a/packages/flutter_tools/lib/src/localizations/gen_l10n_types.dart b/packages/flutter_tools/lib/src/localizations/gen_l10n_types.dart index 70817f2c..347c92d0 100644 --- a/packages/flutter_tools/lib/src/localizations/gen_l10n_types.dart +++ b/packages/flutter_tools/lib/src/localizations/gen_l10n_types.dart @@ -156,19 +156,6 @@ class L10nParserException extends L10nException { final int charNumber; } -class L10nMissingPlaceholderException extends L10nParserException { - L10nMissingPlaceholderException( - super.error, - super.fileName, - super.messageId, - super.messageString, - super.charNumber, - this.placeholderName, - ); - - final String placeholderName; -} - // One optional named parameter to be used by a NumberFormat. // // Some of the NumberFormat factory constructors have optional named parameters. @@ -232,7 +219,7 @@ class OptionalParameter { class Placeholder { Placeholder(this.resourceId, this.name, Map attributes) : example = _stringAttribute(resourceId, name, attributes, 'example'), - type = _stringAttribute(resourceId, name, attributes, 'type'), + type = _typeAttribute(resourceId, name, attributes), format = _stringAttribute(resourceId, name, attributes, 'format'), optionalParameters = _optionalParameters(resourceId, name, attributes), isCustomDateFormat = _boolAttribute(resourceId, name, attributes, 'isCustomDateFormat'); @@ -259,6 +246,44 @@ class Placeholder { List get dateFormatParts => format?.split(_dateFormatPartsDelimiter) ?? []; bool get hasValidDateFormat => dateFormatParts.every(validDateFormats.contains); + // Matches valid Dart type syntax: + // - Optional library or namespace prefixes, e.g. `prefix.`, `package.prefix.` + // - Leading Dart identifier for the type name (letters, numbers, underscores, $) + // - Optional generic type arguments enclosed in angle brackets, restricted to valid type characters (letters, numbers, underscores, $, commas, spaces, nested angle brackets, and ?) + // - Optional trailing nullability operator `?` + // + // Enforcing full-string anchoring and disallowing wildcard characters prevents code injection + // from crafted ARB placeholder types attempting to break out of generated method parameter declarations. + static final RegExp _validTypeRegExp = RegExp( + r'^([a-zA-Z_$][a-zA-Z0-9_$]*\.)*[a-zA-Z_$][a-zA-Z0-9_$]*(\s*<[a-zA-Z0-9_$,\s<>?]+>)?\??$', + ); + + /// Validates that [type] represents a syntactically valid Dart type identifier. + static bool _isValidType(String type) { + if (type.isEmpty) { + return false; + } + return _validTypeRegExp.hasMatch(type); + } + + /// Parses and validates the "type" attribute for placeholder [name]. + /// + /// Throws an [L10nException] if the type is present but does not conform to + /// valid Dart type syntax. + static String? _typeAttribute(String resourceId, String name, Map attributes) { + final String? type = _stringAttribute(resourceId, name, attributes, 'type'); + if (type == null) { + return null; + } + if (!_isValidType(type)) { + throw L10nException( + 'Invalid placeholder type "$type" for placeholder "$name" in message "$resourceId". ' + 'Placeholder types must be valid Dart type names.', + ); + } + return type; + } + static String? _stringAttribute( String resourceId, String name, diff --git a/packages/flutter_tools/lib/src/localizations/localizations_utils.dart b/packages/flutter_tools/lib/src/localizations/localizations_utils.dart index 02818e65..3d013d0a 100644 --- a/packages/flutter_tools/lib/src/localizations/localizations_utils.dart +++ b/packages/flutter_tools/lib/src/localizations/localizations_utils.dart @@ -8,14 +8,10 @@ import 'package:yaml/yaml.dart'; import '../base/common.dart'; import '../base/file_system.dart'; import '../base/logger.dart'; -import '../globals.dart' as globals; import '../runner/flutter_command.dart'; import 'gen_l10n_types.dart'; import 'language_subtag_registry.dart'; -typedef HeaderGenerator = String Function(String regenerateInstructions); -typedef ConstructorGenerator = String Function(LocaleInfo locale); - int sortFilesByPath(File a, File b) { return a.path.compareTo(b.path); } @@ -538,6 +534,7 @@ LocalizationOptions parseLocalizationsOptionsFromYAML({ LocalizationOptions parseLocalizationsOptionsFromCommand({ required FlutterCommand command, required String defaultArbDir, + required Logger logger, }) { const kSyntheticPackage = 'synthetic-package'; const kFlutterGenNotice = 'http://flutter.dev/to/flutter-gen-deprecation'; @@ -548,7 +545,7 @@ LocalizationOptions parseLocalizationsOptionsFromCommand({ 'See $kFlutterGenNotice.', ); } else { - globals.logger.printWarning( + logger.printWarning( 'The argument "$kSyntheticPackage" no longer has any effect and should ' 'be removed. See $kFlutterGenNotice', ); diff --git a/packages/flutter_tools/lib/src/macos/application_package.dart b/packages/flutter_tools/lib/src/macos/application_package.dart index fc62f7f4..0c535680 100644 --- a/packages/flutter_tools/lib/src/macos/application_package.dart +++ b/packages/flutter_tools/lib/src/macos/application_package.dart @@ -129,10 +129,9 @@ class PrebuiltMacOSApp extends MacOSApp implements PrebuiltApplicationPackage { required this.uncompressedBundle, required this.bundleName, required this.projectBundleId, - required String executable, + required this._executable, required this.applicationPackage, - }) : _executable = executable, - super(projectBundleId: projectBundleId); + }) : super(projectBundleId: projectBundleId); /// The uncompressed bundle of the application. /// diff --git a/packages/flutter_tools/lib/src/macos/build_macos.dart b/packages/flutter_tools/lib/src/macos/build_macos.dart index 23d183d6..38803721 100644 --- a/packages/flutter_tools/lib/src/macos/build_macos.dart +++ b/packages/flutter_tools/lib/src/macos/build_macos.dart @@ -96,6 +96,10 @@ Future buildMacOS({ ); } + if (globals.xcodeProjectInterpreter?.isInstalled != true) { + throwToolExit(globals.userMessages.xcodeMissing); + } + // The .xcworkspace may not exist (e.g. a project using Swift Package Manager // without CocoaPods). When absent, xcodebuild builds the .xcodeproj directly. final Directory? xcodeWorkspace = flutterProject.macos.xcodeWorkspace; @@ -123,6 +127,11 @@ Future buildMacOS({ fileSystem: globals.fs, plistParser: globals.plistParser, config: globals.config, + analytics: globals.analytics, + hostPlatform: globals.platform, + operatingSystemUtils: globals.os, + flutterVersion: globals.flutterVersion, + reportCrashes: !await globals.isRunningOnBot, ), SwiftPackageManagerGitignoreMigration(flutterProject, globals.logger), MetalAPIValidationMigrator.macos(flutterProject.macos, globals.logger), @@ -138,6 +147,8 @@ Future buildMacOS({ fileSystem: globals.fs, logger: globals.logger, cocoapods: globals.cocoaPods, + analytics: globals.analytics, + featureFlags: featureFlags, ); final String buildDirectoryPath = getMacOSBuildDirectory(); @@ -155,11 +166,14 @@ Future buildMacOS({ projectFilename: xcodeProjectName, buildDirectory: flutterBuildDir, ); - final String? scheme = projectInfo?.schemeFor(buildInfo); + if (projectInfo == null) { + throwToolExit('Unable to get Xcode project information.'); + } + final String? scheme = projectInfo.schemeFor(buildInfo); if (scheme == null) { - projectInfo!.reportFlavorNotFoundAndExit(); + projectInfo.reportFlavorNotFoundAndExit(); } - final String? configuration = projectInfo?.buildConfigurationFor(buildInfo, scheme); + final String? configuration = projectInfo.buildConfigurationFor(buildInfo, scheme); if (configuration == null) { throwToolExit('Unable to find expected configuration in Xcode project.'); } @@ -278,7 +292,7 @@ Future buildMacOS({ .fetchDependenciesAndGenerateXcodebuildArgs( flutterProject.macos, globals.fs.directory(buildDirectoryPath), - skipPackageUpdatesAndValidation: false, + skipPackageValidation: false, ); result = await globals.processUtils.stream( [ @@ -339,9 +353,8 @@ Future buildMacOS({ } throwToolExit('Build process failed'); } - final String? applicationBundle = MacOSApp.fromMacOSProject( - flutterProject.macos, - ).applicationBundle(buildInfo); + final String? applicationBundle = MacOSApp.fromMacOSProject(flutterProject.macos) + .applicationBundle(buildInfo); if (applicationBundle != null) { final Directory outputDirectory = globals.fs.directory(applicationBundle); // This output directory is the .app folder itself. diff --git a/packages/flutter_tools/lib/src/macos/cocoapod_utils.dart b/packages/flutter_tools/lib/src/macos/cocoapod_utils.dart index 55f38ff9..5426e7e7 100644 --- a/packages/flutter_tools/lib/src/macos/cocoapod_utils.dart +++ b/packages/flutter_tools/lib/src/macos/cocoapod_utils.dart @@ -19,6 +19,7 @@ Future processPodsIfNeeded( String buildDirectory, BuildMode buildMode, { bool forceCocoaPodsOnly = false, + bool forceSwiftPM = false, }) async { final FlutterProject project = xcodeProject.parent; @@ -35,6 +36,7 @@ Future processPodsIfNeeded( iosPlatform: project.ios.existsSync(), macOSPlatform: project.macos.existsSync(), forceCocoaPodsOnly: forceCocoaPodsOnly, + forceSwiftPM: forceSwiftPM, ); // If there are no plugins and if the project is a not module with an existing diff --git a/packages/flutter_tools/lib/src/macos/cocoapods.dart b/packages/flutter_tools/lib/src/macos/cocoapods.dart index 0fe7a288..fb032695 100644 --- a/packages/flutter_tools/lib/src/macos/cocoapods.dart +++ b/packages/flutter_tools/lib/src/macos/cocoapods.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'dart:ffi' show Abi; + import 'package:file/file.dart'; import 'package:process/process.dart'; import 'package:unified_analytics/unified_analytics.dart'; @@ -99,16 +100,14 @@ class CocoaPods { CocoaPods({ required FileSystem fileSystem, required ProcessManager processManager, - required XcodeProjectInterpreter xcodeProjectInterpreter, + required this._xcodeProjectInterpreter, required Logger logger, required Platform platform, - required Analytics analytics, + required this._analytics, Abi? currentAbi, }) : _fileSystem = fileSystem, _processManager = processManager, - _xcodeProjectInterpreter = xcodeProjectInterpreter, _logger = logger, - _analytics = analytics, _processUtils = ProcessUtils(processManager: processManager, logger: logger), _operatingSystemUtils = OperatingSystemUtils( fileSystem: fileSystem, @@ -540,6 +539,13 @@ class CocoaPods { ), ); if (missingPluginSupportsSwiftPM) { + _analytics.send( + Event.appleUsageEvent( + workflow: 'cocoapod-swiftpm-interdependency-failure', + parameter: requiringPlugin, + result: missingPlugin, + ), + ); return 'Error: A dependency conflict has occurred because $requiringPlugin uses CocoaPods while ' '$missingPlugin uses Swift Package Manager. Please contact the $requiringPlugin ' 'maintainers to request Swift Package Manager adoption.\n\n' diff --git a/packages/flutter_tools/lib/src/macos/cocoapods_validator.dart b/packages/flutter_tools/lib/src/macos/cocoapods_validator.dart index 4983344f..57b3e5d0 100644 --- a/packages/flutter_tools/lib/src/macos/cocoapods_validator.dart +++ b/packages/flutter_tools/lib/src/macos/cocoapods_validator.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter_tools_core/flutter_tools_core.dart'; + import '../base/user_messages.dart'; import '../doctor_validator.dart'; import 'cocoapods.dart'; diff --git a/packages/flutter_tools/lib/src/macos/darwin_dependency_management.dart b/packages/flutter_tools/lib/src/macos/darwin_dependency_management.dart index d333091f..1025ca42 100644 --- a/packages/flutter_tools/lib/src/macos/darwin_dependency_management.dart +++ b/packages/flutter_tools/lib/src/macos/darwin_dependency_management.dart @@ -22,18 +22,13 @@ import 'swift_package_manager.dart'; /// project settings for the dependency manager(s) being used. class DarwinDependencyManagement { DarwinDependencyManagement({ - required FlutterProject project, - required CocoaPods? cocoapods, - required SwiftPackageManager swiftPackageManager, - required FileSystem fileSystem, - required FeatureFlags featureFlags, - required Analytics analytics, - }) : _project = project, - _cocoapods = cocoapods, - _swiftPackageManager = swiftPackageManager, - _fileSystem = fileSystem, - _featureFlags = featureFlags, - _analytics = analytics; + required this._project, + required this._cocoapods, + required this._swiftPackageManager, + required this._fileSystem, + required this._featureFlags, + required this._analytics, + }); final FlutterProject _project; final CocoaPods? _cocoapods; @@ -164,6 +159,7 @@ class DarwinDependencyManagement { /// Validate that plugins are compatible with the project dependency management set up. /// /// This includes the following checks: + /// - Warns when SwiftPM feature flag has been disabled /// - Throws when using a SwiftPM-only plugin but SwiftPM is not enabled /// - Warns when using CocoaPod-only plugins /// - Warns when CocoaPods integration is removable @@ -175,10 +171,18 @@ class DarwinDependencyManagement { required FileSystem fileSystem, required Logger logger, required CocoaPods? cocoapods, + required Analytics analytics, + required FeatureFlags featureFlags, }) async { final bool projectUsesSwiftPM = xcodeProject.usesSwiftPackageManager && xcodeProject.flutterPluginSwiftPackageInProjectSettings; + if (!featureFlags.isSwiftPackageManagerEnabled) { + logger.printWarning( + 'Swift Package Manager is currently disabled. $kSwiftPackageManagerDisabledWarning ' + 'To re-enable it, run "flutter config --enable-swift-package-manager"', + ); + } final swiftPackageOnlyPlugins = []; final cocoapodOnlyPlugins = []; @@ -222,6 +226,7 @@ class DarwinDependencyManagement { cocoapodOnlyPlugins: cocoapodOnlyPlugins, logger: logger, platform: platform, + analytics: analytics, ); await _printRemoveCocoapodIntegrationMessage( @@ -279,6 +284,7 @@ class DarwinDependencyManagement { required List cocoapodOnlyPlugins, required Logger logger, required FlutterDarwinPlatform platform, + required Analytics analytics, }) { if (cocoapodOnlyPlugins.isEmpty) { return; @@ -289,6 +295,11 @@ class DarwinDependencyManagement { 'This will become an error in a future version of Flutter. Please contact the plugin ' 'maintainers to request Swift Package Manager adoption.', ); + for (final plugin in cocoapodOnlyPlugins) { + analytics.send( + Event.appleUsageEvent(workflow: 'cocoapod-only-plugin-warning', parameter: plugin), + ); + } } /// Print a message recommending removing CocoaPod integration when all plugins support SwiftPM. diff --git a/packages/flutter_tools/lib/src/macos/macos_device.dart b/packages/flutter_tools/lib/src/macos/macos_device.dart index dee64414..1e043b24 100644 --- a/packages/flutter_tools/lib/src/macos/macos_device.dart +++ b/packages/flutter_tools/lib/src/macos/macos_device.dart @@ -102,19 +102,13 @@ class MacOSDevice extends DesktopDevice { class MacOSDevices extends PollingDeviceDiscovery { MacOSDevices({ - required Platform platform, - required MacOSWorkflow macOSWorkflow, - required ProcessManager processManager, - required Logger logger, - required FileSystem fileSystem, - required OperatingSystemUtils operatingSystemUtils, - }) : _logger = logger, - _platform = platform, - _macOSWorkflow = macOSWorkflow, - _processManager = processManager, - _fileSystem = fileSystem, - _operatingSystemUtils = operatingSystemUtils, - super('macOS devices'); + required this._platform, + required this._macOSWorkflow, + required this._processManager, + required this._logger, + required this._fileSystem, + required this._operatingSystemUtils, + }) : super('macOS devices'); final MacOSWorkflow _macOSWorkflow; final Platform _platform; diff --git a/packages/flutter_tools/lib/src/macos/macos_ipad_device.dart b/packages/flutter_tools/lib/src/macos/macos_ipad_device.dart index 62557125..5e32bb83 100644 --- a/packages/flutter_tools/lib/src/macos/macos_ipad_device.dart +++ b/packages/flutter_tools/lib/src/macos/macos_ipad_device.dart @@ -72,8 +72,11 @@ class MacOSDesignedForIPadDevice extends DesktopDevice { appId: appId, deviceVmservicePort: filterDevicePort, hostVmservicePort: expectedHostPort, - usesIpv6: ipv6, + // Do not throw on error since DelegateVMServiceDiscoveryForAttach falls + // back to log-based protocol discovery. + throwOnError: false, useDeviceIPAsHost: false, + usesIpv6: ipv6, ); return DelegateVMServiceDiscoveryForAttach([ @@ -119,19 +122,13 @@ class MacOSDesignedForIPadDevice extends DesktopDevice { class MacOSDesignedForIPadDevices extends PollingDeviceDiscovery { MacOSDesignedForIPadDevices({ - required Platform platform, - required IOSWorkflow iosWorkflow, - required ProcessManager processManager, - required Logger logger, - required FileSystem fileSystem, - required OperatingSystemUtils operatingSystemUtils, - }) : _logger = logger, - _platform = platform, - _iosWorkflow = iosWorkflow, - _processManager = processManager, - _fileSystem = fileSystem, - _operatingSystemUtils = operatingSystemUtils, - super('Mac designed for iPad devices'); + required this._platform, + required this._iosWorkflow, + required this._processManager, + required this._logger, + required this._fileSystem, + required this._operatingSystemUtils, + }) : super('Mac designed for iPad devices'); final IOSWorkflow _iosWorkflow; final Platform _platform; diff --git a/packages/flutter_tools/lib/src/macos/macos_workflow.dart b/packages/flutter_tools/lib/src/macos/macos_workflow.dart index 81c3eee5..8d9d1f9e 100644 --- a/packages/flutter_tools/lib/src/macos/macos_workflow.dart +++ b/packages/flutter_tools/lib/src/macos/macos_workflow.dart @@ -8,9 +8,7 @@ import '../features.dart'; /// The macOS-specific implementation of a [Workflow]. class MacOSWorkflow implements Workflow { - const MacOSWorkflow({required Platform platform, required FeatureFlags featureFlags}) - : _platform = platform, - _featureFlags = featureFlags; + const MacOSWorkflow({required this._platform, required this._featureFlags}); final Platform _platform; final FeatureFlags _featureFlags; diff --git a/packages/flutter_tools/lib/src/macos/swift_package_manager.dart b/packages/flutter_tools/lib/src/macos/swift_package_manager.dart index 1339079a..f699e28e 100644 --- a/packages/flutter_tools/lib/src/macos/swift_package_manager.dart +++ b/packages/flutter_tools/lib/src/macos/swift_package_manager.dart @@ -13,6 +13,7 @@ import '../base/process.dart'; import '../base/template.dart'; import '../base/version.dart'; import '../darwin/darwin.dart'; +import '../features.dart'; import '../plugins.dart'; import '../xcode_project.dart'; import 'swift_packages.dart'; @@ -25,10 +26,12 @@ const kFlutterGeneratedPluginSwiftPackageName = 'FlutterGeneratedPluginSwiftPack /// a dependency on the Flutter/FlutterMacOS framework. const kFlutterGeneratedFrameworkSwiftPackageTargetName = 'FlutterFramework'; +const kSwiftPackageCacheDirectoryName = 'SourcePackages'; + const kDisableSwiftPMInstructions = 'You can also disable Swift Package Manager for the project by following these instructions:\n' ' https://docs.flutter.dev/packages-and-plugins/swift-package-manager/for-app-developers#how-to-turn-off-swift-package-manager\n' - 'Disabling Swift Package Manager will not be allowed in a future version of Flutter.\n'; + '$kSwiftPackageManagerDisabledWarning\n'; /// Swift Package Manager is a dependency management solution for iOS and macOS /// applications. @@ -40,16 +43,12 @@ const kDisableSwiftPMInstructions = /// documentation on Swift Package Manager manifest file, Package.swift. class SwiftPackageManager { const SwiftPackageManager({ - required FileSystem fileSystem, - required TemplateRenderer templateRenderer, - required ProcessUtils processUtils, - required Config config, - Logger? logger, - }) : _fileSystem = fileSystem, - _templateRenderer = templateRenderer, - _processUtils = processUtils, - _config = config, - _logger = logger; + required this._fileSystem, + required this._templateRenderer, + required this._processUtils, + required this._config, + this._logger, + }); final FileSystem _fileSystem; final TemplateRenderer _templateRenderer; @@ -166,6 +165,10 @@ class SwiftPackageManager { final targetDependencies = []; final expectedBasenames = {}; + final Set pluginsRequiringPluginNameSymlink = _pluginsRequiringPluginNameSymlink( + plugins: plugins, + platform: platform, + ); for (final plugin in plugins) { final String? pluginSwiftPackageManifestPath = plugin.pluginSwiftPackageManifestPath( _fileSystem, @@ -182,31 +185,31 @@ class SwiftPackageManager { continue; } - // Use the plugin basename as the symlink plugin directory name since the basename has the - // version number in it. This will make the symlink name change when the plugin version - // changes, which forces Xcode to re-process the package manifest. final String basename = _fileSystem.directory(plugin.path).basename; - // Check if the plugin has a dependency on another Flutter plugin. - // If the plugin has a dependency on another plugin, copy the plugin to the SourcePackages - // cache directory and update the manifest to use the versioned path. - final String manifestContent = manifest.readAsStringSync(); - final List<({String original, String replacement})> pluginDependencies = - _getPluginDependencies(manifestContent, plugins); - if (pluginDependencies.isNotEmpty) { + final Link pluginSymlink; + final String linkName; + if (pluginsRequiringPluginNameSymlink.contains(plugin)) { + // When basename can't be used as the symlink path, copy the plugin and update the + // manifest contents so Xcode will re-process the package manifest. packagePath = _copyPluginAndUpdateManifest( plugin: plugin, - pluginDependencies: pluginDependencies, pathRelativeTo: pathRelativeTo, basename: basename, platform: platform, - manifestContent: manifestContent, + manifest: manifest, ); + linkName = plugin.name; + } else { + // Use the plugin basename as the symlink plugin directory name since the basename has the + // version number in it. This will make the symlink name change when the plugin version + // changes, which forces Xcode to re-process the package manifest. + linkName = basename; } + pluginSymlink = symlinkDirectory.childLink(linkName); - final Link pluginSymlink = symlinkDirectory.childLink(basename); _createPluginSymlink(pluginSymlink: pluginSymlink, packagePath: packagePath); - expectedBasenames.add(basename); + expectedBasenames.add(linkName); final String packageRelativePath = _fileSystem.path.relative( pluginSymlink.path, @@ -231,12 +234,62 @@ class SwiftPackageManager { return (packageDependencies, targetDependencies, expectedBasenames); } + /// Determine plugins that need to be symlinked by plugin name instead of basename. + /// + /// Xcode caches Swift package manifests and only re-processes them when the path or the contents + /// of the manifest change. To ensure that the package manifest is re-processed when the Flutter + /// plugin version changes, we use the basename as the symlink plugin directory name since the + /// basename has the version number in it. However, the basename cannot be used in the following + /// scenarios: + /// - The basename does not match the plugin's name (see https://github.com/flutter/flutter/issues/186881). + /// - The plugin is a dependency of another plugin. Inter-plugin dependencies are defined + /// using relative paths and therefore must use the plugin name and not basename. Example: + /// ```swift + /// .package(name: "firebase_core", path: "../firebase_core"), + /// ``` + Set _pluginsRequiringPluginNameSymlink({ + required List plugins, + required FlutterDarwinPlatform platform, + }) { + final Set pluginsToCopy = {}; + for (final plugin in plugins) { + if (!plugin.supportSwiftPackageManagerForPlatform(_fileSystem, platform.name)) { + continue; + } + + // The basename cannot be used when it does not match the plugin's name + // (see https://github.com/flutter/flutter/issues/186881). + final String basename = _fileSystem.directory(plugin.path).basename; + if (basename != plugin.name && !basename.startsWith('${plugin.name}-')) { + pluginsToCopy.add(plugin); + } + + // Plugins that are depended on by other plugins cannot use the basename + // since their dependency paths are relative using the name of the plugin. + final String? manifestPath = plugin.pluginSwiftPackageManifestPath( + _fileSystem, + platform.name, + ); + if (manifestPath == null) { + continue; + } + final File manifest = _fileSystem.file(manifestPath); + final String manifestContent = manifest.readAsStringSync(); + final List pluginDependencies = _getPluginDependencies(manifestContent, plugins); + if (pluginDependencies.isNotEmpty) { + pluginsToCopy.addAll(pluginDependencies); + } + } + + return pluginsToCopy; + } + /// Safely creates a symlink at [pluginSymlink] pointing to [packagePath]. /// /// If a symlink already exists and points to the correct target, creation is skipped /// to avoid potential Xcode parallel target build race conditions. /// If creation fails due to sharing violations or locks (e.g., when Xcode is open), - /// throws a descriptive [ToolExit] advising the user to close Xcode and run "flutter clean". + /// throws a descriptive [ToolExit] advising the user to close Xcode and run "flutter clean --include-xcode-workspace". void _createPluginSymlink({required Link pluginSymlink, required String packagePath}) { final FileSystemEntityType type = _fileSystem.typeSync(pluginSymlink.path, followLinks: false); var skipCreation = false; @@ -277,40 +330,24 @@ class SwiftPackageManager { throwToolExit( 'Failed to create Swift Package plugin symlink at "${pluginSymlink.path}" to "$packagePath":\n' '$e\n' - 'If Xcode is currently open, please close Xcode, run "flutter clean", and try building again.', + 'If Xcode is currently open, please close Xcode, run "flutter clean --include-xcode-workspace", and try building again.', ); } } - /// Checks if the plugin has a dependency on another Flutter plugin and returns a list of paths - /// that should be replaced in the [manifestContent]. + /// Returns a list of [Plugin]s that the given plugin's [manifestContent] declares a dependency on. /// - /// Plugins can declare a SwiftPM dependency on another plugin like this: + /// Plugins can declare a SwiftPM dependency on another plugin in their Package.swift like this: /// ```swift /// dependencies: [ /// .package(name: "plugin_1", path: "../plugin_1") /// ] /// ``` /// - /// However, plugins are symlinked in the [XcodeBasedProject.relativeSwiftPackagesDirectory] - /// using the plugin's basename as the symlink name. The basename for non-path dependencies - /// includes the version number, e.g. "plugin_1-1.0.0". To make the relative path in the - /// manifest match the symlink path, we need to replace the path in the manifest with the - /// symlink path. - /// - /// For example, the manifest would need to updated to: - /// ```swift - /// dependencies: [ - /// .package(name: "plugin_1", path: "../plugin_1-1.0.0") - /// ] - /// ``` - List<({String original, String replacement})> _getPluginDependencies( - String manifestContent, - List plugins, - ) { + List _getPluginDependencies(String manifestContent, List plugins) { final dependencyPattern = RegExp(r'"\.\.\/([^"]+)"'); final Iterable matches = dependencyPattern.allMatches(manifestContent); - final List<({String original, String replacement})> pluginDependencies = []; + final List pluginDependencies = []; if (matches.isNotEmpty) { for (final match in matches) { final String? path = match.group(0); @@ -322,47 +359,46 @@ class SwiftPackageManager { if (pluginDependency == null) { continue; } - final newPath = '"../${_fileSystem.directory(pluginDependency.path).basename}"'; - if (path == newPath) { - continue; - } - pluginDependencies.add((original: path, replacement: newPath)); + pluginDependencies.add(pluginDependency); } } return pluginDependencies; } - /// Copy the [plugin] to the build directory and update the manifest to use the versioned path. - /// Returns the path to the copied plugin. + /// Copy the [plugin] to the build directory and update the manifest with a comment containing + /// the version. Returns the path to the copied plugin. /// /// Plugin must be copied first so that the original plugin in pub cache is not modified. /// /// Throws a [ToolExit] if the plugin cannot be copied or the manifest cannot be updated. String _copyPluginAndUpdateManifest({ required Plugin plugin, - required List<({String original, String replacement})> pluginDependencies, required String pathRelativeTo, required String basename, required FlutterDarwinPlatform platform, - required String manifestContent, + required File manifest, }) { - final String destination = _fileSystem - .directory( - _fileSystem.path.join( - platform.buildDirectory(config: _config, fileSystem: _fileSystem), - 'SourcePackages', - basename, - ), - ) - .absolute - .path; + final String manifestContent = manifest.readAsStringSync(); + final Directory destination = _fileSystem.directory( + _fileSystem.path.join( + platform.buildDirectory(config: _config, fileSystem: _fileSystem), + kSwiftPackageCacheDirectoryName, + basename, + ), + ); + destination.createSync(recursive: true); + final String sourcePath = plugin.path.endsWith('/') ? plugin.path : '${plugin.path}/'; + final String destinationPath = destination.absolute.path; final RunResult result = _processUtils.runSync([ 'rsync', '-8', // Avoid mangling filenames with encodings that do not match the current locale. '-av', // Archive mode and verbose: preserve permissions, ownership, timestamps, etc. '--delete', // Delete files in the destination that are not in the source. - plugin.path, - destination, + // Don't copy the example directory as this can cause rsync to fail or be slow due to + // copying build directories and symlinks + '--exclude=/example/', + sourcePath, + destinationPath, ]); if (result.exitCode != 0) { throwToolExit('Failed to copy plugin ${plugin.name}: \n${result.stdout}\n${result.stderr}'); @@ -371,7 +407,7 @@ class SwiftPackageManager { final String? packagePath = plugin.pluginSwiftPackagePath( _fileSystem, platform.name, - overridePath: destination, + overridePath: destinationPath, ); if (packagePath == null) { throwToolExit('Failed to find path to Package.swift for plugin ${plugin.name}'); @@ -383,13 +419,7 @@ class SwiftPackageManager { 'rsync stdout: \n${result.stdout}\nrsync stderr: \n${result.stderr}', ); } - var newManifestContent = manifestContent; - for (final dependency in pluginDependencies) { - newManifestContent = newManifestContent.replaceAll( - dependency.original, - dependency.replacement, - ); - } + final newManifestContent = '$manifestContent\n\n// Basename of original plugin: $basename'; copiedManifest.writeAsStringSync(newManifestContent); return packagePath; } diff --git a/packages/flutter_tools/lib/src/macos/swift_packages.dart b/packages/flutter_tools/lib/src/macos/swift_packages.dart index c1849f89..38b309ec 100644 --- a/packages/flutter_tools/lib/src/macos/swift_packages.dart +++ b/packages/flutter_tools/lib/src/macos/swift_packages.dart @@ -59,22 +59,15 @@ const _doubleIndent = '$_singleIndent$_singleIndent'; /// for more information about Swift Packages and Package.swift. class SwiftPackage { SwiftPackage({ - required File manifest, - required String name, - required List platforms, - required List products, - required List dependencies, - required List targets, - required TemplateRenderer templateRenderer, - String? swiftCodeBeforePackageDefinition, - }) : _manifest = manifest, - _name = name, - _platforms = platforms, - _products = products, - _dependencies = dependencies, - _targets = targets, - _templateRenderer = templateRenderer, - _swiftCodeBeforePackageDefinition = swiftCodeBeforePackageDefinition; + required this._manifest, + required this._name, + required this._platforms, + required this._products, + required this._dependencies, + required this._targets, + required this._templateRenderer, + this._swiftCodeBeforePackageDefinition, + }); /// [File] for Package.swift. final File _manifest; @@ -331,7 +324,8 @@ class SwiftPackagePackageDependency { // dependencies: [ // .package(name: "image_picker_ios", path: "/path/to/packages/image_picker/image_picker_ios/ios/image_picker_ios"), // ], - return '.package(name: "$name", path: "$path")'; + final String posixPath = path.replaceAll(r'\', '/'); + return '.package(name: "$name", path: "$posixPath")'; } } diff --git a/packages/flutter_tools/lib/src/macos/xcdevice.dart b/packages/flutter_tools/lib/src/macos/xcdevice.dart index 89f3d1a7..5b2c67fd 100644 --- a/packages/flutter_tools/lib/src/macos/xcdevice.dart +++ b/packages/flutter_tools/lib/src/macos/xcdevice.dart @@ -59,7 +59,7 @@ class XCDevice { required Platform platform, required IProxy iproxy, required FileSystem fileSystem, - required Analytics analytics, + required this._analytics, required ShutdownHooks shutdownHooks, @visibleForTesting IOSCoreDeviceControl? coreDeviceControl, XcodeDebug? xcodeDebug, @@ -95,8 +95,7 @@ class XCDevice { fileSystem: fileSystem, ), _iProxy = iproxy, - _xcode = xcode, - _analytics = analytics { + _xcode = xcode { shutdownHooks.addShutdownHook(dispose); _setupDeviceIdentifierByEventStream(); @@ -550,6 +549,9 @@ class XCDevice { var devModeEnabled = true; var isConnected = true; var isPaired = true; + final modelCode = device['modelCode'] as String?; + final operatingSystemVersion = device['operatingSystemVersion'] as String?; + final cpuArchitectureString = device['architecture'] as String?; final Map? errorProperties = _errorProperties(device); if (errorProperties != null) { final String? errorMessage = _parseErrorMessage(errorProperties); @@ -633,11 +635,15 @@ class XCDevice { identifier, name: name, cpuArch: _cpuArchitecture(device), + cpuArchitectureString: cpuArchitectureString, connectionInterface: connectionInterface, isConnected: isConnected, sdkVersion: sdkVersionString, + modelCode: modelCode, + operatingSystemVersion: operatingSystemVersion, iProxy: _iProxy, fileSystem: globals.fs, + fileSystemUtils: globals.fsUtils, logger: _logger, analytics: globals.analytics, iosDeploy: _iosDeploy, @@ -650,6 +656,7 @@ class XCDevice { fileSystem: globals.fs, processUtils: _processUtils, xcodeProjectInterpreter: globals.xcodeProjectInterpreter!, + deviceVersion: Version.parse(sdkVersionString), ), xcodeDebug: _xcodeDebug, xcode: _xcode, diff --git a/packages/flutter_tools/lib/src/macos/xcode.dart b/packages/flutter_tools/lib/src/macos/xcode.dart index edd4fcf6..0d8d7c42 100644 --- a/packages/flutter_tools/lib/src/macos/xcode.dart +++ b/packages/flutter_tools/lib/src/macos/xcode.dart @@ -47,18 +47,14 @@ String getSDKNameForIOSEnvironmentType(EnvironmentType environmentType) { /// A utility class for interacting with Xcode command line tools. class Xcode { Xcode({ - required Platform platform, + required this._platform, required ProcessManager processManager, required Logger logger, - required FileSystem fileSystem, - required XcodeProjectInterpreter xcodeProjectInterpreter, + required this._fileSystem, + required this._xcodeProjectInterpreter, required UserMessages userMessages, - String? flutterRoot, - }) : _platform = platform, - _fileSystem = fileSystem, - _xcodeProjectInterpreter = xcodeProjectInterpreter, - _userMessage = userMessages, - _flutterRoot = flutterRoot, + this._flutterRoot, + }) : _userMessage = userMessages, _processUtils = ProcessUtils(logger: logger, processManager: processManager), _logger = logger; @@ -200,7 +196,7 @@ class Xcode { /// to run it. `devicectl` is made available in Xcode 15. bool get isDevicectlInstalled { if (_isDevicectlInstalled == null) { - if (currentVersion == null || currentVersion!.major < 15) { + if (currentVersion == null) { _isDevicectlInstalled = false; return _isDevicectlInstalled!; } @@ -235,11 +231,11 @@ class Xcode { Future> fetchDependenciesAndGenerateXcodebuildArgs( XcodeBasedProject xcodeProject, Directory buildDirectory, { - bool skipPackageUpdatesAndValidation = true, + bool skipPackageValidation = true, }) async => _xcodeProjectInterpreter.fetchDependenciesAndGenerateXcodebuildArgs( xcodeProject, buildDirectory, - skipPackageUpdatesAndValidation: skipPackageUpdatesAndValidation, + skipPackageValidation: skipPackageValidation, ); Future cc(List args) => _run('cc', args); @@ -301,7 +297,11 @@ class Xcode { } } -EnvironmentType? environmentTypeFromSdkroot(String sdkroot, FileSystem fileSystem) { +/// Determines the iOS [EnvironmentType] from [sdkroot]. +/// +/// Throws a [ToolExit] for unrecognized or non-iOS SDK roots (e.g., `MacOSX.sdk` +/// or `XROS.sdk`). +EnvironmentType environmentTypeFromSdkroot(String sdkroot, FileSystem fileSystem) { // NOTE: If you modify this function, you should likely also update the equivalent implementation in // packages/flutter_tools/templates/add_to_app/darwin/Tools/FlutterToolHelper/FlutterToolHelper.swift.tmpl @@ -310,6 +310,9 @@ EnvironmentType? environmentTypeFromSdkroot(String sdkroot, FileSystem fileSyste if (sdkName.contains('iphone')) { return sdkName.contains('simulator') ? EnvironmentType.simulator : EnvironmentType.physical; } - assert(false); - return null; + throwToolExit( + 'Unsupported iOS SDK root "$sdkroot". Expected an iPhoneOS or iPhoneSimulator SDK. ' + "Flutter only supports building the iOS Runner for iOS; check the target's Base SDK " + '(SDKROOT) and Supported Destinations in Xcode.', + ); } diff --git a/packages/flutter_tools/lib/src/macos/xcode_validator.dart b/packages/flutter_tools/lib/src/macos/xcode_validator.dart index dd4fe11f..34b6e06b 100644 --- a/packages/flutter_tools/lib/src/macos/xcode_validator.dart +++ b/packages/flutter_tools/lib/src/macos/xcode_validator.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter_tools_core/flutter_tools_core.dart'; + import '../base/user_messages.dart'; import '../base/version.dart'; import '../build_info.dart'; @@ -20,13 +22,10 @@ For more information, please visit: class XcodeValidator extends DoctorValidator { XcodeValidator({ - required Xcode xcode, - required IOSSimulatorUtils iosSimulatorUtils, - required UserMessages userMessages, - }) : _xcode = xcode, - _iosSimulatorUtils = iosSimulatorUtils, - _userMessages = userMessages, - super('Xcode - develop for iOS and macOS'); + required this._xcode, + required this._iosSimulatorUtils, + required this._userMessages, + }) : super('Xcode - develop for iOS and macOS'); final Xcode _xcode; final IOSSimulatorUtils _iosSimulatorUtils; @@ -113,11 +112,10 @@ class XcodeValidator extends DoctorValidator { /// with Xcode and must be downloaded and installed separately. /// iOS applications cannot be run without it. Future _validateSimulatorRuntimeInstalled() async { - // Skip this validation if Xcode is not installed, Xcode is a version less - // than 15, simctl is not installed, or if the EULA is not signed. + // Skip this validation if Xcode is not installed, simctl is not installed, + // or if the EULA is not signed. if (!_xcode.isInstalled || _xcode.currentVersion == null || - _xcode.currentVersion!.major < 15 || !_xcode.isSimctlInstalled || !_xcode.eulaSigned) { return null; diff --git a/packages/flutter_tools/lib/src/mdns_discovery.dart b/packages/flutter_tools/lib/src/mdns_discovery.dart index 0840c95b..51187245 100644 --- a/packages/flutter_tools/lib/src/mdns_discovery.dart +++ b/packages/flutter_tools/lib/src/mdns_discovery.dart @@ -17,6 +17,9 @@ import 'convert.dart'; import 'device.dart'; import 'globals.dart' as globals; +// macOS error code for EADDRINUSE. +const _kMacOSAddressAlreadyInUseErrno = 48; + String _missingLocalNetworkPermissionsInstructions(String err) => ''' Flutter could not access the local network. @@ -28,20 +31,30 @@ You can grant this permission in System Settings > Privacy & Security > Local Ne $err '''; +String _portAlreadyInUseInstructions(String err) => + ''' +Flutter could not start mDNS discovery because UDP port 5353 is already in use. + +This can happen if another process on your machine is exclusively using port 5353, or if multiple network interfaces share the same IPv4 subnet. + +You can check which processes are using port 5353 by running: + sudo lsof -i :5353 + +$err +'''; + /// A wrapper around [MDnsClient] to find a Dart VM Service instance. class MDnsVmServiceDiscovery { /// Creates a new [MDnsVmServiceDiscovery] object. /// /// The [_client] parameter will be defaulted to a new [MDnsClient] if null. MDnsVmServiceDiscovery({ + required this._analytics, + required this._logger, MDnsClient? mdnsClient, MDnsClient? preliminaryMDnsClient, - required Logger logger, - required Analytics analytics, }) : _client = mdnsClient ?? MDnsClient(), - _preliminaryClient = preliminaryMDnsClient, - _logger = logger, - _analytics = analytics; + _preliminaryClient = preliminaryMDnsClient; final MDnsClient _client; @@ -92,9 +105,9 @@ class MDnsVmServiceDiscovery { String? applicationId, int? deviceVmservicePort, bool ipv6 = false, - bool useDeviceIPAsHost = false, + bool throwOnError = true, Duration timeout = const Duration(minutes: 10), - bool throwOnMissingLocalNetworkPermissionsError = true, + bool useDeviceIPAsHost = false, }) async { // Poll for 5 seconds to see if there are already services running. // Use a new instance of MDnsClient so results don't get cached in _client. @@ -106,9 +119,9 @@ class MDnsVmServiceDiscovery { applicationId: applicationId, deviceVmServicePort: deviceVmservicePort, ipv6: ipv6, - useDeviceIPAsHost: useDeviceIPAsHost, + throwOnError: throwOnError, timeout: const Duration(seconds: 5), - throwOnMissingLocalNetworkPermissionsError: throwOnMissingLocalNetworkPermissionsError, + useDeviceIPAsHost: useDeviceIPAsHost, ); if (results.isEmpty) { return firstMatchingVmService( @@ -116,8 +129,9 @@ class MDnsVmServiceDiscovery { applicationId: applicationId, deviceVmservicePort: deviceVmservicePort, ipv6: ipv6, - useDeviceIPAsHost: useDeviceIPAsHost, + throwOnError: throwOnError, timeout: timeout, + useDeviceIPAsHost: useDeviceIPAsHost, ); } else if (results.length > 1) { final buffer = StringBuffer(); @@ -164,12 +178,12 @@ class MDnsVmServiceDiscovery { @visibleForTesting Future queryForLaunch({ required String applicationId, - int? deviceVmservicePort, String? deviceName, + int? deviceVmservicePort, bool ipv6 = false, - bool useDeviceIPAsHost = false, + bool throwOnError = true, Duration timeout = const Duration(minutes: 10), - bool throwOnMissingLocalNetworkPermissionsError = true, + bool useDeviceIPAsHost = false, }) async { // Either the device port or the device name must be provided. assert(deviceVmservicePort != null || deviceName != null); @@ -178,12 +192,12 @@ class MDnsVmServiceDiscovery { return firstMatchingVmService( _client, applicationId: applicationId, - deviceVmservicePort: deviceVmservicePort, deviceName: deviceName, + deviceVmservicePort: deviceVmservicePort, ipv6: ipv6, - useDeviceIPAsHost: useDeviceIPAsHost, + throwOnError: throwOnError, timeout: timeout, - throwOnMissingLocalNetworkPermissionsError: throwOnMissingLocalNetworkPermissionsError, + useDeviceIPAsHost: useDeviceIPAsHost, ); } @@ -194,23 +208,23 @@ class MDnsVmServiceDiscovery { Future firstMatchingVmService( MDnsClient client, { String? applicationId, - int? deviceVmservicePort, String? deviceName, + int? deviceVmservicePort, bool ipv6 = false, - bool useDeviceIPAsHost = false, + bool throwOnError = true, Duration timeout = const Duration(minutes: 10), - bool throwOnMissingLocalNetworkPermissionsError = true, + bool useDeviceIPAsHost = false, }) async { final List results = await _pollingVmService( client, applicationId: applicationId, - deviceVmServicePort: deviceVmservicePort, deviceName: deviceName, + deviceVmServicePort: deviceVmservicePort, ipv6: ipv6, - useDeviceIPAsHost: useDeviceIPAsHost, - timeout: timeout, quitOnFind: true, - throwOnMissingLocalNetworkPermissionsError: throwOnMissingLocalNetworkPermissionsError, + throwOnError: throwOnError, + timeout: timeout, + useDeviceIPAsHost: useDeviceIPAsHost, ); if (results.isEmpty) { return null; @@ -220,14 +234,14 @@ class MDnsVmServiceDiscovery { Future> _pollingVmService( MDnsClient client, { + required Duration timeout, String? applicationId, - int? deviceVmServicePort, String? deviceName, + int? deviceVmServicePort, bool ipv6 = false, - bool useDeviceIPAsHost = false, - required Duration timeout, bool quitOnFind = false, - bool throwOnMissingLocalNetworkPermissionsError = true, + bool throwOnError = true, + bool useDeviceIPAsHost = false, }) async { // macOS blocks mDNS unless the app has Local Network permissions. // Since the mDNS client does not handle errors from the socket's stream, @@ -267,12 +281,14 @@ class MDnsVmServiceDiscovery { if (!globals.platform.isMacOS) { rethrow; } - - _logger.printTrace(stackTrace.toString()); - if (throwOnMissingLocalNetworkPermissionsError) { - throwToolExit(_missingLocalNetworkPermissionsInstructions(e.toString())); + _logger.printTrace('mDNS discovery failed: $e\n$stackTrace'); + if (throwOnError) { + final String message = switch (e.osError?.errorCode) { + _kMacOSAddressAlreadyInUseErrno => _portAlreadyInUseInstructions(e.toString()), + _ => _missingLocalNetworkPermissionsInstructions(e.toString()), + }; + throwToolExit(message); } else { - _logger.printError(_missingLocalNetworkPermissionsInstructions(e.toString())); return []; } } @@ -435,9 +451,8 @@ class MDnsVmServiceDiscovery { String _getAuthCode(String txtRecord) { const authCodePrefix = 'authCode='; - final Iterable matchingRecords = LineSplitter.split( - txtRecord, - ).where((String record) => record.startsWith(authCodePrefix)); + final Iterable matchingRecords = LineSplitter.split(txtRecord) + .where((String record) => record.startsWith(authCodePrefix)); if (matchingRecords.isEmpty) { return ''; } @@ -463,18 +478,20 @@ class MDnsVmServiceDiscovery { Future getVMServiceUriForAttach( String? applicationId, Device device, { - bool usesIpv6 = false, - int? hostVmservicePort, int? deviceVmservicePort, - bool useDeviceIPAsHost = false, + int? hostVmservicePort, + bool throwOnError = true, Duration timeout = const Duration(minutes: 10), + bool useDeviceIPAsHost = false, + bool usesIpv6 = false, }) async { final MDnsVmServiceDiscoveryResult? result = await queryForAttach( applicationId: applicationId, deviceVmservicePort: deviceVmservicePort, ipv6: usesIpv6, - useDeviceIPAsHost: useDeviceIPAsHost, + throwOnError: throwOnError, timeout: timeout, + useDeviceIPAsHost: useDeviceIPAsHost, ); return _handleResult( result, @@ -499,21 +516,21 @@ class MDnsVmServiceDiscovery { Future getVMServiceUriForLaunch( String applicationId, Device device, { - bool usesIpv6 = false, - int? hostVmservicePort, int? deviceVmservicePort, - bool useDeviceIPAsHost = false, + int? hostVmservicePort, + bool throwOnError = true, Duration timeout = const Duration(minutes: 10), - bool throwOnMissingLocalNetworkPermissionsError = true, + bool useDeviceIPAsHost = false, + bool usesIpv6 = false, }) async { final MDnsVmServiceDiscoveryResult? result = await queryForLaunch( applicationId: applicationId, - deviceVmservicePort: deviceVmservicePort, deviceName: deviceVmservicePort == null ? device.name : null, + deviceVmservicePort: deviceVmservicePort, ipv6: usesIpv6, - useDeviceIPAsHost: useDeviceIPAsHost, + throwOnError: throwOnError, timeout: timeout, - throwOnMissingLocalNetworkPermissionsError: throwOnMissingLocalNetworkPermissionsError, + useDeviceIPAsHost: useDeviceIPAsHost, ); return _handleResult( result, @@ -579,8 +596,8 @@ class MDnsVmServiceDiscovery { return; } final TargetPlatform targetPlatform = await device.targetPlatform; - switch (targetPlatform) { - case TargetPlatform.ios: + switch (targetPlatform.os) { + case .ios: _analytics.send( Event.appleUsageEvent(workflow: 'ios-mdns', parameter: 'no-ipv4-link-local'), ); @@ -591,22 +608,9 @@ class MDnsVmServiceDiscovery { 'under System Preferences > Network > iPhone USB. ' 'See https://github.com/flutter/flutter/issues/46698 for details.', ); - case TargetPlatform.android: - case TargetPlatform.android_arm: - case TargetPlatform.android_arm64: - case TargetPlatform.android_x64: - case TargetPlatform.darwin: - case TargetPlatform.fuchsia_arm64: - case TargetPlatform.fuchsia_x64: - case TargetPlatform.linux_arm64: - case TargetPlatform.linux_riscv64: - case TargetPlatform.linux_x64: - case TargetPlatform.tester: - case TargetPlatform.web_javascript: - case TargetPlatform.windows_x64: - case TargetPlatform.windows_arm64: + case .android || .macos || .fuchsia || .linux || .tester || .web || .windows: _logger.printTrace('No interface with an ipv4 link local address was found.'); - case TargetPlatform.unsupported: + case .unsupported: TargetPlatform.throwUnsupportedTarget(); } } diff --git a/packages/flutter_tools/lib/src/migrations/analysis_options_migration.dart b/packages/flutter_tools/lib/src/migrations/analysis_options_migration.dart index 0d6fbebd..731c0b01 100644 --- a/packages/flutter_tools/lib/src/migrations/analysis_options_migration.dart +++ b/packages/flutter_tools/lib/src/migrations/analysis_options_migration.dart @@ -2,19 +2,25 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:package_config/package_config.dart'; import 'package:yaml/yaml.dart'; import 'package:yaml_edit/yaml_edit.dart'; import '../base/file_system.dart'; import '../base/project_migrator.dart'; +import '../dart/package_map.dart'; import '../project.dart'; /// Migrates analysis_options.yaml to exclude build and platform directories. class AnalysisOptionsMigration extends ProjectMigrator { - AnalysisOptionsMigration(FlutterProject project, super.logger) - : _analysisOptionsFile = project.directory.childFile('analysis_options.yaml'); + AnalysisOptionsMigration(FlutterProject project, super.logger, {this._packageConfig}) + : _project = project, + _analysisOptionsFile = project.directory.childFile('analysis_options.yaml'); + final FlutterProject _project; final File _analysisOptionsFile; + final PackageConfig? _packageConfig; + PackageConfig? _loadedPackageConfig; @override Future migrate() async { @@ -22,6 +28,14 @@ class AnalysisOptionsMigration extends ProjectMigrator { return; } + // A pure Dart package (no `flutter` dependency) has no platform scaffold + // directories, so `web/`, `android/`, etc. are ordinary source or asset + // directories rather than generated platform code. Excluding them here + // would silently drop them from analysis. + if (!_project.manifest.dependencies.contains('flutter')) { + return; + } + final String originalContent = _analysisOptionsFile.readAsStringSync(); final YamlNode root; try { @@ -36,35 +50,25 @@ class AnalysisOptionsMigration extends ProjectMigrator { return; } - const excludesToExclude = [ + final excludesToExclude = [ 'build/**', - 'android/**', - 'ios/**', - 'web/**', - 'windows/**', - 'macos/**', - 'linux/**', + if (_project.android.existsSync()) 'android/**', + if (_project.ios.existsSync()) 'ios/**', + if (_project.web.existsSync()) 'web/**', + if (_project.windows.existsSync()) 'windows/**', + if (_project.macos.existsSync()) 'macos/**', + if (_project.linux.existsSync()) 'linux/**', ]; - var needsMigration = false; - final Object? analyzer = root['analyzer']; - if (analyzer is! YamlMap) { - needsMigration = true; - } else { - final exclude = analyzer['exclude'] as Object?; - if (exclude is! YamlList) { - needsMigration = true; - } else { - for (final requiredExclude in excludesToExclude) { - if (!exclude.contains(requiredExclude)) { - needsMigration = true; - break; - } - } + final Set activeExcludes = await _collectExcludes(_analysisOptionsFile); + final missingExcludes = []; + for (final requiredExclude in excludesToExclude) { + if (!activeExcludes.contains(requiredExclude)) { + missingExcludes.add(requiredExclude); } } - if (!needsMigration) { + if (missingExcludes.isEmpty) { return; } @@ -73,18 +77,28 @@ class AnalysisOptionsMigration extends ProjectMigrator { ); final editor = YamlEditor(originalContent); + final Object? analyzer = root['analyzer']; try { if (analyzer is! YamlMap) { - editor.update(['analyzer'], {'exclude': excludesToExclude}); + editor.update(['analyzer'], {'exclude': missingExcludes}); } else { final exclude = analyzer['exclude'] as Object?; if (exclude is! YamlList) { - editor.update(['analyzer', 'exclude'], excludesToExclude); + editor.update(['analyzer', 'exclude'], missingExcludes); + } else if (exclude.style == CollectionStyle.FLOW) { + // Workaround for https://github.com/dart-lang/tools/issues/2532. + // Appending to a multiline flow-style list with a trailing comma crashes YamlEditor. + // Instead, rewrite the entire exclude list as a block list. + final newExcludes = [ + ...exclude, + ...missingExcludes.where((String item) => !exclude.contains(item)), + ]; + editor.update(['analyzer', 'exclude'], newExcludes); } else { - for (final requiredExclude in excludesToExclude) { - if (!exclude.contains(requiredExclude)) { - editor.appendToList(['analyzer', 'exclude'], requiredExclude); + for (final missingExclude in missingExcludes) { + if (!exclude.contains(missingExclude)) { + editor.appendToList(['analyzer', 'exclude'], missingExclude); } } } @@ -94,4 +108,109 @@ class AnalysisOptionsMigration extends ProjectMigrator { logger.printError('Failed to migrate analysis_options.yaml: $e'); } } + + /// Recursively collects analyzer `exclude:` patterns from [file] and any + /// configuration files referenced via `include:` directives. + /// + /// Supports both relative paths and `package:` URIs, handling single string + /// or list-based include directives while tracking [visited] canonical paths + /// to prevent infinite recursion on cyclic includes. + Future> _collectExcludes(File file, {Set visited = const {}}) async { + final String canonicalPath = file.fileSystem.path.canonicalize(file.path); + if (visited.contains(canonicalPath)) { + return {}; // Avoid cycles + } + if (!file.existsSync()) { + return {}; + } + final String content; + try { + content = file.readAsStringSync(); + } on FileSystemException { + return {}; + } + final YamlNode root; + try { + root = loadYamlNode(content); + } on YamlException { + return {}; + } + if (root is! YamlMap) { + return {}; + } + + final excludes = {}; + final Object? analyzer = root['analyzer']; + if (analyzer is YamlMap) { + final Object? exclude = analyzer['exclude']; + if (exclude is YamlList) { + excludes.addAll(exclude.whereType()); + } + } + + final Object? include = root['include']; + final includes = []; + if (include is String) { + includes.add(include); + } else if (include is YamlList) { + includes.addAll(include.whereType()); + } + + for (final includeUri in includes) { + final File? includedFile = await _resolveInclude(includeUri, file); + if (includedFile != null) { + excludes.addAll( + await _collectExcludes(includedFile, visited: {...visited, canonicalPath}), + ); + } + } + + return excludes; + } + + Future _resolveInclude(String includeUri, File referringFile) async { + if (includeUri.startsWith('package:')) { + final PackageConfig? packageConfig = await _getPackageConfig(); + if (packageConfig == null) { + return null; + } + try { + final Uri? resolvedUri = packageConfig.resolve(Uri.parse(includeUri)); + if (resolvedUri != null && resolvedUri.scheme == 'file') { + return referringFile.fileSystem.file(resolvedUri); + } + } on FormatException { + // Invalid URI + } + return null; + } else { + // Relative path + final String path = referringFile.fileSystem.path.join(referringFile.parent.path, includeUri); + return referringFile.fileSystem.file(path); + } + } + + Future _getPackageConfig() async { + if (_packageConfig != null) { + return _packageConfig; + } + if (_loadedPackageConfig != null) { + return _loadedPackageConfig; + } + final File? configFile = findPackageConfigFile(_project.directory); + if (configFile == null) { + return null; + } + try { + _loadedPackageConfig = await loadPackageConfigWithLogging( + configFile, + logger: logger, + throwOnError: false, + ); + return _loadedPackageConfig; + } on Exception { + // Ignore errors loading package config during migration checks. + return null; + } + } } diff --git a/packages/flutter_tools/lib/src/migrations/cmake_native_assets_migration.dart b/packages/flutter_tools/lib/src/migrations/cmake_native_assets_migration.dart index f32d2320..d65334a3 100644 --- a/packages/flutter_tools/lib/src/migrations/cmake_native_assets_migration.dart +++ b/packages/flutter_tools/lib/src/migrations/cmake_native_assets_migration.dart @@ -9,7 +9,7 @@ import '../cmake_project.dart'; /// Adds the snippet to the CMake file that copies the native assets. /// /// ```cmake -/// # Copy the native assets provided by the build.dart from all packages. +/// # Copy the native assets provided by the hook/build.dart from all packages. /// set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") /// install(DIRECTORY "${NATIVE_ASSETS_DIR}" /// DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" @@ -39,7 +39,7 @@ class CmakeNativeAssetsMigration extends ProjectMigrator { final copyNativeAssetsCommand = ''' -# Copy the native assets provided by the build.dart from all packages. +# Copy the native assets provided by the hook/build.dart from all packages. set(NATIVE_ASSETS_DIR "\${PROJECT_BUILD_DIR}native_assets/$os/") install(DIRECTORY "\${NATIVE_ASSETS_DIR}" DESTINATION "\${INSTALL_BUNDLE_LIB_DIR}" diff --git a/packages/flutter_tools/lib/src/migrations/cocoapods_script_symlink.dart b/packages/flutter_tools/lib/src/migrations/cocoapods_script_symlink.dart index 48fc620a..38890ea8 100644 --- a/packages/flutter_tools/lib/src/migrations/cocoapods_script_symlink.dart +++ b/packages/flutter_tools/lib/src/migrations/cocoapods_script_symlink.dart @@ -4,7 +4,6 @@ import '../base/file_system.dart'; import '../base/project_migrator.dart'; -import '../base/version.dart'; import '../ios/xcodeproj.dart'; import '../xcode_project.dart'; @@ -33,13 +32,9 @@ class CocoaPodsScriptReadlink extends ProjectMigrator { return; } - final Version? version = _xcodeProjectInterpreter.version; - - // If Xcode not installed or less than 14.3 with readlink behavior change, skip this migration. - if (version == null || version < Version(14, 3, 0)) { - logger.printTrace( - 'Detected Xcode version is $version, below 14.3, skipping "readlink -f" workaround.', - ); + // If Xcode not installed, skip this migration. + if (!_xcodeProjectInterpreter.isInstalled) { + logger.printTrace('Xcode is not installed, skipping "readlink -f" workaround.'); return; } diff --git a/packages/flutter_tools/lib/src/migrations/cocoapods_toolchain_directory_migration.dart b/packages/flutter_tools/lib/src/migrations/cocoapods_toolchain_directory_migration.dart index 3221a5fe..938bd265 100644 --- a/packages/flutter_tools/lib/src/migrations/cocoapods_toolchain_directory_migration.dart +++ b/packages/flutter_tools/lib/src/migrations/cocoapods_toolchain_directory_migration.dart @@ -4,7 +4,6 @@ import '../base/file_system.dart'; import '../base/project_migrator.dart'; -import '../base/version.dart'; import '../ios/xcodeproj.dart'; import '../xcode_project.dart'; @@ -34,13 +33,9 @@ class CocoaPodsToolchainDirectoryMigration extends ProjectMigrator { return; } - final Version? version = _xcodeProjectInterpreter.version; - - // If Xcode not installed or less than 15, skip this migration. - if (version == null || version < Version(15, 0, 0)) { - logger.printTrace( - 'Detected Xcode version is $version, below 15.0, skipping TOOLCHAIN_DIR workaround.', - ); + // If Xcode not installed, skip this migration. + if (!_xcodeProjectInterpreter.isInstalled) { + logger.printTrace('Xcode is not installed, skipping TOOLCHAIN_DIR workaround.'); return; } diff --git a/packages/flutter_tools/lib/src/migrations/lldb_init_migration.dart b/packages/flutter_tools/lib/src/migrations/lldb_init_migration.dart index 28e0a08c..b28acd18 100644 --- a/packages/flutter_tools/lib/src/migrations/lldb_init_migration.dart +++ b/packages/flutter_tools/lib/src/migrations/lldb_init_migration.dart @@ -16,15 +16,12 @@ class LLDBInitMigration extends ProjectMigrator { IosProject project, BuildInfo buildInfo, super.logger, { - required FileSystem fileSystem, - required EnvironmentType environmentType, - String? deviceID, + required this._fileSystem, + required this._environmentType, + this._deviceID, }) : _xcodeProject = project, _buildInfo = buildInfo, - _xcodeProjectInfoFile = project.xcodeProjectInfoFile, - _fileSystem = fileSystem, - _environmentType = environmentType, - _deviceID = deviceID; + _xcodeProjectInfoFile = project.xcodeProjectInfoFile; final IosProject _xcodeProject; final BuildInfo _buildInfo; diff --git a/packages/flutter_tools/lib/src/migrations/swift_package_manager_integration_migration.dart b/packages/flutter_tools/lib/src/migrations/swift_package_manager_integration_migration.dart index 1e5c8286..85c0b60e 100644 --- a/packages/flutter_tools/lib/src/migrations/swift_package_manager_integration_migration.dart +++ b/packages/flutter_tools/lib/src/migrations/swift_package_manager_integration_migration.dart @@ -2,13 +2,18 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:meta/meta.dart'; +import 'package:unified_analytics/unified_analytics.dart'; import 'package:xml/xml.dart'; +import '../base/async_guard.dart'; import '../base/common.dart'; import '../base/config.dart'; import '../base/error_handling_io.dart'; import '../base/file_system.dart'; import '../base/logger.dart'; +import '../base/os.dart'; +import '../base/platform.dart'; import '../base/project_migrator.dart'; import '../build_info.dart'; import '../convert.dart'; @@ -18,6 +23,8 @@ import '../ios/xcodeproj.dart'; import '../macos/swift_package_manager.dart'; import '../plugins.dart'; import '../project.dart'; +import '../reporting/crash_reporting.dart'; +import '../version.dart'; /// Swift Package Manager integration requires changes to the Xcode project's /// project.pbxproj and xcscheme. This class handles making those changes. @@ -26,19 +33,20 @@ class SwiftPackageManagerIntegrationMigration extends ProjectMigrator { XcodeBasedProject project, FlutterDarwinPlatform platform, BuildInfo buildInfo, { - required XcodeProjectInterpreter xcodeProjectInterpreter, + required this._xcodeProjectInterpreter, required Logger logger, - required FileSystem fileSystem, - required PlistParser plistParser, - required Config config, + required this._fileSystem, + required this._plistParser, + required this._config, + required this._analytics, + required this._hostPlatform, + required this._operatingSystemUtils, + required this._flutterVersion, + required this._reportCrashes, }) : _xcodeProject = project, _platform = platform, _buildInfo = buildInfo, _xcodeProjectInfoFile = project.xcodeProjectInfoFile, - _xcodeProjectInterpreter = xcodeProjectInterpreter, - _fileSystem = fileSystem, - _plistParser = plistParser, - _config = config, super(logger); final XcodeBasedProject _xcodeProject; @@ -49,6 +57,11 @@ class SwiftPackageManagerIntegrationMigration extends ProjectMigrator { final File _xcodeProjectInfoFile; final PlistParser _plistParser; final Config _config; + final Analytics _analytics; + final Platform _hostPlatform; + final OperatingSystemUtils _operatingSystemUtils; + final FlutterVersion _flutterVersion; + final bool _reportCrashes; /// New identifier for FlutterGeneratedPluginSwiftPackage PBXBuildFile. static const _flutterPluginsSwiftPackageBuildFileIdentifier = '78A318202AECB46A00862997'; @@ -134,6 +147,8 @@ class SwiftPackageManagerIntegrationMigration extends ProjectMigrator { /// If the app is not an example app or the plugin cannot be found, this will return null. late final ({String name, String path})? _examplePlugin; + String _flutterVersionString() => _flutterVersion.getVersionString(redactUnknownBranches: true); + void restoreFromBackup(SchemeInfo? schemeInfo) { if (backupProjectSettings.existsSync()) { logger.printTrace('Restoring project settings from backup file...'); @@ -171,7 +186,7 @@ class SwiftPackageManagerIntegrationMigration extends ProjectMigrator { var optionalOnly = false; try { if (!_xcodeProjectInfoFile.existsSync()) { - throw Exception('Xcode project not found.'); + throw SwiftPackageManagerMigrationException('Xcode project not found.'); } _examplePlugin = await _loadPluginFromExampleProject( @@ -219,23 +234,76 @@ class SwiftPackageManagerIntegrationMigration extends ProjectMigrator { // If pbxproj was not already migrated, verify settings were set correctly. if (!isPbxprojMigrated) { if (!_isPbxprojMigratedCorrectly(updatedInfo, logErrorIfNotMigrated: true)) { - throw Exception('Settings were not updated correctly.'); + throw SwiftPackageManagerMigrationException('Settings were not updated correctly.'); } } else if (!isOptionalFilesMigrated) { if (!_areOptionalFilesMigratedCorrectly(updatedInfo, logErrorIfNotMigrated: true)) { - throw Exception('Settings were not updated correctly.'); + throw SwiftPackageManagerMigrationException('Settings were not updated correctly.'); } } // Get the project info to make sure it compiles with xcodebuild - await _xcodeProjectInterpreter.getInfo( - _xcodeProject, - buildDirectory: _fileSystem.directory( - _platform.buildDirectory(config: _config, fileSystem: _fileSystem), + try { + await _xcodeProjectInterpreter.getInfo( + _xcodeProject, + buildDirectory: _fileSystem.directory( + _platform.buildDirectory(config: _config, fileSystem: _fileSystem), + ), + ); + } on Exception catch (e, stackTrace) { + // Send error to crash reporter. + if (_reportCrashes) { + try { + await asyncGuard(() async { + final crashReportSender = CrashReportSender( + platform: _hostPlatform, + logger: logger, + operatingSystemUtils: _operatingSystemUtils, + analytics: _analytics, + ); + await crashReportSender.sendReport( + error: e, + stackTrace: stackTrace, + getFlutterVersion: _flutterVersionString, + command: 'swiftpm-migration', + typeId: 'XcodeBuildError', + ); + }); + } on Exception catch (_) { + // Fail silently if it fails to send crash report + } + } + + final message = e.toString(); + if (message.contains('Could not resolve package dependencies')) { + throw SwiftPackageManagerMigrationException( + message, + analyticsMessage: 'Xcode could not resolve package dependencies.', + ); + } else { + throw SwiftPackageManagerMigrationException( + message, + analyticsMessage: 'Xcode failed for unknown reason.', + ); + } + } + _analytics.send( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-success', + parameter: optionalOnly ? 'optional' : 'full', ), ); } on Exception catch (e) { restoreFromBackup(schemeInfo); + if (e is SwiftPackageManagerMigrationException) { + _analytics.send( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-failure', + parameter: optionalOnly ? 'optional' : 'full', + result: e.analyticsMessage ?? e.userMessage, + ), + ); + } if (optionalOnly) { // This part of the migration is optional. We'll log this for debugging sake but don't // really expect the user to see it. @@ -248,7 +316,7 @@ class SwiftPackageManagerIntegrationMigration extends ProjectMigrator { throwToolExit( 'An error occurred when adding Swift Package Manager integration:\n' ' $e\n\n' - 'Swift Package Manager is currently an experimental feature, please file a bug at\n' + 'To help the Flutter team improve this process, please file a bug at\n' ' https://github.com/flutter/flutter/issues/new?template=01_activation.yml \n' 'Consider including a copy of the following files in your bug report:\n' ' ${_platform.name}/Runner.xcodeproj/project.pbxproj\n' @@ -271,7 +339,7 @@ class SwiftPackageManagerIntegrationMigration extends ProjectMigrator { Future _getSchemeFile() async { final XcodeProjectInfo? projectInfo = await _xcodeProject.projectInfo(); if (projectInfo == null) { - throw Exception('Unable to get Xcode project info.'); + throw SwiftPackageManagerMigrationException('Unable to get Xcode project info.'); } final String? scheme = projectInfo.schemeFor(_buildInfo); if (scheme == null) { @@ -280,7 +348,9 @@ class SwiftPackageManagerIntegrationMigration extends ProjectMigrator { final File schemeFile = _xcodeProject.xcodeProjectSchemeFile(scheme: scheme); if (!schemeFile.existsSync()) { - throw Exception('Unable to get scheme file for $scheme.'); + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => 'Unable to get scheme file for ${sanitize(scheme, .name)}.', + ); } final String schemeContent = schemeFile.readAsStringSync(); @@ -315,7 +385,10 @@ class SwiftPackageManagerIntegrationMigration extends ProjectMigrator { try { document = XmlDocument.parse(schemeContent); } on XmlException catch (exception) { - throw Exception('Failed to parse ${schemeFile.basename}: Invalid xml: $exception'); + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Failed to parse ${sanitize(schemeFile.basename, .basename)}: Invalid xml${sanitize(': $exception', .error)}', + ); } final Iterable buildableReferences = document.findAllElements('BuildableReference'); @@ -327,28 +400,36 @@ class SwiftPackageManagerIntegrationMigration extends ProjectMigrator { } } if (targetReference == null) { - throw Exception( - 'Failed to parse ${schemeFile.basename}: Could not find BuildableReference ' - 'for ${_xcodeProject.hostAppProjectName}.', + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Failed to parse ${sanitize(schemeFile.basename, .basename)}: Could not find BuildableReference ' + 'for ${sanitize(_xcodeProject.hostAppProjectName, .name)}.', ); } final String? buildableNameAttr = targetReference.getAttribute('BuildableName'); if (buildableNameAttr == null) { - throw Exception('Failed to parse ${schemeFile.basename}: Could not find BuildableName.'); + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Failed to parse ${sanitize(schemeFile.basename, .basename)}: Could not find BuildableName.', + ); } final buildableName = 'BuildableName = "$buildableNameAttr"'; final String? blueprintNameAttr = targetReference.getAttribute('BlueprintName'); if (blueprintNameAttr == null) { - throw Exception('Failed to parse ${schemeFile.basename}: Could not find BlueprintName.'); + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Failed to parse ${sanitize(schemeFile.basename, .basename)}: Could not find BlueprintName.', + ); } final blueprintName = 'BlueprintName = "$blueprintNameAttr"'; final String? referencedContainerAttr = targetReference.getAttribute('ReferencedContainer'); if (referencedContainerAttr == null) { - throw Exception( - 'Failed to parse ${schemeFile.basename}: Could not find ReferencedContainer.', + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Failed to parse ${sanitize(schemeFile.basename, .basename)}: Could not find ReferencedContainer.', ); } final referencedContainer = 'ReferencedContainer = "$referencedContainerAttr"'; @@ -360,11 +441,9 @@ class SwiftPackageManagerIntegrationMigration extends ProjectMigrator { final String scriptText; if (_platform == FlutterDarwinPlatform.ios) { - scriptText = - r'scriptText = "/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">'; + scriptText = r'scriptText = "/bin/sh "$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh" prepare ">'; } else { - scriptText = - r'scriptText = ""$FLUTTER_ROOT"/packages/flutter_tools/bin/macos_assemble.sh prepare ">'; + scriptText = r'scriptText = ""$FLUTTER_ROOT"/packages/flutter_tools/bin/macos_assemble.sh prepare ">'; } var newContent = @@ -405,7 +484,10 @@ $newContent .firstOrNull; if (buildAction == null) { - throw Exception('Failed to parse ${schemeFile.basename}: Could not find BuildAction.'); + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Failed to parse ${sanitize(schemeFile.basename, .basename)}: Could not find BuildAction.', + ); } } newScheme = schemeContent.replaceFirst(buildAction, '$newContent$buildAction'); @@ -415,8 +497,9 @@ $newContent try { XmlDocument.parse(newScheme); } on XmlException catch (exception) { - throw Exception( - 'Failed to parse ${schemeFile.basename}: Invalid xml: $newScheme\n$exception', + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Failed to parse ${sanitize(schemeFile.basename, .basename)} after updating: Invalid xml${sanitize(': $newScheme\n$exception', .error)}', ); } } @@ -426,17 +509,23 @@ $newContent ParsedProjectInfo _parsePbxproj() { final String? results = _plistParser.plistJsonContent(_xcodeProjectInfoFile.path); if (results == null) { - throw Exception('Failed to parse project settings.'); + throw SwiftPackageManagerMigrationException('Failed to parse project settings.'); } try { final decodeResult = json.decode(results) as Object; if (decodeResult is! Map) { - throw Exception('project.pbxproj returned unexpected JSON response: $results'); + throw SwiftPackageManagerMigrationException( + 'project.pbxproj returned unexpected JSON response: $results', + analyticsMessage: 'project.pbxproj returned unexpected JSON response', + ); } return ParsedProjectInfo.fromJson(decodeResult); } on FormatException { - throw Exception('project.pbxproj returned non-JSON response: $results'); + throw SwiftPackageManagerMigrationException( + 'project.pbxproj returned non-JSON response: $results', + analyticsMessage: 'project.pbxproj returned non-JSON response', + ); } } @@ -609,19 +698,23 @@ $newContent '$_flutterPluginsSwiftPackageBuildFileIdentifier /* $kFlutterGeneratedPluginSwiftPackageName in Frameworks */', ) && originalProjectContents.contains(_flutterPluginsSwiftPackageBuildFileIdentifier)) { - throw Exception('Duplicate id found for PBXBuildFile.'); + throw SwiftPackageManagerMigrationException('Duplicate id found for PBXBuildFile.'); } if (!originalProjectContents.contains( '$_flutterPluginsSwiftPackageProductDependencyIdentifier /* $kFlutterGeneratedPluginSwiftPackageName */', ) && originalProjectContents.contains(_flutterPluginsSwiftPackageProductDependencyIdentifier)) { - throw Exception('Duplicate id found for XCSwiftPackageProductDependency.'); + throw SwiftPackageManagerMigrationException( + 'Duplicate id found for XCSwiftPackageProductDependency.', + ); } if (!originalProjectContents.contains( '$_localFlutterPluginsSwiftPackageReferenceIdentifier /* XCLocalSwiftPackageReference', ) && originalProjectContents.contains(_localFlutterPluginsSwiftPackageReferenceIdentifier)) { - throw Exception('Duplicate id found for XCLocalSwiftPackageReference.'); + throw SwiftPackageManagerMigrationException( + 'Duplicate id found for XCLocalSwiftPackageReference.', + ); } } @@ -630,7 +723,7 @@ $newContent '$_flutterPluginsSwiftPackageFileIdentifer /* $kFlutterGeneratedPluginSwiftPackageName */', ) && originalProjectContents.contains(_flutterPluginsSwiftPackageFileIdentifer)) { - throw Exception( + throw SwiftPackageManagerMigrationException( 'Duplicate id found for $kFlutterGeneratedPluginSwiftPackageName PBXFileReference.', ); } @@ -639,13 +732,16 @@ $newContent '$_flutterPluginLocalOverrideFileIdenitifier /* ${_examplePlugin.name} */', ) && originalProjectContents.contains(_flutterPluginLocalOverrideFileIdenitifier)) { - throw Exception('Duplicate id found for ${_examplePlugin.name} PBXFileReference.'); + throw SwiftPackageManagerMigrationException( + 'Duplicate id found for ${_examplePlugin.name} PBXFileReference.', + analyticsMessage: 'Duplicate id found for plugin PBXFileReference.', + ); } if (!originalProjectContents.contains( '$_flutterFrameworkLocalOverrideFileIdentifier /* $kFlutterGeneratedFrameworkSwiftPackageTargetName */', ) && originalProjectContents.contains(_flutterFrameworkLocalOverrideFileIdentifier)) { - throw Exception( + throw SwiftPackageManagerMigrationException( 'Duplicate id found for $kFlutterGeneratedFrameworkSwiftPackageTargetName PBXFileReference.', ); } @@ -749,8 +845,9 @@ $newContent ); if (runnerFrameworksPhaseStartIndex == -1 || runnerFrameworksPhaseStartIndex > endSectionIndex) { - throw Exception( - 'Unable to find PBXFrameworksBuildPhase for ${_xcodeProject.hostAppProjectName} target.', + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Unable to find PBXFrameworksBuildPhase for ${sanitize(_xcodeProject.hostAppProjectName, .name)} target.', ); } @@ -765,8 +862,9 @@ $newContent .toList() .firstOrNull; if (runnerFrameworksPhase == null) { - throw Exception( - 'Unable to find parsed PBXFrameworksBuildPhase for ${_xcodeProject.hostAppProjectName} target.', + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Unable to find parsed PBXFrameworksBuildPhase for ${sanitize(_xcodeProject.hostAppProjectName, .name)} target.', ); } @@ -785,8 +883,9 @@ $newContent runnerFrameworksPhaseStartIndex, ); if (startFilesIndex == -1 || startFilesIndex > endSectionIndex) { - throw Exception( - 'Unable to files for PBXFrameworksBuildPhase ${_xcodeProject.hostAppProjectName} target.', + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Unable to find files for PBXFrameworksBuildPhase for ${sanitize(_xcodeProject.hostAppProjectName, .name)} target.', ); } const newContent = @@ -831,8 +930,9 @@ $newContent .where((ParsedNativeTarget target) => target.identifier == _runnerNativeTargetIdentifier) .firstOrNull; if (runnerNativeTarget == null) { - throw Exception( - 'Unable to find parsed PBXNativeTarget for ${_xcodeProject.hostAppProjectName} target.', + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Unable to find parsed PBXNativeTarget for ${sanitize(_xcodeProject.hostAppProjectName, .name)} target.', ); } final String subsectionLineStart = runnerNativeTarget.name != null @@ -843,8 +943,9 @@ $newContent startSectionIndex, ); if (runnerNativeTargetStartIndex == -1 || runnerNativeTargetStartIndex > endSectionIndex) { - throw Exception( - 'Unable to find PBXNativeTarget for ${_xcodeProject.hostAppProjectName} target.', + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Unable to find PBXNativeTarget for ${sanitize(_xcodeProject.hostAppProjectName, .name)} target.', ); } @@ -864,8 +965,9 @@ $newContent ); if (packageProductDependenciesIndex == -1 || packageProductDependenciesIndex > endSectionIndex) { - throw Exception( - 'Unable to find packageProductDependencies for ${_xcodeProject.hostAppProjectName} PBXNativeTarget.', + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Unable to find packageProductDependencies for ${sanitize(_xcodeProject.hostAppProjectName, .name)} PBXNativeTarget.', ); } const newContent = @@ -914,7 +1016,7 @@ $newContent startSectionIndex, ); if (flutterGroupStartIndex == -1 || flutterGroupStartIndex > endSectionIndex) { - throw Exception('Unable to find Flutter PBXGroup.'); + throw SwiftPackageManagerMigrationException('Unable to find Flutter PBXGroup.'); } // Get the Flutter Group from the parsed project info. @@ -923,7 +1025,7 @@ $newContent .toList() .firstOrNull; if (parsedGroup == null) { - throw Exception('Unable to find parsed Flutter PBXGroup.'); + throw SwiftPackageManagerMigrationException('Unable to find parsed Flutter PBXGroup.'); } // Find the children field within the Flutter PBXGroup. @@ -981,7 +1083,10 @@ $newContent startSectionIndex, ); if (projectStartIndex == -1 || projectStartIndex > endSectionIndex) { - throw Exception('Unable to find PBXProject for ${_xcodeProject.hostAppProjectName}.'); + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Unable to find PBXProject for ${sanitize(_xcodeProject.hostAppProjectName, .name)}.', + ); } // Get the Runner project from the parsed project info. @@ -990,7 +1095,10 @@ $newContent .toList() .firstOrNull; if (projectObject == null) { - throw Exception('Unable to find parsed PBXProject for ${_xcodeProject.hostAppProjectName}.'); + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Unable to find parsed PBXProject for ${sanitize(_xcodeProject.hostAppProjectName, .name)}.', + ); } if (projectObject.packageReferences == null) { @@ -1008,8 +1116,9 @@ $newContent projectStartIndex, ); if (packageReferencesIndex == -1 || packageReferencesIndex > endSectionIndex) { - throw Exception( - 'Unable to find packageReferences for ${_xcodeProject.hostAppProjectName} PBXProject.', + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Unable to find packageReferences for ${sanitize(_xcodeProject.hostAppProjectName, .name)} PBXProject.', ); } const newContent = @@ -1062,7 +1171,7 @@ $newContent final int index = lines.lastIndexWhere((String line) => line.trim().startsWith('/* End')); if (index == -1) { - throw Exception('Unable to find any sections.'); + throw SwiftPackageManagerMigrationException('Unable to find any sections.'); } lines.insertAll(index + 1, newContent); @@ -1124,7 +1233,7 @@ $newContent final int index = lines.lastIndexWhere((String line) => line.trim().startsWith('/* End')); if (index == -1) { - throw Exception('Unable to find any sections.'); + throw SwiftPackageManagerMigrationException('Unable to find any sections.'); } lines.insertAll(index + 1, newContent); @@ -1204,14 +1313,18 @@ $newContent (int, int) _sectionRange(String sectionName, List lines, {bool throwIfMissing = true}) { final int startSectionIndex = lines.indexOf('/* Begin $sectionName section */'); if (throwIfMissing && startSectionIndex == -1) { - throw Exception('Unable to find beginning of $sectionName section.'); + throw SwiftPackageManagerMigrationException( + 'Unable to find beginning of $sectionName section.', + ); } final int endSectionIndex = lines.indexOf('/* End $sectionName section */'); if (throwIfMissing && endSectionIndex == -1) { - throw Exception('Unable to find end of $sectionName section.'); + throw SwiftPackageManagerMigrationException('Unable to find end of $sectionName section.'); } if (throwIfMissing && startSectionIndex > endSectionIndex) { - throw Exception('Found the end of $sectionName section before the beginning.'); + throw SwiftPackageManagerMigrationException( + 'Found the end of $sectionName section before the beginning.', + ); } return (startSectionIndex, endSectionIndex); } @@ -1427,3 +1540,69 @@ class ParsedProject { final String identifier; final List? packageReferences; } + +/// Exception thrown when the Swift Package Manager integration migration fails. +@visibleForTesting +class SwiftPackageManagerMigrationException implements Exception { + SwiftPackageManagerMigrationException(this.userMessage, {this.analyticsMessage}); + + /// Creates a [SwiftPackageManagerMigrationException] where both [userMessage] + /// and [analyticsMessage] are built by [builder]. + /// + /// The [userMessage] is created by evaluating [builder] without sanitization. + /// The [analyticsMessage] is created by evaluating [builder] with [sanitizer] + /// to anonymize user-specific identifiers and strip sensitive error details + /// for analytics reporting. + factory SwiftPackageManagerMigrationException.sanitized( + String Function(String Function(String, SanitizeType type) sanitize) builder, { + String Function(String, SanitizeType type) sanitizer = _defaultSanitizer, + }) { + return SwiftPackageManagerMigrationException( + builder((value, type) => value), + analyticsMessage: builder(sanitizer), + ); + } + + /// Default sanitizer used to redact names, file basenames, or error details + /// for analytics reporting. + static String _defaultSanitizer(String name, SanitizeType type) { + switch (type) { + case SanitizeType.name: + return name == 'Runner' ? 'Runner' : 'custom'; + case SanitizeType.basename: + if (name.startsWith('Runner.')) { + return name; + } + final List parts = name.split('.'); + if (parts.length >= 2) { + return 'custom.${parts.last}'; + } + return 'custom'; + case SanitizeType.error: + return ''; + } + } + + /// The message to display to the user. + final String userMessage; + + /// An optional sanitized message for analytics reporting. + final String? analyticsMessage; + + @override + String toString() => userMessage; +} + +/// The type of data to sanitize for analytics reporting in +/// [SwiftPackageManagerMigrationException]. +@visibleForTesting +enum SanitizeType { + /// A target or scheme name. + name, + + /// A file basename (e.g. `Runner.xcscheme`). + basename, + + /// An error message or stack trace. + error, +} diff --git a/packages/flutter_tools/lib/src/migrations/uiscene_migration.dart b/packages/flutter_tools/lib/src/migrations/uiscene_migration.dart index 2303806e..659fb6e4 100644 --- a/packages/flutter_tools/lib/src/migrations/uiscene_migration.dart +++ b/packages/flutter_tools/lib/src/migrations/uiscene_migration.dart @@ -17,11 +17,9 @@ class UISceneMigration extends ProjectMigrator { UISceneMigration( IosProject project, super.logger, { - required bool isMigrationFeatureEnabled, - required PlistParser plistParser, - }) : _isMigrationFeatureEnabled = isMigrationFeatureEnabled, - _project = project, - _plistParser = plistParser; + required this._isMigrationFeatureEnabled, + required this._plistParser, + }) : _project = project; final bool _isMigrationFeatureEnabled; final PlistParser _plistParser; diff --git a/packages/flutter_tools/lib/src/native_assets.dart b/packages/flutter_tools/lib/src/native_assets.dart index 41f4773b..dfcdfcfb 100644 --- a/packages/flutter_tools/lib/src/native_assets.dart +++ b/packages/flutter_tools/lib/src/native_assets.dart @@ -2,14 +2,23 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'asset.dart'; import 'build_info.dart'; import 'project.dart'; +typedef TestCompilerNativeAssetsBuildResult = ({ + Uri? nativeAssetsManifest, + FlutterHookResult? flutterHookResult, +}); + /// An interface to enable overriding native assets build logic in other /// build systems. abstract class TestCompilerNativeAssetsBuilder { Future build(BuildInfo buildInfo); + Future buildWithHookResult(BuildInfo buildInfo) async => + (nativeAssetsManifest: await build(buildInfo), flutterHookResult: null); + /// Returns the Windows native assets build directory. /// /// By prepending the PATH environment variable for the flutter tester with the diff --git a/packages/flutter_tools/lib/src/platform_plugins.dart b/packages/flutter_tools/lib/src/platform_plugins.dart index bc6e7ffa..23b7f54d 100644 --- a/packages/flutter_tools/lib/src/platform_plugins.dart +++ b/packages/flutter_tools/lib/src/platform_plugins.dart @@ -40,7 +40,7 @@ final RegExp _pluginIdentifierPattern = RegExp( /// Whether [value] is a valid native plugin class or dot-separated package /// identifier. Callers first confirm the value is a String via the schema type /// checks; absent fields are not validated here. -bool _isValidPluginIdentifier(String value) => _pluginIdentifierPattern.hasMatch(value); +bool isValidPluginIdentifier(String value) => _pluginIdentifierPattern.hasMatch(value); /// Matches a safe relative Dart source path (e.g. `src/foo_web.dart`) ending in /// `.dart`. Plugin `fileName`/`dartFileName` values are interpolated into an @@ -115,9 +115,8 @@ class AndroidPlugin extends PluginPlatform implements NativeOrDartPlugin { this.dartFileName, bool? ffiPlugin, this.defaultPackage, - required FileSystem fileSystem, - }) : _fileSystem = fileSystem, - ffiPlugin = ffiPlugin ?? false; + required this._fileSystem, + }) : ffiPlugin = ffiPlugin ?? false; factory AndroidPlugin.fromYaml( String name, @@ -125,7 +124,9 @@ class AndroidPlugin extends PluginPlatform implements NativeOrDartPlugin { String pluginPath, FileSystem fileSystem, ) { - assert(validate(yaml)); + if (!validate(yaml)) { + throwToolExit('Invalid "android" plugin specification for plugin "$name".'); + } final dartPluginClass = yaml[kDartPluginClass] as String?; final dartFileName = yaml[kDartFileName] as String?; @@ -178,13 +179,13 @@ class AndroidPlugin extends PluginPlatform implements NativeOrDartPlugin { // Validate every identifier that is present, not just the ones that made // the declaration above valid, so a plugin cannot smuggle an unsafe // identifier in alongside an `ffiPlugin` or `default_package` entry. - if (package is String && !_isValidPluginIdentifier(package)) { + if (package is String && !isValidPluginIdentifier(package)) { return false; } - if (pluginClass is String && !_isValidPluginIdentifier(pluginClass)) { + if (pluginClass is String && !isValidPluginIdentifier(pluginClass)) { return false; } - if (dartPluginClass is String && !_isValidPluginIdentifier(dartPluginClass)) { + if (dartPluginClass is String && !isValidPluginIdentifier(dartPluginClass)) { return false; } @@ -318,7 +319,9 @@ class IOSPlugin extends PluginPlatform implements NativeOrDartPlugin, DarwinPlug sharedDarwinSource = sharedDarwinSource ?? false; factory IOSPlugin.fromYaml(String name, YamlMap yaml) { - assert(validate(yaml)); // TODO(zanderso): https://github.com/flutter/flutter/issues/67241 + if (!validate(yaml)) { + throwToolExit('Invalid "ios" plugin specification for plugin "$name".'); + } final dartPluginClass = yaml[kDartPluginClass] as String?; final dartFileName = yaml[kDartFileName] as String?; @@ -358,10 +361,10 @@ class IOSPlugin extends PluginPlatform implements NativeOrDartPlugin, DarwinPlug // Validate every identifier that is present, not just the ones that made // the declaration above valid. - if (pluginClass is String && !_isValidPluginIdentifier(pluginClass)) { + if (pluginClass is String && !isValidPluginIdentifier(pluginClass)) { return false; } - if (dartPluginClass is String && !_isValidPluginIdentifier(dartPluginClass)) { + if (dartPluginClass is String && !isValidPluginIdentifier(dartPluginClass)) { return false; } @@ -431,7 +434,9 @@ class MacOSPlugin extends PluginPlatform implements NativeOrDartPlugin, DarwinPl sharedDarwinSource = sharedDarwinSource ?? false; factory MacOSPlugin.fromYaml(String name, YamlMap yaml) { - assert(validate(yaml)); + if (!validate(yaml)) { + throwToolExit('Invalid "macos" plugin specification for plugin "$name".'); + } final dartPluginClass = yaml[kDartPluginClass] as String?; final dartFileName = yaml[kDartFileName] as String?; @@ -470,10 +475,10 @@ class MacOSPlugin extends PluginPlatform implements NativeOrDartPlugin, DarwinPl // Validate every identifier that is present, not just the ones that made // the declaration above valid. - if (pluginClass is String && !_isValidPluginIdentifier(pluginClass)) { + if (pluginClass is String && !isValidPluginIdentifier(pluginClass)) { return false; } - if (dartPluginClass is String && !_isValidPluginIdentifier(dartPluginClass)) { + if (dartPluginClass is String && !isValidPluginIdentifier(dartPluginClass)) { return false; } @@ -541,7 +546,9 @@ class WindowsPlugin extends PluginPlatform implements NativeOrDartPlugin, Varian ); factory WindowsPlugin.fromYaml(String name, YamlMap yaml) { - assert(validate(yaml)); + if (!validate(yaml)) { + throwToolExit('Invalid "windows" plugin specification for plugin "$name".'); + } final pluginClass = yaml[kPluginClass] as String?; final variants = {}; final variantList = yaml[kSupportedVariants] as YamlList?; @@ -595,10 +602,10 @@ class WindowsPlugin extends PluginPlatform implements NativeOrDartPlugin, Varian // Validate every identifier that is present, not just the ones that made // the declaration above valid. - if (pluginClass is String && !_isValidPluginIdentifier(pluginClass)) { + if (pluginClass is String && !isValidPluginIdentifier(pluginClass)) { return false; } - if (dartPluginClass is String && !_isValidPluginIdentifier(dartPluginClass)) { + if (dartPluginClass is String && !isValidPluginIdentifier(dartPluginClass)) { return false; } @@ -664,7 +671,9 @@ class LinuxPlugin extends PluginPlatform implements NativeOrDartPlugin { ); factory LinuxPlugin.fromYaml(String name, YamlMap yaml) { - assert(validate(yaml)); + if (!validate(yaml)) { + throwToolExit('Invalid "linux" plugin specification for plugin "$name".'); + } final dartPluginClass = yaml[kDartPluginClass] as String?; final dartFileName = yaml[kDartFileName] as String?; @@ -701,10 +710,10 @@ class LinuxPlugin extends PluginPlatform implements NativeOrDartPlugin { // Validate every identifier that is present, not just the ones that made // the declaration above valid. - if (pluginClass is String && !_isValidPluginIdentifier(pluginClass)) { + if (pluginClass is String && !isValidPluginIdentifier(pluginClass)) { return false; } - if (dartPluginClass is String && !_isValidPluginIdentifier(dartPluginClass)) { + if (dartPluginClass is String && !isValidPluginIdentifier(dartPluginClass)) { return false; } @@ -762,7 +771,7 @@ class WebPlugin extends PluginPlatform { if (fileName is! String) { throwToolExit('The plugin `$name` is missing the required field `fileName` in pubspec.yaml'); } - if (!_isValidPluginIdentifier(pluginClass)) { + if (!isValidPluginIdentifier(pluginClass)) { throwToolExit( 'The plugin `$name` has an invalid `pluginClass` in its web plugin declaration.', ); diff --git a/packages/flutter_tools/lib/src/plugins.dart b/packages/flutter_tools/lib/src/plugins.dart index 4f2427a1..2f090d36 100644 --- a/packages/flutter_tools/lib/src/plugins.dart +++ b/packages/flutter_tools/lib/src/plugins.dart @@ -215,6 +215,7 @@ class Plugin { bool isDirectDependency, { required bool isDevDependency, }) { + // SAFETY: This constructor is only invoked from .fromYaml, which validates. final platforms = {}; final pluginClass = (pluginYaml as Map)['pluginClass'] as String?; if (pluginClass != null) { @@ -308,6 +309,22 @@ class Plugin { } } + /// Whether [yaml] declares [key] with a value that is not a string. + /// + /// A key that is present with no value (`default_package:`) counts: the + /// entry is read with a non-nullable `as String` cast, so null crashes it. + static bool _hasNonStringValue(YamlMap yaml, String key) => + yaml.containsKey(key) && yaml[key] is! String; + + /// Whether [yaml] gives [key] a value that is neither absent nor a string. + /// + /// Unlike [_hasNonStringValue] this accepts null. `dartFileName` is read with + /// `as String?` and falls back to `.dart` when it is null, so an + /// empty `dartFileName:` is valid and has always worked; only a value of the + /// wrong type (`dartFileName: 123`) reaches the cast and crashes the tool. + static bool _hasNonNullNonStringValue(YamlMap yaml, String key) => + yaml[key] != null && yaml[key] is! String; + static List _validateMultiPlatformYaml({required YamlMap parentMap}) { final Object? platforms = parentMap['platforms']; if (platforms is! YamlMap?) { @@ -328,7 +345,17 @@ class Plugin { if (yamlValue is! YamlMap) { return true; } - if (yamlValue.containsKey('default_package')) { + // These entries are cast to String while the plugin is constructed, so a + // value of the wrong type has to be rejected here. Otherwise a malformed + // pubspec crashes the tool with a type error instead of reporting the + // bad specification. `dartFileName` is the one whose cast is nullable, + // so null is left alone for it and only a wrong-typed value is rejected. + if (_hasNonStringValue(yamlValue, kDefaultPackage) || + _hasNonStringValue(yamlValue, kDartPluginClass) || + _hasNonNullNonStringValue(yamlValue, kDartFileName)) { + return true; + } + if (yamlValue.containsKey(kDefaultPackage)) { return false; } return !validate(yamlValue); @@ -349,11 +376,31 @@ class Plugin { } static List _validateLegacyYaml(YamlMap yaml) { + final Object? androidPackage = yaml['androidPackage']; + final Object? iosPrefix = yaml['iosPrefix']; + final Object? pluginClass = yaml['pluginClass']; + + // As in the `platforms:` format, every identifier is checked here: they are + // interpolated verbatim into the generated GeneratedPluginRegistrant source + // files, so anything that is not a plain identifier would let a (possibly + // transitive) dependency inject native code into the consuming app's build. + const identifierRequirement = + 'must be a valid identifier, optionally with dot-separated segments'; + return [ - if (yaml['androidPackage'] is! String?) - 'The "androidPackage" must either be null or a string.', - if (yaml['iosPrefix'] is! String?) 'The "iosPrefix" must either be null or a string.', - if (yaml['pluginClass'] is! String?) 'The "pluginClass" must either be null or a string.', + if (androidPackage is! String?) + 'The "androidPackage" must either be null or a string.' + else if (androidPackage != null && !isValidPluginIdentifier(androidPackage)) + 'The "androidPackage" $identifierRequirement.', + if (iosPrefix is! String?) + 'The "iosPrefix" must either be null or a string.' + // An empty prefix is the default for a legacy plugin that does not set one. + else if (iosPrefix != null && iosPrefix.isNotEmpty && !isValidPluginIdentifier(iosPrefix)) + 'The "iosPrefix" $identifierRequirement.', + if (pluginClass is! String?) + 'The "pluginClass" must either be null or a string.' + else if (pluginClass != null && !isValidPluginIdentifier(pluginClass)) + 'The "pluginClass" $identifierRequirement.', ]; } diff --git a/packages/flutter_tools/lib/src/project.dart b/packages/flutter_tools/lib/src/project.dart index c7fe80e3..64dca94f 100644 --- a/packages/flutter_tools/lib/src/project.dart +++ b/packages/flutter_tools/lib/src/project.dart @@ -20,6 +20,7 @@ import 'base/project_migrator.dart'; import 'base/utils.dart'; import 'base/version.dart'; import 'base/yaml.dart'; +import 'build_info.dart'; import 'bundle.dart' as bundle; import 'cmake_project.dart'; import 'convert.dart'; @@ -51,9 +52,7 @@ enum SupportedPlatform { } class FlutterProjectFactory { - FlutterProjectFactory({required Logger logger, required FileSystem fileSystem}) - : _logger = logger, - _fileSystem = fileSystem; + FlutterProjectFactory({required this._logger, required this._fileSystem}); final Logger _logger; final FileSystem _fileSystem; @@ -64,9 +63,10 @@ class FlutterProjectFactory { /// Returns a [FlutterProject] view of the given directory or a ToolExit error, /// if `pubspec.yaml` or `example/pubspec.yaml` is invalid. FlutterProject fromDirectory(Directory directory) { - return projects.putIfAbsent(directory.path, () { + final File pubspec = directory.childFile(bundle.defaultManifestPath); + if (!pubspec.existsSync()) { final FlutterManifest manifest = FlutterProject._readManifest( - directory.childFile(bundle.defaultManifestPath).path, + pubspec.path, logger: _logger, fileSystem: _fileSystem, ); @@ -76,8 +76,26 @@ class FlutterProjectFactory { fileSystem: _fileSystem, ); return FlutterProject(directory, manifest, exampleManifest); + } + return projects.putIfAbsent(directory.path, () { + final FlutterManifest manifest = FlutterProject._readManifest( + pubspec.path, + logger: _logger, + fileSystem: _fileSystem, + ); + final FlutterManifest exampleManifest = FlutterProject._readManifest( + FlutterProject._exampleDirectory(directory).childFile(bundle.defaultManifestPath).path, + logger: _logger, + fileSystem: _fileSystem, + ); + return FlutterProject(directory, manifest, exampleManifest, projectFactory: this); }); } + + /// Invalidate any cached [FlutterProject] for the given [directory]. + void invalidate(Directory directory) { + projects.remove(directory.path); + } } /// Represents the contents of a Flutter project at the specified [directory]. @@ -91,10 +109,75 @@ class FlutterProjectFactory { /// cached. class FlutterProject { @visibleForTesting - FlutterProject(this.directory, FlutterManifest manifest, this._exampleManifest) { + FlutterProject( + this.directory, + FlutterManifest manifest, + this._exampleManifest, { + this._buildDirectory, + this._projectFactory, + }) { _setManifest(manifest); } + final FlutterProjectFactory? _projectFactory; + + FlutterProject? _workspaceRoot; + bool _searchedForWorkspaceRoot = false; + + /// Returns the workspace root project if this project is a member of a workspace. + /// + /// Returns null if this project is not part of a workspace or is itself the workspace root. + FlutterProject? get workspaceRoot { + if (_searchedForWorkspaceRoot) { + return _workspaceRoot; + } + _searchedForWorkspaceRoot = true; + _workspaceRoot = _findWorkspaceRoot(); + return _workspaceRoot; + } + + FlutterProject? _findWorkspaceRoot() { + final FileSystem fileSystem = directory.fileSystem; + final String normalizedPath = fileSystem.path.normalize(directory.absolute.path); + Directory candidate = fileSystem.directory(normalizedPath); + + while (true) { + final Directory parent = candidate.parent; + if (fileSystem.path.equals(parent.path, candidate.path)) { + break; + } + candidate = parent; + final File pubspec = candidate.childFile('pubspec.yaml'); + if (!pubspec.existsSync()) { + continue; + } + try { + final FlutterManifest manifest = FlutterProject._readManifest( + pubspec.path, + logger: globals.logger, + fileSystem: fileSystem, + ); + if (manifest.workspace.isNotEmpty) { + final String relativePath = fileSystem.path.relative( + normalizedPath, + from: candidate.path, + ); + final bool isMember = manifest.workspace.any((String entry) { + final glob = Glob(entry, context: fileSystem.path); + return glob.matches(relativePath); + }); + if (isMember) { + return _projectFactory?.fromDirectory(candidate) ?? + FlutterProject.fromDirectory(candidate); + } + } + } on Exception catch (_) { + // Ignore manifest reading errors. + } + } + return null; + } + /// Returns a [FlutterProject] view of the given directory or a ToolExit error, /// if `pubspec.yaml` or `example/pubspec.yaml` is invalid. static FlutterProject fromDirectory(Directory directory) => @@ -107,7 +190,11 @@ class FlutterProject { /// Create a [FlutterProject] and bypass the project caching. @visibleForTesting - static FlutterProject fromDirectoryTest(Directory directory, [Logger? logger]) { + static FlutterProject fromDirectoryTest( + Directory directory, [ + Logger? logger, + Directory? buildDirectory, + ]) { final FileSystem fileSystem = directory.fileSystem; logger ??= BufferLogger.test(); final FlutterManifest manifest = FlutterProject._readManifest( @@ -120,14 +207,22 @@ class FlutterProject { logger: logger, fileSystem: fileSystem, ); - return FlutterProject(directory, manifest, exampleManifest); + return FlutterProject( + directory, + manifest, + exampleManifest, + buildDirectory: buildDirectory ?? directory.childDirectory('build'), + ); } /// The location of this project. final Directory directory; + final Directory? _buildDirectory; + /// The location of the build folder. - Directory get buildDirectory => directory.childDirectory('build'); + Directory get buildDirectory => + _buildDirectory ?? directory.childDirectory(getBuildDirectory(null, directory.fileSystem)); /// The manifest of this project. FlutterManifest get manifest => _manifest; @@ -142,19 +237,107 @@ class FlutterProject { void _setManifest(FlutterManifest manifest) { _manifest = manifest; + _searchedForWorkspaceRoot = false; + _workspaceRoot = null; // Update the workspace projects based on the new manifest. _workspaceProjects = []; + if (!directory.existsSync()) { + return; + } for (final String entry in manifest.workspace) { - final glob = Glob(entry); - for (final Directory globResult - in glob - .listFileSystemSync(directory.fileSystem, root: directory.path) - .whereType()) { - if (globResult.childFile('pubspec.yaml').existsSync()) { - _workspaceProjects.add(FlutterProject.fromDirectory(globResult)); + for (final Directory entity in _resolveWorkspacePattern(directory, entry)) { + if (entity.childFile('pubspec.yaml').existsSync()) { + try { + _workspaceProjects.add( + _projectFactory?.fromDirectory(entity) ?? FlutterProject.fromDirectory(entity), + ); + } on Exception catch (_) { + // Ignore child projects with invalid manifests. + } + } + } + } + } + + /// Resolves the given workspace [pattern] relative to [root] to find all + /// matching package root directories. + /// + /// Evaluates the pattern segment-by-segment using shallow directory listings + /// to avoid deep recursive tree traversal into build artifacts or caches + /// (such as Gradle `.transforms/...`) that can exceed Windows `MAX_PATH` + /// limits. Hidden directories (starting with `.`) and `build/` directories + /// are ignored during matching. + /// + /// Supports exact relative paths, single-level glob patterns (e.g. `packages/*`), + /// and recursive multi-level directory descent (`**`). + static List _resolveWorkspacePattern(Directory root, String pattern) { + final List segments = pattern + .replaceAll(r'\', '/') + .split('/') + .where((String s) => s.isNotEmpty) + .toList(); + if (segments.isEmpty) { + return const []; + } + + var currentDirs = [root]; + + for (final segment in segments) { + if (segment == '**') { + final nextDirs = []; + final visited = {}; + for (final dir in currentDirs) { + if (!dir.existsSync()) { + continue; + } + if (visited.add(dir.path)) { + nextDirs.add(dir); + _collectSubdirectories(dir, nextDirs, visited); + } + } + currentDirs = nextDirs; + } else { + final glob = Glob(segment, context: root.fileSystem.path); + currentDirs = currentDirs.expand((Directory dir) { + if (!dir.existsSync()) { + return const []; + } + try { + return dir.listSync(followLinks: false).whereType().where((Directory d) { + final String name = d.basename; + return !name.startsWith('.') && name != 'build' && glob.matches(name); + }); + } on FileSystemException { + return const []; + } + }).toList(); + } + } + + return currentDirs; + } + + /// Recursively collects all subdirectories under [dir], excluding hidden + /// directories (starting with `.`) and `build/` directories. + /// + /// Tracks [visited] paths to prevent infinite loops from cyclic directory + /// structures or overlapping search roots. + static void _collectSubdirectories(Directory dir, List results, Set visited) { + try { + for (final FileSystemEntity entity in dir.listSync(followLinks: false)) { + if (entity is Directory) { + final String name = entity.basename; + if (!name.startsWith('.') && name != 'build') { + if (visited.add(entity.path)) { + results.add(entity); + _collectSubdirectories(entity, results, visited); + } + } } } + } on FileSystemException { + // Ignore unreadable or transient directories. } } @@ -283,7 +466,7 @@ class FlutterProject { FlutterProject get example => FlutterProject( _exampleDirectory(directory), _exampleManifest, - FlutterManifest.empty(logger: globals.logger), + FlutterManifest.empty(logger: _manifest.logger), ); /// The generated scaffolding project for hosting widget previews from this @@ -426,7 +609,7 @@ class FlutterProject { } final migration = ProjectMigration([ - AnalysisOptionsMigration(this, globals.logger), + AnalysisOptionsMigration(this, globals.logger, packageConfig: packageConfig), ]); await migration.run(); diff --git a/packages/flutter_tools/lib/src/protocol_discovery.dart b/packages/flutter_tools/lib/src/protocol_discovery.dart index cd2d4a9b..c7c45fd0 100644 --- a/packages/flutter_tools/lib/src/protocol_discovery.dart +++ b/packages/flutter_tools/lib/src/protocol_discovery.dart @@ -9,7 +9,7 @@ import 'base/logger.dart'; import 'base/utils.dart'; import 'device.dart'; import 'device_port_forwarder.dart'; -import 'globals.dart' as globals; +import 'vmservice.dart'; /// Discovers a specific service protocol on a device, and forwards the service /// protocol device port to the host. @@ -21,10 +21,9 @@ class ProtocolDiscovery { required this.throttleDuration, this.hostPort, this.devicePort, - required bool ipv6, - required Logger logger, - }) : _logger = logger, - _ipv6 = ipv6 { + required this._ipv6, + required this._logger, + }) { _deviceLogSubscription = logReader.logLines.listen(_handleLine, onDone: _stopScrapingLogs); } @@ -101,7 +100,7 @@ class ProtocolDiscovery { } Match? _getPatternMatch(String line) { - return globals.kVMServiceMessageRegExp.firstMatch(line); + return kVMServiceMessageRegExp.firstMatch(line); } Uri? _getVmServiceUri(String line) { diff --git a/packages/flutter_tools/lib/src/proxied_devices/devices.dart b/packages/flutter_tools/lib/src/proxied_devices/devices.dart index 65fd5e1c..5d4851aa 100644 --- a/packages/flutter_tools/lib/src/proxied_devices/devices.dart +++ b/packages/flutter_tools/lib/src/proxied_devices/devices.dart @@ -42,14 +42,11 @@ T _cast(Object? object) { class ProxiedDevices extends PollingDeviceDiscovery { ProxiedDevices( this.connection, { - bool deltaFileTransfer = true, - bool enableDdsProxy = false, + this._deltaFileTransfer = true, + this._enableDdsProxy = false, required this.logger, - FileTransfer fileTransfer = const FileTransfer(), - }) : _deltaFileTransfer = deltaFileTransfer, - _enableDdsProxy = enableDdsProxy, - _fileTransfer = fileTransfer, - super('Proxied devices'); + this._fileTransfer = const FileTransfer(), + }) : super('Proxied devices'); /// [DaemonConnection] used to communicate with the daemon. final DaemonConnection connection; @@ -176,36 +173,27 @@ class ProxiedDevice extends Device { ProxiedDevice( this.connection, String id, { - bool deltaFileTransfer = true, - bool enableDdsProxy = false, + this._deltaFileTransfer = true, + this._enableDdsProxy = false, required super.category, required super.platformType, - required TargetPlatform targetPlatform, - required CpuArch cpuArch, + required this._targetPlatform, + required this._cpuArch, required super.ephemeral, required this.isConnected, required this.connectionInterface, required this.name, - required bool isLocalEmulator, - required String? emulatorId, - required String sdkNameAndVersion, + required this._isLocalEmulator, + required this._emulatorId, + required this._sdkNameAndVersion, required this.supportsHotReload, required this.supportsHotRestart, required this.supportsFlutterExit, required this.supportsScreenshot, - required bool supportsHardwareRendering, + required this._supportsHardwareRendering, required super.logger, - FileTransfer fileTransfer = const FileTransfer(), - }) : _deltaFileTransfer = deltaFileTransfer, - _enableDdsProxy = enableDdsProxy, - _isLocalEmulator = isLocalEmulator, - _emulatorId = emulatorId, - _sdkNameAndVersion = sdkNameAndVersion, - _supportsHardwareRendering = supportsHardwareRendering, - _targetPlatform = targetPlatform, - _cpuArch = cpuArch, - _logger = logger, - _fileTransfer = fileTransfer, + this._fileTransfer = const FileTransfer(), + }) : _logger = logger, super(id); /// [DaemonConnection] used to communicate with the daemon. @@ -597,8 +585,11 @@ class _ProxiedForwardedPort extends ForwardedPort { } } -typedef CreateSocketServer = - Future Function(Logger logger, int? hostPort, bool? ipv6); +typedef CreateSocketServer = Future Function( + Logger logger, + int? hostPort, + bool? ipv6, +); /// A [DevicePortForwarder] for a proxied device. /// @@ -611,12 +602,10 @@ typedef CreateSocketServer = class ProxiedPortForwarder extends DevicePortForwarder { ProxiedPortForwarder( this.connection, { - String? deviceId, - required Logger logger, - @visibleForTesting CreateSocketServer createSocketServer = _defaultCreateServerSocket, - }) : _logger = logger, - _deviceId = deviceId, - _createSocketServer = createSocketServer; + this._deviceId, + required this._logger, + @visibleForTesting this._createSocketServer = _defaultCreateServerSocket, + }); final String? _deviceId; @@ -702,25 +691,30 @@ class ProxiedPortForwarder extends DevicePortForwarder { }, ), ); - debounceDataStream(socket).listen((Uint8List data) { - unawaited( - connection - .sendRequest('proxy.write', {'id': id}, data) - .then( - (Object? obj) => obj, - onError: (Object error, StackTrace stackTrace) { - // Log the error, but proceed normally. Network failure should not - // crash the tool. If this is critical, the place where the connection - // is being used would crash. - _logger.printWarning('Write to remote proxy error: $error'); - _logger.printTrace( - 'Write to remote proxy error: $error, stack trace: $stackTrace', - ); - return null; - }, - ), - ); - }); + debounceDataStream(socket).listen( + (Uint8List data) { + unawaited( + connection + .sendRequest('proxy.write', {'id': id}, data) + .then( + (Object? obj) => obj, + onError: (Object error, StackTrace stackTrace) { + // Log the error, but proceed normally. Network failure should not + // crash the tool. If this is critical, the place where the connection + // is being used would crash. + _logger.printWarning('Write to remote proxy error: $error'); + _logger.printTrace( + 'Write to remote proxy error: $error, stack trace: $stackTrace', + ); + return null; + }, + ), + ); + }, + onError: (Object error, StackTrace stackTrace) { + _logger.printTrace('Socket error: $error\n$stackTrace'); + }, + ); _connectedSockets.add(socket); unawaited( @@ -817,12 +811,10 @@ class ProxiedDartDevelopmentService this.connection, this.deviceId, { required this.logger, - required ProxiedPortForwarder proxiedPortForwarder, - required ProxiedPortForwarder devicePortForwarder, + required this._proxiedPortForwarder, + required this._devicePortForwarder, @visibleForTesting DartDevelopmentService? localDds, - }) : _proxiedPortForwarder = proxiedPortForwarder, - _devicePortForwarder = devicePortForwarder, - _localDds = localDds ?? DartDevelopmentService(logger: logger); + }) : _localDds = localDds ?? DartDevelopmentService(logger: logger); final String deviceId; diff --git a/packages/flutter_tools/lib/src/proxy_validator.dart b/packages/flutter_tools/lib/src/proxy_validator.dart index df8658d5..f169a72a 100644 --- a/packages/flutter_tools/lib/src/proxy_validator.dart +++ b/packages/flutter_tools/lib/src/proxy_validator.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter_tools_core/flutter_tools_core.dart'; + import 'base/io.dart'; import 'base/platform.dart'; import 'doctor_validator.dart'; diff --git a/packages/flutter_tools/lib/src/reporting/crash_reporting.dart b/packages/flutter_tools/lib/src/reporting/crash_reporting.dart index 513471fc..e91889ed 100644 --- a/packages/flutter_tools/lib/src/reporting/crash_reporting.dart +++ b/packages/flutter_tools/lib/src/reporting/crash_reporting.dart @@ -56,12 +56,10 @@ class CrashDetails { /// Reports information about the crash to the user. class CrashReporter { CrashReporter({ - required FileSystem fileSystem, - required Logger logger, - required FlutterProjectFactory flutterProjectFactory, - }) : _fileSystem = fileSystem, - _logger = logger, - _flutterProjectFactory = flutterProjectFactory; + required this._fileSystem, + required this._logger, + required this._flutterProjectFactory, + }); final FileSystem _fileSystem; final Logger _logger; @@ -116,15 +114,11 @@ class CrashReporter { class CrashReportSender { CrashReportSender({ http.Client? client, - required Platform platform, - required Logger logger, - required OperatingSystemUtils operatingSystemUtils, - required Analytics analytics, - }) : _client = client ?? http.Client(), - _platform = platform, - _logger = logger, - _operatingSystemUtils = operatingSystemUtils, - _analytics = analytics; + required this._platform, + required this._logger, + required this._operatingSystemUtils, + required this._analytics, + }) : _client = client ?? http.Client(); final http.Client _client; final Platform _platform; @@ -146,11 +140,15 @@ class CrashReportSender { /// Sends one crash report. /// /// The report is populated from data in [error] and [stackTrace]. + /// + /// If [typeId] is provided, it will be used as the type of the crash, otherwise will default to + /// being a Dart error ([_kDartTypeId]). Future sendReport({ required Object error, required StackTrace stackTrace, required String Function() getFlutterVersion, required String command, + String? typeId, }) async { // Only send one crash report per run. if (_crashReportSent) { @@ -176,7 +174,7 @@ class CrashReportSender { req.fields['version'] = flutterVersion; req.fields['osName'] = _platform.operatingSystem; req.fields['osVersion'] = _operatingSystemUtils.name; // this actually includes version - req.fields['type'] = _kDartTypeId; + req.fields['type'] = typeId ?? _kDartTypeId; req.fields['error_runtime_type'] = '${error.runtimeType}'; req.fields['error_message'] = '$error'; req.fields['comments'] = command; diff --git a/packages/flutter_tools/lib/src/reporting/github_template.dart b/packages/flutter_tools/lib/src/reporting/github_template.dart index 8575a146..49a98c48 100644 --- a/packages/flutter_tools/lib/src/reporting/github_template.dart +++ b/packages/flutter_tools/lib/src/reporting/github_template.dart @@ -21,12 +21,10 @@ import '../version.dart'; /// Provide suggested GitHub issue templates to user when Flutter encounters an error. class GitHubTemplateCreator { GitHubTemplateCreator({ - required FileSystem fileSystem, - required Logger logger, - required FlutterProjectFactory flutterProjectFactory, - }) : _fileSystem = fileSystem, - _logger = logger, - _flutterProjectFactory = flutterProjectFactory; + required this._fileSystem, + required this._logger, + required this._flutterProjectFactory, + }); final FileSystem _fileSystem; final Logger _logger; @@ -149,8 +147,12 @@ ${_projectMetadataInformation()} if (manifest.isEmpty) { return 'No pubspec in working directory.'; } - final metadata = FlutterProjectMetadata(project.metadataFile, _logger); - final FlutterTemplateType? projectType = metadata.projectType; + final metadata = FlutterProjectMetadata( + project.metadataFile, + _logger, + extensionTemplateManager: null, + ); + final ParsedFlutterTemplateType? projectType = metadata.projectType; final description = StringBuffer() ..writeln('**Type**: ${projectType == null ? 'malformed' : projectType.cliName}') ..writeln('**Version**: ${manifest.appVersion}') diff --git a/packages/flutter_tools/lib/src/reporting/unified_analytics.dart b/packages/flutter_tools/lib/src/reporting/unified_analytics.dart index a7b6bbaf..7d726d7f 100644 --- a/packages/flutter_tools/lib/src/reporting/unified_analytics.dart +++ b/packages/flutter_tools/lib/src/reporting/unified_analytics.dart @@ -6,8 +6,8 @@ import 'package:unified_analytics/unified_analytics.dart'; import '../base/config.dart'; import '../base/io.dart'; +import '../base/logger.dart'; import '../features.dart'; -import '../globals.dart' as globals; import '../version.dart'; /// This function is called from within the context runner to perform @@ -75,11 +75,13 @@ String? getEnabledFeatures(Config config) { } /// Function to safely grab the max rss from [ProcessInfo]. -int? getMaxRss(ProcessInfo processInfo) { +int? getMaxRss(ProcessInfo processInfo, {required Logger logger}) { try { - return globals.processInfo.maxRss; + return processInfo.maxRss; } on Exception catch (error) { - globals.printTrace('Querying maxRss failed with error: $error'); + logger.printTrace('Querying maxRss failed with error: $error'); + } on UnsupportedError { + // maxRss is unsupported on this platform. } return null; } diff --git a/packages/flutter_tools/lib/src/resident_runner.dart b/packages/flutter_tools/lib/src/resident_runner.dart index 12d366f9..405626d5 100644 --- a/packages/flutter_tools/lib/src/resident_runner.dart +++ b/packages/flutter_tools/lib/src/resident_runner.dart @@ -6,49 +6,60 @@ import 'dart:async'; import 'package:meta/meta.dart'; import 'package:package_config/package_config.dart'; +import 'package:process/process.dart'; +import 'package:unified_analytics/unified_analytics.dart'; import 'package:vm_service/vm_service.dart' as vm_service; +import 'android/android_device.dart'; import 'application_package.dart'; +import 'artifacts.dart'; import 'asset.dart'; import 'base/command_help.dart'; import 'base/common.dart'; +import 'base/config.dart'; import 'base/context.dart'; import 'base/dds.dart'; import 'base/file_system.dart'; import 'base/io.dart' as io; import 'base/logger.dart'; +import 'base/os.dart'; import 'base/platform.dart'; +import 'base/process.dart'; import 'base/signals.dart'; import 'base/terminal.dart'; import 'base/utils.dart'; import 'base/version.dart'; import 'build_info.dart'; import 'build_system/build_system.dart'; +import 'build_system/build_targets.dart'; import 'build_system/tools/shader_compiler.dart'; import 'bundle.dart'; import 'cache.dart'; import 'compile.dart'; +import 'context/tool_context.dart'; import 'convert.dart'; import 'devfs.dart'; import 'device.dart'; -import 'globals.dart' as globals; import 'hook_runner.dart' show FlutterHookRunner; import 'ios/application_package.dart'; import 'ios/devices.dart'; +import 'macos/xcode.dart'; import 'project.dart'; import 'run_cold.dart'; import 'run_hot.dart'; +import 'version.dart'; import 'vmservice.dart'; class FlutterDevice { FlutterDevice( this.device, { required this.buildInfo, - required this.targetPlatform, - required this.generator, + required this._toolContext, required this.developmentShaderCompiler, - this.userIdentifier, + required this.generator, + required this.targetPlatform, @visibleForTesting this.logFlushDelay = const Duration(milliseconds: 500), + this.userIdentifier, }); final Duration logFlushDelay; @@ -56,32 +67,44 @@ class FlutterDevice { /// Create a [FlutterDevice] with optional code generation enabled. static Future create( Device device, { - required String? target, + required ToolContext toolContext, required BuildInfo buildInfo, - required Platform platform, - String? userIdentifier, + required String? target, TargetModel? targetModelOverride, + String? userIdentifier, }) async { + final ToolContext( + :Artifacts artifacts, + :Config config, + :FileSystem fs, + :Logger logger, + :Platform platform, + :ProcessManager processManager, + :ShutdownHooks shutdownHooks, + ) = toolContext; + final TargetPlatform targetPlatform = await device.targetPlatform; + final shaderCompiler = DevelopmentShaderCompiler( shaderCompiler: ShaderCompiler( - artifacts: globals.artifacts!, - logger: globals.logger, - processManager: globals.processManager, - fileSystem: globals.fs, + artifacts: artifacts, + logger: logger, + processManager: processManager, + fileSystem: fs, + platform: platform, ), - fileSystem: globals.fs, - logger: globals.logger, + fileSystem: fs, + logger: logger, ); final ResidentCompiler generator = residentCompilerFactory.create( - artifacts: globals.artifacts!, - processManager: globals.processManager, - logger: globals.logger, - fileSystem: globals.fs, + artifacts: artifacts, + processManager: processManager, + logger: logger, + fileSystem: fs, platform: platform, - shutdownHooks: globals.shutdownHooks, - config: globals.config, + shutdownHooks: shutdownHooks, + config: config, targetPlatform: targetPlatform, buildInfo: buildInfo, targetModelOverride: targetModelOverride, @@ -89,11 +112,12 @@ class FlutterDevice { return FlutterDevice( device, - targetPlatform: targetPlatform, - generator: generator, + toolContext: toolContext, buildInfo: buildInfo, - userIdentifier: userIdentifier, developmentShaderCompiler: shaderCompiler, + generator: generator, + targetPlatform: targetPlatform, + userIdentifier: userIdentifier, ); } @@ -101,19 +125,19 @@ class FlutterDevice { final Device? device; final ResidentCompiler? generator; final BuildInfo buildInfo; + final ToolContext _toolContext; final String? userIdentifier; final DevelopmentShaderCompiler developmentShaderCompiler; DevFSWriter? devFSWriter; - Stream? vmServiceUris; + Future? vmServiceUri; FlutterVmService? vmService; DevFS? devFS; ApplicationPackage? package; StreamSubscription? _loggingSubscription; - bool? _isListeningForVmServiceUri; - /// Whether the stream [vmServiceUris] is still open. - bool get isWaitingForVmService => _isListeningForVmServiceUri ?? false; + /// Whether this device is waiting for a VM Service URI. + bool get isWaitingForVmService => vmService == null && vmServiceUri != null; /// If the [reloadSources] parameter is not null the 'reloadSources' service /// will be registered. @@ -125,202 +149,157 @@ class FlutterDevice { /// This ensures that the reload process follows the normal orchestration of /// the Flutter Tools and not just the VM internal service. Future connect({ + required Uri vmServiceUri, ReloadSources? reloadSources, Restart? restart, CompileExpression? compileExpression, PrintStructuredErrorLogMethod? printStructuredErrorLogMethod, required DebuggingOptions debuggingOptions, - int? hostVmServicePort, - }) { - final completer = Completer(); - late StreamSubscription subscription; - var isWaitingForVm = false; - - subscription = vmServiceUris!.listen( - (Uri? vmServiceUri) async { - // FYI, this message is used as a sentinel in tests. - globals.printTrace('Connecting to service protocol: $vmServiceUri'); - isWaitingForVm = true; - var existingDds = false; - FlutterVmService? service; - if (debuggingOptions.enableDds) { - void handleError(Exception e, StackTrace st) { - globals.printTrace('Fail to connect to service protocol: $vmServiceUri: $e'); - if (!completer.isCompleted) { - completer.completeError('failed to connect to $vmServiceUri $e', st); - } - } - - const kMaxAttempts = 3; - for (var attempts = 1; attempts <= kMaxAttempts; ++attempts) { - void handleVmServiceCheckException(Exception e) { - globals.printTrace('Fail to connect to service protocol: $vmServiceUri: $e'); - if (!completer.isCompleted && !_isListeningForVmServiceUri!) { - completer.completeError('failed to connect to $vmServiceUri $e'); - } - } - - // First check if the VM service is actually listening on vmServiceUri as - // this may not be the case when scraping logcat for URIs. If this URI is - // from an old application instance, we shouldn't try and start DDS. - try { - service = await connectToVmService(vmServiceUri!, logger: globals.logger); - await service.dispose(); - break; - } on vm_service.RPCError catch (e, st) { - if (!e.isConnectionDisposedException) { - handleVmServiceCheckException(e); - return; - } - // It's possible (but unlikely) that two DDS instances can try and start at the same - // time (e.g., a "flutter run" is initiated while an existing "flutter attach" is - // waiting for a target to attach to). This can lead to the initial VM service connection - // failing for one of the processes when the VM service disconnects it after the other - // instance successfully invoked the "_yieldControlToDDS" RPC. - // - // To handle this, we retry connecting to the VM service, which should successfully - // be redirected to the DDS instance. - // - // See https://github.com/flutter/flutter/issues/169265 for details. - if (attempts == kMaxAttempts) { - globals.printTrace( - 'Failed to make initial connection to VM Service (attempt $attempts of $kMaxAttempts).', - ); - handleError(e, st); - return; - } - // Exponential backoff. - final int backoffPeriod = (1 << (attempts - 1)) * 100; - globals.printTrace( - 'Failed to make initial connection to VM Service (attempt $attempts of $kMaxAttempts). ' - 'Retrying in ${backoffPeriod}ms...', - ); - await Future.delayed(Duration(milliseconds: backoffPeriod)); - } on Exception catch (e) { - handleVmServiceCheckException(e); - return; - } + }) async { + final ToolContext(:FileSystem fs, :Logger logger, :FlutterProjectFactory projectFactory) = + _toolContext; + this.vmServiceUri ??= Future.value(vmServiceUri); + // FYI, this message is used as a sentinel in tests. + logger.printTrace('Connecting to service protocol: $vmServiceUri'); + var existingDds = false; + FlutterVmService? service; + if (debuggingOptions.enableDds) { + const kMaxAttempts = 3; + for (var attempts = 1; attempts <= kMaxAttempts; ++attempts) { + // First check if the VM service is actually listening on vmServiceUri as + // this may not be the case when scraping logcat for URIs. If this URI is + // from an old application instance, we shouldn't try and start DDS. + try { + service = await connectToVmService(vmServiceUri, logger: logger); + await service.dispose(); + break; + } on vm_service.RPCError catch (e) { + if (!e.isConnectionDisposedException) { + logger.printTrace('Fail to connect to service protocol: $vmServiceUri: $e'); + rethrow; } - - for (var attempts = 1; attempts <= kMaxAttempts; ++attempts) { - // This try block is meant to catch errors that occur during DDS startup - // (e.g., failure to bind to a port, failure to connect to the VM service, - // attaching to a VM service with existing clients, etc.). - try { - await device!.dds.startDartDevelopmentServiceFromDebuggingOptions( - vmServiceUri!, - debuggingOptions: debuggingOptions, - appName: - 'Kind: Flutter - Device: ${device!.displayName} - ' - 'Package: ${FlutterProject.current().manifest.appName}', - ); - break; - } on DartDevelopmentServiceException catch (e, st) { - if (e.errorCode == DartDevelopmentServiceException.existingDdsInstanceError) { - existingDds = true; - break; - } - // It's possible (but unlikely) that two DDS instances can try and start at the same - // time (e.g., a "flutter run" is initiated while an existing "flutter attach" is - // waiting for a target to attach to). This leads to DDS failing to initialize for - // one of the processes when the VM service disconnects it after the other instance - // successfully invoked the "_yieldControlToDDS" RPC. - // - // To handle this, we retry to start DDS after a short delay, which should result in - // an existingDdsInstanceError if the failure to start was due to a startup race. - // - // See https://github.com/flutter/flutter/issues/169265 for details. - if (attempts == kMaxAttempts) { - globals.printTrace('Failed to start DDS (attempt $attempts of $kMaxAttempts).'); - handleError(e, st); - return; - } - // Exponential backoff. - final int backoffPeriod = (1 << (attempts - 1)) * 100; - globals.printTrace( - 'Failed to start DDS (attempt $attempts of $kMaxAttempts). ' - 'Retrying in ${backoffPeriod}ms...', - ); - await Future.delayed(Duration(milliseconds: backoffPeriod)); - } on ToolExit { - rethrow; - } on Exception catch (e, st) { - handleError(e, st); - return; - } + // It's possible (but unlikely) that two DDS instances can try and start at the same + // time (e.g., a "flutter run" is initiated while an existing "flutter attach" is + // waiting for a target to attach to). This can lead to the initial VM service connection + // failing for one of the processes when the VM service disconnects it after the other + // instance successfully invoked the "_yieldControlToDDS" RPC. + // + // To handle this, we retry connecting to the VM service, which should successfully + // be redirected to the DDS instance. + // + // See https://github.com/flutter/flutter/issues/169265 for details. + if (attempts == kMaxAttempts) { + logger.printTrace( + 'Failed to make initial connection to VM Service (attempt $attempts of $kMaxAttempts).', + ); + logger.printTrace('Fail to connect to service protocol: $vmServiceUri: $e'); + throw Exception('failed to connect to $vmServiceUri $e'); } + // Exponential backoff. + final int backoffPeriod = (1 << (attempts - 1)) * 100; + logger.printTrace( + 'Failed to make initial connection to VM Service (attempt $attempts of $kMaxAttempts). ' + 'Retrying in ${backoffPeriod}ms...', + ); + await Future.delayed(Duration(milliseconds: backoffPeriod)); + } on Exception catch (e) { + logger.printTrace('Fail to connect to service protocol: $vmServiceUri: $e'); + rethrow; } - // This second try block handles cases where the VM service connection goes down - // before flutter_tools connects to DDS. The DDS `done` future completes when DDS - // shuts down, including after an error. If `done` completes before `connectToVmService`, - // something went wrong that caused DDS to shutdown early. + } + + for (var attempts = 1; attempts <= kMaxAttempts; ++attempts) { + // This try block is meant to catch errors that occur during DDS startup + // (e.g., failure to bind to a port, failure to connect to the VM service, + // attaching to a VM service with existing clients, etc.). try { - service = - await Future.any(>[ - connectToVmService( - debuggingOptions.enableDds - ? (device!.dds.uri ?? vmServiceUri!) - : vmServiceUri!, - reloadSources: reloadSources, - restart: restart, - compileExpression: compileExpression, - flutterProject: FlutterProject.current(), - printStructuredErrorLogMethod: printStructuredErrorLogMethod, - device: device, - logger: globals.logger, - ), - if (!existingDds) - device!.dds.done.whenComplete( - () => throw Exception('DDS shut down too early'), - ), - ]) - as FlutterVmService?; - } on Exception catch (exception) { - globals.printTrace('Fail to connect to service protocol: $vmServiceUri: $exception'); - if (!completer.isCompleted && !_isListeningForVmServiceUri!) { - completer.completeError('failed to connect to $vmServiceUri $exception'); + await device!.dds.startDartDevelopmentServiceFromDebuggingOptions( + vmServiceUri, + debuggingOptions: debuggingOptions, + appName: + 'Kind: Flutter - Device: ${device!.displayName} - ' + 'Package: ${projectFactory.fromDirectory(fs.currentDirectory).manifest.appName}', + ); + break; + } on DartDevelopmentServiceException catch (e) { + if (e.errorCode == DartDevelopmentServiceException.existingDdsInstanceError) { + existingDds = true; + break; } - return; - } - if (completer.isCompleted) { - return; - } - globals.printTrace('Successfully connected to service protocol: $vmServiceUri'); - - vmService = service; - if (debuggingOptions.enableDds && !existingDds) { - // Don't await this as service extensions won't return if the target - // isolate is paused on start. - unawaited(device!.dds.invokeServiceExtensions(this)); - } - if ((existingDds || !debuggingOptions.enableDds) && - debuggingOptions.devToolsServerAddress != null) { - // Don't await this as service extensions won't return if the target - // isolate is paused on start. - unawaited( - device!.dds.maybeCallDevToolsUriServiceExtension( - device: this, - uri: debuggingOptions.devToolsServerAddress, - ), + // It's possible (but unlikely) that two DDS instances can try and start at the same + // time (e.g., a "flutter run" is initiated while an existing "flutter attach" is + // waiting for a target to attach to). This leads to DDS failing to initialize for + // one of the processes when the VM service disconnects it after the other instance + // successfully invoked the "_yieldControlToDDS" RPC. + // + // To handle this, we retry to start DDS after a short delay, which should result in + // an existingDdsInstanceError if the failure to start was due to a startup race. + // + // See https://github.com/flutter/flutter/issues/169265 for details. + if (attempts == kMaxAttempts) { + logger.printTrace('Failed to start DDS (attempt $attempts of $kMaxAttempts).'); + logger.printTrace('Fail to connect to service protocol: $vmServiceUri: $e'); + throw Exception('failed to connect to $vmServiceUri $e'); + } + // Exponential backoff. + final int backoffPeriod = (1 << (attempts - 1)) * 100; + logger.printTrace( + 'Failed to start DDS (attempt $attempts of $kMaxAttempts). ' + 'Retrying in ${backoffPeriod}ms...', ); + await Future.delayed(Duration(milliseconds: backoffPeriod)); + } on ToolExit { + rethrow; + } on Exception catch (e) { + logger.printTrace('Fail to connect to service protocol: $vmServiceUri: $e'); + throw Exception('failed to connect to $vmServiceUri $e'); } + } + } + // This second try block handles cases where the VM service connection goes down + // before flutter_tools connects to DDS. The DDS `done` future completes when DDS + // shuts down, including after an error. If `done` completes before `connectToVmService`, + // something went wrong that caused DDS to shutdown early. + try { + service = await Future.any(>[ + connectToVmService( + debuggingOptions.enableDds ? (device!.dds.uri ?? vmServiceUri) : vmServiceUri, + reloadSources: reloadSources, + restart: restart, + compileExpression: compileExpression, + flutterProject: projectFactory.fromDirectory(fs.currentDirectory), + printStructuredErrorLogMethod: printStructuredErrorLogMethod, + device: device, + logger: logger, + ), + if (!existingDds) + device!.dds.done.whenComplete(() => throw Exception('DDS shut down too early')), + ]) as FlutterVmService?; + } on Exception catch (exception) { + logger.printTrace('Fail to connect to service protocol: $vmServiceUri: $exception'); + rethrow; + } + logger.printTrace('Successfully connected to service protocol: $vmServiceUri'); - await (await device!.getLogReader(app: package)).provideVmService(vmService!); - completer.complete(); - await subscription.cancel(); - }, - onError: (dynamic error) { - globals.printTrace('Fail to handle VM Service URI: $error'); - }, - onDone: () { - _isListeningForVmServiceUri = false; - if (!completer.isCompleted && !isWaitingForVm) { - completer.completeError(Exception('connection to device ended too early')); - } - }, - ); - _isListeningForVmServiceUri = true; - return completer.future; + vmService = service; + if (debuggingOptions.enableDds && !existingDds) { + // Don't await this as service extensions won't return if the target + // isolate is paused on start. + unawaited(device!.dds.invokeServiceExtensions(this)); + } + if ((existingDds || !debuggingOptions.enableDds) && + debuggingOptions.devToolsServerAddress != null) { + // Don't await this as service extensions won't return if the target + // isolate is paused on start. + unawaited( + device!.dds.maybeCallDevToolsUriServiceExtension( + device: this, + uri: debuggingOptions.devToolsServerAddress, + ), + ); + } + + await (await device!.getLogReader(app: package)).provideVmService(vmService!); } Future exitApps({ @@ -338,17 +317,14 @@ class FlutterDevice { vmService!, fsName, rootDirectory, - osUtils: globals.os, - fileSystem: globals.fs, - logger: globals.logger, - processManager: globals.processManager, - artifacts: globals.artifacts!, buildMode: buildInfo.mode, + toolContext: _toolContext, ); return devFS!.create(); } Future startEchoingDeviceLog(DebuggingOptions debuggingOptions) async { + final Logger logger = _toolContext.logger; if (_loggingSubscription != null) { return; } @@ -357,14 +333,24 @@ class FlutterDevice { logStream = (device! as IOSDevice) .getLogReader(app: package as IOSApp?, usingCISystem: debuggingOptions.usingCISystem) .logLines; + } else if (device is AndroidDevice) { + logStream = (await (device! as AndroidDevice).getLogReader( + app: package, + adbLogFiltering: debuggingOptions.adbLogFiltering, + )).logLines; } else { logStream = (await device!.getLogReader(app: package)).logLines; } - _loggingSubscription = logStream.listen((String line) { - if (!line.contains(globals.kVMServiceMessageRegExp)) { - globals.printStatus(line, wrap: false); - } - }); + _loggingSubscription = logStream.listen( + (String line) { + if (!line.contains(kVMServiceMessageRegExp)) { + logger.printStatus(line, wrap: false); + } + }, + onError: (Object error, StackTrace stackTrace) { + logger.printTrace('Error on device log stream: $error\n$stackTrace'); + }, + ); } Future stopEchoingDeviceLog() async { @@ -376,10 +362,12 @@ class FlutterDevice { } Future runHot({required HotRunner hotRunner, String? route}) async { + final ToolContext(:FileSystem fs, :Logger logger, :FlutterProjectFactory projectFactory) = + _toolContext; final prebuiltMode = hotRunner.applicationBinary != null; final String modeName = hotRunner.debuggingOptions.buildInfo.mode.friendlyName; - globals.printStatus( - 'Launching ${getDisplayPath(hotRunner.mainPath, globals.fs)} ' + logger.printStatus( + 'Launching ${getDisplayPath(hotRunner.mainPath, fs)} ' 'on ${device!.displayName} in $modeName mode...', ); @@ -393,11 +381,15 @@ class FlutterDevice { if (applicationPackage == null) { var message = 'No application found for $targetPlatform.'; - final String? hint = await getMissingPackageHintForPlatform(targetPlatform); + final String? hint = await getMissingPackageHintForPlatform( + targetPlatform, + fileSystem: fs, + projectFactory: projectFactory, + ); if (hint != null) { message += '\n$hint'; } - globals.printError(message); + logger.printError(message); return 1; } devFSWriter = device!.createDevFSWriter(applicationPackage, userIdentifier); @@ -420,19 +412,19 @@ class FlutterDevice { final LaunchResult result = await futureResult; if (!result.started) { - globals.printError('Error launching application on ${device!.displayName}.'); + logger.printError('Error launching application on ${device!.displayName}.'); await stopEchoingDeviceLog(); return 2; } if (result.hasVmService) { - vmServiceUris = Stream.value(result.vmServiceUri).asBroadcastStream(); - } else { - vmServiceUris = const Stream.empty().asBroadcastStream(); + vmServiceUri = Future.value(result.vmServiceUri!); } return 0; } Future runCold({required ColdRunner coldRunner, String? route}) async { + final ToolContext(:FileSystem fs, :Logger logger, :FlutterProjectFactory projectFactory) = + _toolContext; final TargetPlatform targetPlatform = await device!.targetPlatform; package = await ApplicationPackageFactory.instance!.getPackageForPlatform( targetPlatform, @@ -443,11 +435,15 @@ class FlutterDevice { if (applicationPackage == null) { var message = 'No application found for $targetPlatform.'; - final String? hint = await getMissingPackageHintForPlatform(targetPlatform); + final String? hint = await getMissingPackageHintForPlatform( + targetPlatform, + fileSystem: fs, + projectFactory: projectFactory, + ); if (hint != null) { message += '\n$hint'; } - globals.printError(message); + logger.printError(message); return 1; } @@ -455,8 +451,8 @@ class FlutterDevice { final String modeName = coldRunner.debuggingOptions.buildInfo.mode.friendlyName; final prebuiltMode = coldRunner.applicationBinary != null; - globals.printStatus( - 'Launching ${getDisplayPath(coldRunner.mainPath, globals.fs)} ' + logger.printStatus( + 'Launching ${getDisplayPath(coldRunner.mainPath, fs)} ' 'on ${device!.displayName} in $modeName mode...', ); @@ -476,14 +472,12 @@ class FlutterDevice { ); if (!result.started) { - globals.printError('Error running application on ${device!.displayName}.'); + logger.printError('Error running application on ${device!.displayName}.'); await stopEchoingDeviceLog(); return 2; } if (result.hasVmService) { - vmServiceUris = Stream.value(result.vmServiceUri).asBroadcastStream(); - } else { - vmServiceUris = const Stream.empty().asBroadcastStream(); + vmServiceUri = Future.value(result.vmServiceUri!); } return 0; } @@ -500,7 +494,9 @@ class FlutterDevice { required List invalidatedFiles, required PackageConfig packageConfig, }) async { - final Status devFSStatus = globals.logger.startProgress( + final ToolContext(:FileSystem fs, :Logger logger, :FlutterProjectFactory projectFactory) = + _toolContext; + final Status devFSStatus = logger.startProgress( 'Syncing files to device ${device!.displayName}...', progressId: 'devFS.update', ); @@ -521,14 +517,16 @@ class FlutterDevice { packageConfig: packageConfig, devFSWriter: devFSWriter, shaderCompiler: developmentShaderCompiler, - dartPluginRegistrant: FlutterProject.current().dartPluginRegistrant, + dartPluginRegistrant: projectFactory + .fromDirectory(fs.currentDirectory) + .dartPluginRegistrant, ); } on DevFSException { devFSStatus.cancel(); return UpdateFSReport(); } devFSStatus.stop(); - globals.printTrace('Synced ${getSizeAsPlatformMB(report.syncedBytes)}.'); + logger.printTrace('Synced ${getSizeAsPlatformMB(report.syncedBytes)}.'); return report; } @@ -960,41 +958,71 @@ abstract class ResidentHandlers { abstract class ResidentRunner extends ResidentHandlers { ResidentRunner( this.flutterDevices, { - required this.target, + required this.analytics, + required this.buildSystem, + required this.buildTargets, required this.debuggingOptions, - String? projectRootPath, - this.stayResident = true, - this.hotMode = true, + required this.target, + required this.toolContext, + required this.xcode, + this.dartBuilder, String? dillOutputPath, + this.hotMode = true, this.machine = false, - CommandHelp? commandHelp, - this.dartBuilder, - }) : mainPath = globals.fs.file(target).absolute.path, + String? projectRootPath, + this.stayResident = true, + }) : mainPath = toolContext.fs.file(target).absolute.path, packagesFilePath = debuggingOptions.buildInfo.packageConfigPath, - projectRootPath = projectRootPath ?? globals.fs.currentDirectory.path, + projectRootPath = projectRootPath ?? toolContext.fs.currentDirectory.path, _dillOutputPath = dillOutputPath, artifactDirectory = dillOutputPath == null - ? globals.fs.systemTempDirectory.createTempSync('flutter_tool.') - : globals.fs.file(dillOutputPath).parent, + ? toolContext.fs.systemTempDirectory.createTempSync('flutter_tool.') + : toolContext.fs.file(dillOutputPath).parent, assetBundle = AssetBundleFactory.instance.createBundle(), - commandHelp = - commandHelp ?? - CommandHelp( - logger: globals.logger, - terminal: globals.terminal, - platform: globals.platform, - outputPreferences: globals.outputPreferences, - ) { + commandHelp = CommandHelp( + logger: toolContext.logger, + terminal: toolContext.terminal, + platform: toolContext.platform, + outputPreferences: toolContext.outputPreferences, + ) { if (!artifactDirectory.existsSync()) { artifactDirectory.createSync(recursive: true); } } @override - Logger get logger => globals.logger; + Logger get logger => toolContext.logger; @override - FileSystem get fileSystem => globals.fs; + FileSystem get fileSystem => toolContext.fs; + + @protected + Platform get platform => toolContext.platform; + @protected + Terminal get terminal => toolContext.terminal; + @protected + OutputPreferences get outputPreferences => toolContext.outputPreferences; + @protected + Artifacts get artifacts => toolContext.artifacts; + @protected + Config get config => toolContext.config; + @protected + Cache get cache => toolContext.cache; + @protected + FlutterVersion get flutterVersion => toolContext.flutterVersion; + @protected + ProcessManager get processManager => toolContext.processManager; + @protected + OperatingSystemUtils get osUtils => toolContext.os; + + /// The Xcode installation, or null on platforms without Xcode support. + final Xcode? xcode; + + /// The injected dependencies used by this runner. + final ToolContext toolContext; + final Analytics analytics; + final BuildTargets buildTargets; + final BuildSystem buildSystem; @override final List flutterDevices; @@ -1021,17 +1049,17 @@ abstract class ResidentRunner extends ResidentHandlers { BuildResult? _lastBuild; late final _environment = Environment( - artifacts: globals.artifacts!, - logger: globals.logger, - cacheDir: globals.cache.getRoot(), - engineVersion: globals.flutterVersion.engineRevision, - fileSystem: globals.fs, - flutterRootDir: globals.fs.directory(Cache.flutterRoot), - outputDir: globals.fs.directory(getBuildDirectory()), - processManager: globals.processManager, - platform: globals.platform, - analytics: globals.analytics, - projectDir: globals.fs.directory(projectRootPath), + artifacts: artifacts, + logger: logger, + cacheDir: cache.getRoot(), + engineVersion: flutterVersion.engineRevision, + fileSystem: fileSystem, + flutterRootDir: fileSystem.directory(Cache.flutterRoot), + outputDir: fileSystem.directory(getBuildDirectory(config, fileSystem)), + processManager: processManager, + platform: platform, + analytics: analytics, + projectDir: fileSystem.directory(projectRootPath), packageConfigPath: debuggingOptions.buildInfo.packageConfigPath, generateDartPluginRegistry: generateDartPluginRegistry, defines: { @@ -1049,7 +1077,7 @@ abstract class ResidentRunner extends ResidentHandlers { @override bool hotMode; - /// Returns true if every device is streaming vmService URIs. + /// Returns true if every device is waiting on a VM Service URI. bool get isWaitingForVmService { return flutterDevices.every((FlutterDevice? device) { return device!.isWaitingForVmService; @@ -1057,7 +1085,7 @@ abstract class ResidentRunner extends ResidentHandlers { } String get dillOutputPath => - _dillOutputPath ?? globals.fs.path.join(artifactDirectory.path, 'app.dill'); + _dillOutputPath ?? fileSystem.path.join(artifactDirectory.path, 'app.dill'); String getReloadPath({bool resetCompiler = false, required bool swap}) { if (!resetCompiler) { return 'main.dart.incremental.dill'; @@ -1078,10 +1106,12 @@ abstract class ResidentRunner extends ResidentHandlers { bool get debuggingEnabled => debuggingOptions.debuggingEnabled; @override - bool get isRunningDebug => !debuggingOptions.webUseWasm && debuggingOptions.buildInfo.isDebug; + bool get isRunningDebug => + !debuggingOptions.webConfig.useWasm && debuggingOptions.buildInfo.isDebug; @override - bool get isRunningProfile => !debuggingOptions.webUseWasm && debuggingOptions.buildInfo.isProfile; + bool get isRunningProfile => + !debuggingOptions.webConfig.useWasm && debuggingOptions.buildInfo.isProfile; @override bool get isRunningRelease => debuggingOptions.buildInfo.isRelease; @@ -1143,24 +1173,20 @@ abstract class ResidentRunner extends ResidentHandlers { @override Future runSourceGenerators() async { final compositeTarget = CompositeTarget([ - globals.buildTargets.generateLocalizationsTarget, - globals.buildTargets.dartPluginRegistrantTarget, + buildTargets.generateLocalizationsTarget, + buildTargets.dartPluginRegistrantTarget, ]); - _lastBuild = await globals.buildSystem.buildIncremental( - compositeTarget, - _environment, - _lastBuild, - ); + _lastBuild = await buildSystem.buildIncremental(compositeTarget, _environment, _lastBuild); if (!_lastBuild!.success) { for (final ExceptionMeasurement exceptionMeasurement in _lastBuild!.exceptions.values) { - globals.printError( + logger.printError( exceptionMeasurement.exception.toString(), - stackTrace: globals.logger.isVerbose ? exceptionMeasurement.stackTrace : null, + stackTrace: logger.isVerbose ? exceptionMeasurement.stackTrace : null, ); } } - globals.printTrace('complete'); + logger.printTrace('complete'); } @protected @@ -1168,11 +1194,11 @@ abstract class ResidentRunner extends ResidentHandlers { if (debuggingOptions.vmserviceOutFile != null) { try { final address = flutterDevices.first.vmService!.wsAddress.toString(); - final File vmserviceOutFile = globals.fs.file(debuggingOptions.vmserviceOutFile); + final File vmserviceOutFile = fileSystem.file(debuggingOptions.vmserviceOutFile); vmserviceOutFile.createSync(recursive: true); vmserviceOutFile.writeAsStringSync(address); } on FileSystemException { - globals.printError( + logger.printError( 'Failed to write vmservice-out-file at ${debuggingOptions.vmserviceOutFile}', ); } @@ -1213,13 +1239,13 @@ abstract class ResidentRunner extends ResidentHandlers { try { await dds.shutdown(); } on Object catch (error) { - globals.printTrace('Warning: Failed to shut down DDS for device: $error'); + logger.printTrace('Warning: Failed to shut down DDS for device: $error'); } } }), ).timeout(const Duration(seconds: 10)); } on TimeoutException { - globals.printTrace('Warning: shutdownDartDevelopmentService timed out.'); + logger.printTrace('Warning: shutdownDartDevelopmentService timed out.'); } } @@ -1228,20 +1254,26 @@ abstract class ResidentRunner extends ResidentHandlers { if (_dillOutputPath != null) { return; } - globals.printTrace('Caching compiled dill'); - final File outputDill = globals.fs.file(dillOutputPath); + logger.printTrace('Caching compiled dill'); + final ToolContext(:Config config, :FileSystem fs) = toolContext; + final File outputDill = fs.file(dillOutputPath); if (outputDill.existsSync()) { final TargetPlatform? targetPlatform = flutterDevices.firstOrNull?.targetPlatform; final TargetModel targetModel = TargetModel.fromTargetPlatform(targetPlatform); + final BuildInfo buildInfo = debuggingOptions.buildInfo; final String copyPath = getDefaultCachedKernelPath( trackWidgetCreation: trackWidgetCreation, - dartDefines: debuggingOptions.buildInfo.dartDefines, - extraFrontEndOptions: debuggingOptions.buildInfo.extraFrontEndOptions, - config: globals.config, - fileSystem: globals.fs, + dartDefines: buildInfo.dartDefines, + // Must match the options the resident compiler uses to compute the + // path it initializes from. + extraFrontEndOptions: targetModel == TargetModel.dartdevc + ? ddcFrontEndOptions(buildInfo) + : buildInfo.extraFrontEndOptions, + config: config, + fileSystem: fs, targetModel: targetModel, ); - globals.fs.file(copyPath).parent.createSync(recursive: true); + fs.file(copyPath).parent.createSync(recursive: true); outputDill.copySync(copyPath); } } @@ -1259,15 +1291,15 @@ abstract class ResidentRunner extends ResidentHandlers { if (errorsSinceReload == 0) { // We print a blank line around the first error, to more clearly emphasize it // in the output. (Other errors don't get this.) - globals.printStatus(''); + logger.printStatus(''); } - globals.printStatus('${json['renderedErrorText']}'); + logger.printStatus('${json['renderedErrorText']}'); if (errorsSinceReload == 0) { - globals.printStatus(''); + logger.printStatus(''); } } else { - globals.printError( - 'Received an invalid ${globals.logger.terminal.bolden("Flutter.Error")} message from app: $json', + logger.printError( + 'Received an invalid ${logger.terminal.bolden("Flutter.Error")} message from app: $json', ); } } @@ -1294,12 +1326,16 @@ abstract class ResidentRunner extends ResidentHandlers { continue; } try { + if (device.vmServiceUri == null) { + throw Exception('VM Service URI info not available.'); + } + final Uri vmServiceUri = await device.vmServiceUri!; await device.connect( + vmServiceUri: vmServiceUri, debuggingOptions: debuggingOptions, reloadSources: reloadSources, restart: restart, compileExpression: compileExpression, - hostVmServicePort: debuggingOptions.hostVmServicePort, printStructuredErrorLogMethod: printStructuredErrorLog, ); } catch (error) { @@ -1321,11 +1357,11 @@ abstract class ResidentRunner extends ResidentHandlers { } Future _serviceProtocolDone(dynamic object) async { - globals.printTrace('Service protocol connection closed.'); + logger.printTrace('Service protocol connection closed.'); } Future _serviceProtocolError(Object error, StackTrace stack) { - globals.printTrace('Service protocol connection closed with an error: $error\n$stack'); + logger.printTrace('Service protocol connection closed with an error: $error\n$stack'); return Future.error(error, stack); } @@ -1337,16 +1373,16 @@ abstract class ResidentRunner extends ResidentHandlers { if (_finished.isCompleted) { return; } - globals.printStatus('Lost connection to device.'); + logger.printStatus('Lost connection to device.'); - final Version? xcodeVersion = globals.xcode?.currentVersion; + final Version? xcodeVersion = xcode?.currentVersion; for (final FlutterDevice device in flutterDevices) { final Device? rawDevice = device.device; if (rawDevice is IOSDevice && debuggingOptions.buildInfo.isProfile && !(debuggingOptions.iosProfileDebugger ?? (xcodeVersion == null || xcodeVersion.major < 26))) { - globals.printStatus( + logger.printStatus( 'If the application crashed, you can attach a debugger to get a more complete ' 'stack trace by running again with the "--ios-profile-debugger" flag.', ); @@ -1361,7 +1397,7 @@ abstract class ResidentRunner extends ResidentHandlers { if (_finished.isCompleted) { return; } - globals.printStatus('Application finished.'); + logger.printStatus('Application finished.'); _finished.complete(0); } @@ -1407,7 +1443,7 @@ abstract class ResidentRunner extends ResidentHandlers { continue; } // Caution: This log line is parsed by device lab tests. - globals.printStatus( + logger.printStatus( 'A Dart VM Service on ${device.device!.name} is available at: ' '${device.vmService!.httpAddress}', ); @@ -1416,7 +1452,7 @@ abstract class ResidentRunner extends ResidentHandlers { // See https://github.com/flutter/flutter/issues/182052 final Uri? dtdUri = connectionInfo?.dtdUri ?? device.device!.dds.dtdUri; if (debuggingOptions.printDtd && dtdUri != null) { - globals.printStatus('The Dart Tooling Daemon is available at: $dtdUri'); + logger.printStatus('The Dart Tooling Daemon is available at: $dtdUri'); } final Uri? devToolsUri = device.device!.devToolsUri; if (devToolsUri != null) { @@ -1432,7 +1468,7 @@ abstract class ResidentRunner extends ResidentHandlers { return base.toString(); } - globals.printStatus( + logger.printStatus( 'The Flutter DevTools debugger and profiler ' 'on ${device.device!.name} is available at: ${urlToDisplayString(devToolsUri)}', ); @@ -1636,29 +1672,21 @@ class OperationResultExtraTiming { final int timeInMs; } -Future getMissingPackageHintForPlatform(TargetPlatform platform) async { - switch (platform) { - case TargetPlatform.android_arm: - case TargetPlatform.android_arm64: - case TargetPlatform.android_x64: - final FlutterProject project = FlutterProject.current(); - final String manifestPath = globals.fs.path.relative(project.android.appManifestFile.path); +Future getMissingPackageHintForPlatform( + TargetPlatform platform, { + required FileSystem fileSystem, + required FlutterProjectFactory projectFactory, +}) async { + switch (platform.os) { + case .android: + final FlutterProject project = projectFactory.fromDirectory(fileSystem.currentDirectory); + final String manifestPath = fileSystem.path.relative(project.android.appManifestFile.path); return 'Is your project missing an $manifestPath?\nConsider running "flutter create ." to create one.'; - case TargetPlatform.ios: + case .ios: return 'Is your project missing an ios/Runner/Info.plist?\nConsider running "flutter create ." to create one.'; - case TargetPlatform.android: - case TargetPlatform.darwin: - case TargetPlatform.fuchsia_arm64: - case TargetPlatform.fuchsia_x64: - case TargetPlatform.linux_arm64: - case TargetPlatform.linux_riscv64: - case TargetPlatform.linux_x64: - case TargetPlatform.tester: - case TargetPlatform.web_javascript: - case TargetPlatform.windows_x64: - case TargetPlatform.windows_arm64: + case .macos || .fuchsia || .linux || .tester || .web || .windows: return null; - case TargetPlatform.unsupported: + case .unsupported: TargetPlatform.throwUnsupportedTarget(); } } @@ -1667,18 +1695,13 @@ Future getMissingPackageHintForPlatform(TargetPlatform platform) async class TerminalHandler { TerminalHandler( this.residentRunner, { - required Logger logger, - required Terminal terminal, - required Signals signals, - required io.ProcessInfo processInfo, - required bool reportReady, - String? pidFile, - }) : _logger = logger, - _terminal = terminal, - _signals = signals, - _processInfo = processInfo, - _reportReady = reportReady, - _pidFile = pidFile; + required this._logger, + required this._terminal, + required this._signals, + required this._processInfo, + required this._reportReady, + this._pidFile, + }); final Logger _logger; final Terminal _terminal; diff --git a/packages/flutter_tools/lib/src/run_cold.dart b/packages/flutter_tools/lib/src/run_cold.dart index 88d41f08..821be94b 100644 --- a/packages/flutter_tools/lib/src/run_cold.dart +++ b/packages/flutter_tools/lib/src/run_cold.dart @@ -6,9 +6,7 @@ import 'dart:async'; import 'base/dds.dart'; import 'base/file_system.dart'; -import 'base/logger.dart'; import 'build_info.dart'; -import 'globals.dart' as globals; import 'resident_runner.dart'; import 'tracing.dart'; import 'vmservice.dart'; @@ -18,14 +16,19 @@ const kFlutterTestOutputsDirEnvName = 'FLUTTER_TEST_OUTPUTS_DIR'; class ColdRunner extends ResidentRunner { ColdRunner( super.flutterDevices, { - required super.target, + required super.analytics, + required super.buildSystem, + required super.buildTargets, required super.debuggingOptions, - this.traceStartup = false, - this.awaitFirstFrameWhenTracing = true, + required super.target, + required super.toolContext, + required super.xcode, this.applicationBinary, - super.stayResident, - super.machine, + this.awaitFirstFrameWhenTracing = true, super.dartBuilder, + super.machine, + super.stayResident, + this.traceStartup = false, }) : super(hotMode: false); final bool traceStartup; @@ -39,12 +42,6 @@ class ColdRunner extends ResidentRunner { @override bool get reloadIsRestart => false; - @override - Logger get logger => globals.logger; - - @override - FileSystem get fileSystem => globals.fs; - @override bool get supportsDetach => _didAttach; @@ -63,7 +60,7 @@ class ColdRunner extends ResidentRunner { } } } on Exception catch (err, stack) { - globals.printError('$err\n$stack'); + logger.printError('$err\n$stack'); appFailedToStart(); return 1; } @@ -73,14 +70,14 @@ class ColdRunner extends ResidentRunner { try { await connectToServiceProtocol(); } on Exception catch (exception) { - globals.printError(exception.toString()); + logger.printError(exception.toString()); appFailedToStart(); return 2; } } final FlutterDevice flutterDevice = flutterDevices.first; - if (flutterDevice.vmServiceUris != null) { + if (flutterDevice.vmServiceUri != null) { final FlutterVmService? vmService = flutterDevice.vmService; final DartDevelopmentService dds = flutterDevice.device!.dds; // For now, only support one debugger connection. @@ -94,27 +91,28 @@ class ColdRunner extends ResidentRunner { ); } - globals.printTrace('Application running.'); + logger.printTrace('Application running.'); for (final FlutterDevice? device in flutterDevices) { if (device!.vmService == null) { continue; } - globals.printTrace('Connected to ${device.device!.displayName}'); + logger.printTrace('Connected to ${device.device!.displayName}'); } if (traceStartup) { // Only trace startup for the first device. final FlutterDevice device = flutterDevices.first; if (device.vmService != null) { - globals.printStatus('Tracing startup on ${device.device!.displayName}.'); + logger.printStatus('Tracing startup on ${device.device!.displayName}.'); final String outputPath = - globals.platform.environment[kFlutterTestOutputsDirEnvName] ?? getBuildDirectory(); + platform.environment[kFlutterTestOutputsDirEnvName] ?? + getBuildDirectory(config, fileSystem); await downloadStartupTrace( device.vmService!, awaitFirstFrame: awaitFirstFrameWhenTracing, - logger: globals.logger, - output: globals.fs.directory(outputPath), + logger: logger, + output: fileSystem.directory(outputPath), ); } appFinished(); @@ -141,14 +139,14 @@ class ColdRunner extends ResidentRunner { try { await connectToServiceProtocol(); } on Exception catch (error) { - globals.printError('Error connecting to the service protocol: $error'); + logger.printError('Error connecting to the service protocol: $error'); return 2; } for (final FlutterDevice? device in flutterDevices) { final List views = await device!.vmService!.getFlutterViews(); for (final view in views) { - globals.printTrace('Connected to $view.'); + logger.printTrace('Connected to $view.'); } } diff --git a/packages/flutter_tools/lib/src/run_hot.dart b/packages/flutter_tools/lib/src/run_hot.dart index 17ca7be7..c66c69aa 100644 --- a/packages/flutter_tools/lib/src/run_hot.dart +++ b/packages/flutter_tools/lib/src/run_hot.dart @@ -10,7 +10,6 @@ import 'package:pool/pool.dart'; import 'package:unified_analytics/unified_analytics.dart'; import 'package:vm_service/vm_service.dart' as vm_service; -import 'base/context.dart'; import 'base/dds.dart'; import 'base/file_system.dart'; import 'base/logger.dart'; @@ -21,21 +20,9 @@ import 'compile.dart'; import 'convert.dart'; import 'devfs.dart'; import 'device.dart'; -import 'globals.dart' as globals; -import 'project.dart'; import 'resident_runner.dart'; import 'vmservice.dart'; -ProjectFileInvalidator get projectFileInvalidator => - context.get() ?? - ProjectFileInvalidator( - fileSystem: globals.fs, - platform: globals.platform, - logger: globals.logger, - ); - -HotRunnerConfig? get hotRunnerConfig => context.get(); - class HotRunnerConfig { /// Should the hot runner assume that the minimal Dart dependencies do not change? bool stableDartDependencies = false; @@ -79,34 +66,43 @@ class DeviceReloadReport { class HotRunner extends ResidentRunner { HotRunner( super.flutterDevices, { - required super.target, + required super.analytics, + required super.buildSystem, + required super.buildTargets, required super.debuggingOptions, - this.benchmarkMode = false, + required super.target, + required super.toolContext, + required super.xcode, this.applicationBinary, + this.benchmarkMode = false, + super.dartBuilder, + super.dillOutputPath, this.hostIsIde = false, - Logger? logger, + HotRunnerConfig? hotRunnerConfig, + super.machine, + this._nativeAssetsYamlFile, + ProjectFileInvalidator? projectFileInvalidator, super.projectRootPath, - super.dillOutputPath, + this._reassembleHelper, + this._reloadSourcesHelper = defaultReloadSourcesHelper, super.stayResident, - super.machine, - StopwatchFactory stopwatchFactory = const StopwatchFactory(), - ReloadSourcesHelper reloadSourcesHelper = defaultReloadSourcesHelper, - ReassembleHelper reassembleHelper = _defaultReassembleHelper, - String? nativeAssetsYamlFile, - required Analytics analytics, - super.dartBuilder, - }) : _stopwatchFactory = stopwatchFactory, - _reloadSourcesHelper = reloadSourcesHelper, - _reassembleHelper = reassembleHelper, - _nativeAssetsYamlFile = nativeAssetsYamlFile, - _analytics = analytics, - _logger = logger, + this._stopwatchFactory = const StopwatchFactory(), + }) : hotRunnerConfig = hotRunnerConfig ?? HotRunnerConfig(), + projectFileInvalidator = + projectFileInvalidator ?? + ProjectFileInvalidator( + fileSystem: toolContext.fs, + platform: toolContext.platform, + logger: toolContext.logger, + ), super(hotMode: true); final StopwatchFactory _stopwatchFactory; final ReloadSourcesHelper _reloadSourcesHelper; - final ReassembleHelper _reassembleHelper; - final Analytics _analytics; + final ReassembleHelper? _reassembleHelper; + final String? _nativeAssetsYamlFile; + final ProjectFileInvalidator projectFileInvalidator; + final HotRunnerConfig hotRunnerConfig; final bool benchmarkMode; final File? applicationBinary; @@ -135,12 +131,8 @@ class HotRunner extends ResidentRunner { String? _sdkName; bool? _emulator; - final String? _nativeAssetsYamlFile; - String? flavor; - final Logger? _logger; - @override bool get supportsDetach => stopAppDuringCleanup; @@ -277,7 +269,7 @@ class HotRunner extends ResidentRunner { if (error is! Exception && error is! String) { rethrow; } - globals.printError('Error connecting to the service protocol: $error'); + logger.printError('Error connecting to the service protocol: $error'); return 2; } @@ -301,7 +293,7 @@ class HotRunner extends ResidentRunner { ); } } on DevFSException catch (error) { - globals.printError('Error initializing DevFS: $error'); + logger.printError('Error initializing DevFS: $error'); return 3; } @@ -329,7 +321,7 @@ class HotRunner extends ResidentRunner { } final List views = await device.vmService!.getFlutterViews(); for (final view in views) { - globals.printTrace('Connected to $view.'); + logger.printTrace('Connected to $view.'); } } @@ -339,27 +331,27 @@ class HotRunner extends ResidentRunner { // Wait multiple seconds for the isolate to have fully started. await Future.delayed(const Duration(seconds: 10)); // We are running in benchmark mode. - globals.printStatus('Running in benchmark mode.'); + logger.printStatus('Running in benchmark mode.'); // Measure time to perform a hot restart. - globals.printStatus('Benchmarking hot restart'); + logger.printStatus('Benchmarking hot restart'); await restart(fullRestart: true); // Wait multiple seconds to stabilize benchmark on slower device lab hardware. // Hot restart finishes when the new isolate is started, not when the new isolate // is ready. This process can actually take multiple seconds. await Future.delayed(const Duration(seconds: 10)); - globals.printStatus('Benchmarking hot reload'); + logger.printStatus('Benchmarking hot reload'); // Measure time to perform a hot reload. await restart(); if (stayResident) { await waitForAppToFinish(); } else { - globals.printStatus('Benchmark completed. Exiting application.'); + logger.printStatus('Benchmark completed. Exiting application.'); await _cleanupDevFS(); await stopEchoingDeviceLog(); await exitApp(); } - final File benchmarkOutput = globals.fs.file('hot_benchmark.json'); + final File benchmarkOutput = fileSystem.file('hot_benchmark.json'); benchmarkOutput.writeAsStringSync(toPrettyJson(benchmarkData)); return 0; } @@ -382,11 +374,11 @@ class HotRunner extends ResidentRunner { await _calculateTargetPlatform(); final Uri? nativeAssetsYaml = _nativeAssetsYamlFile != null - ? globals.fs.path.toUri(_nativeAssetsYamlFile) + ? fileSystem.path.toUri(_nativeAssetsYamlFile) : null; final appStartedTimer = Stopwatch()..start(); - final File mainFile = globals.fs.file(mainPath); + final File mainFile = fileSystem.file(mainPath); Duration totalCompileTime = Duration.zero; Duration totalLaunchAppTime = Duration.zero; @@ -411,11 +403,13 @@ class HotRunner extends ResidentRunner { // should only be displayed once. suppressErrors: applicationBinary == null, checkDartPluginRegistry: true, - dartPluginRegistrant: FlutterProject.current().dartPluginRegistrant, + dartPluginRegistrant: toolContext.projectFactory + .fromDirectory(fileSystem.currentDirectory) + .dartPluginRegistrant, outputPath: dillOutputPath, packageConfig: debuggingOptions.buildInfo.packageConfig, - projectRootPath: FlutterProject.current().directory.absolute.path, - fs: globals.fs, + projectRootPath: fileSystem.directory(projectRootPath).absolute.path, + fs: fileSystem, nativeAssetsYaml: nativeAssetsYaml, ) .then((CompilerOutput? output) { @@ -437,7 +431,7 @@ class HotRunner extends ResidentRunner { unawaited( appStartedCompleter?.future.then((_) { - _analytics.send( + analytics.send( Event.hotRunnerInfo( label: 'reload-ready', targetPlatform: _targetPlatformName!, @@ -460,7 +454,7 @@ class HotRunner extends ResidentRunner { } cacheInitialDillCompilation(); } on Exception catch (err) { - globals.printError(err.toString()); + logger.printError(err.toString()); appFailedToStart(); return 1; } @@ -473,10 +467,10 @@ class HotRunner extends ResidentRunner { } Future> _initDevFS() async { - final String fsName = globals.fs.path.basename(projectRootPath); + final String fsName = fileSystem.path.basename(projectRootPath); return [ for (final FlutterDevice? device in flutterDevices) - await device!.setupDevFS(fsName, globals.fs.directory(projectRootPath)), + await device!.setupDevFS(fsName, fileSystem.directory(projectRootPath)), ]; } @@ -488,12 +482,12 @@ class HotRunner extends ResidentRunner { final bool rebuildBundle = assetBundle.needsBuild(); if (rebuildBundle) { for (final targetPlatform in targetPlatforms) { - globals.printTrace('Updating assets for ${targetPlatform.osName}'); + logger.printTrace('Updating assets for ${targetPlatform.osName}'); final int result = await assetBundle.build( flutterHookResult: await dartBuilder?.runHooks( targetPlatform: targetPlatform, environment: environment, - logger: _logger, + logger: logger, ), packageConfigPath: debuggingOptions.buildInfo.packageConfigPath, flavor: debuggingOptions.buildInfo.flavor, @@ -512,13 +506,13 @@ class HotRunner extends ResidentRunner { lastCompiled: devFS.lastCompiled, urisToMonitor: devFS.sources, packagesPath: packagesFilePath, - asyncScanning: hotRunnerConfig!.asyncScanning, + asyncScanning: hotRunnerConfig.asyncScanning, packageConfig: devFS.lastPackageConfig ?? debuggingOptions.buildInfo.packageConfig, ); findInvalidationTimer.stop(); - final File entrypointFile = globals.fs.file(mainPath); + final File entrypointFile = fileSystem.file(mainPath); if (!entrypointFile.existsSync()) { - globals.printError( + logger.printError( 'The entrypoint file (i.e. the file with main()) ${entrypointFile.path} ' 'cannot be found. Moving or renaming this file will prevent changes to ' 'its contents from being discovered during hot reload/restart until ' @@ -575,7 +569,7 @@ class HotRunner extends ResidentRunner { .then( (Object? _) {}, onError: (Object? error, StackTrace stackTrace) { - globals.printTrace('Ignored error while cleaning up DevFS: $error\n$stackTrace'); + logger.printTrace('Ignored error while cleaning up DevFS: $error\n$stackTrace'); }, ), ); @@ -600,7 +594,7 @@ class HotRunner extends ResidentRunner { _swap ? 'main.dart.swap.dill' : 'main.dart.dill', ); final Uri deviceAssetsDirectoryUri = device.devFS!.baseUri!.resolveUri( - globals.fs.path.toUri(getAssetBuildDirectory()), + fileSystem.path.toUri(getAssetBuildDirectory()), ); futures.add(_launchInView(device, deviceEntryUri, deviceAssetsDirectoryUri)); } @@ -613,7 +607,7 @@ class HotRunner extends ResidentRunner { try { updatedDevFS = await _updateDevFS(fullRestart: true, targetPlatforms: _targetPlatforms); } finally { - hotRunnerConfig!.updateDevFSComplete(); + hotRunnerConfig.updateDevFSComplete(); } if (!updatedDevFS.success) { for (final FlutterDevice? device in flutterDevices) { @@ -718,17 +712,17 @@ class HotRunner extends ResidentRunner { } } await Future.wait(operations); - globals.printTrace('Finished waiting on operations.'); + logger.printTrace('Finished waiting on operations.'); await _launchFromDevFS(); restartTimer.stop(); - globals.printTrace( + logger.printTrace( 'Hot restart performed in ${getElapsedAsMilliseconds(restartTimer.elapsed)}.', ); _addBenchmarkData('hotRestartMillisecondsToFrame', restartTimer.elapsed.inMilliseconds); // Send timing analytics. final Duration elapsedDuration = restartTimer.elapsed; - _analytics.send( + analytics.send( Event.timing( workflow: 'hot', variableName: 'restart', @@ -751,19 +745,20 @@ class HotRunner extends ResidentRunner { static bool validateReloadReport( vm_service.ReloadReport? reloadReport, { bool printErrors = true, + Logger? logger, }) { if (reloadReport == null) { if (printErrors) { - globals.printError('Hot reload did not receive reload report.'); + logger?.printError('Hot reload did not receive reload report.'); } return false; } final contents = ReloadReportContents.fromReloadReport(reloadReport); if (!reloadReport.success!) { if (printErrors) { - globals.printError('Hot reload was rejected:'); + logger?.printError('Hot reload was rejected:'); for (final ReasonForCancelling reason in contents.notices) { - globals.printError(reason.toString()); + logger?.printError(reason.toString()); } } return false; @@ -796,7 +791,7 @@ class HotRunner extends ResidentRunner { silent: silent, ); if (!silent) { - globals.printStatus('Restarted application in ${getElapsedAsMilliseconds(timer.elapsed)}.'); + logger.printStatus('Restarted application in ${getElapsedAsMilliseconds(timer.elapsed)}.'); } for (final FlutterDevice? device in flutterDevices) { unawaited(device?.handleHotRestart()); @@ -817,9 +812,9 @@ class HotRunner extends ResidentRunner { final String extraTimingsString = result.extraTimings .map((OperationResultExtraTiming e) => '${e.description}: ${e.timeInMs} ms') .join(', '); - globals.printStatus('${result.message} in $elapsed ($extraTimingsString).'); + logger.printStatus('${result.message} in $elapsed ($extraTimingsString).'); } else { - globals.printStatus('${result.message} in $elapsed.'); + logger.printStatus('${result.message} in $elapsed.'); } } } @@ -838,14 +833,14 @@ class HotRunner extends ResidentRunner { } Status? status; if (!silent!) { - status = globals.logger.startProgress('Performing hot restart...', progressId: 'hot.restart'); + status = logger.startProgress('Performing hot restart...', progressId: 'hot.restart'); } OperationResult result; String? restartEvent; try { final Stopwatch restartTimer = _stopwatchFactory.createStopwatch('fullRestartHelper') ..start(); - if ((await hotRunnerConfig!.setupHotRestart()) != true) { + if ((await hotRunnerConfig.setupHotRestart()) != true) { return OperationResult(1, 'setupHotRestart failed'); } result = await _restartFromSources(reason: reason); @@ -853,7 +848,7 @@ class HotRunner extends ResidentRunner { if (!result.isOk) { restartEvent = 'restart-failed'; } else { - _analytics.send( + analytics.send( Event.hotRunnerInfo( label: 'restart', targetPlatform: targetPlatform!, @@ -881,7 +876,7 @@ class HotRunner extends ResidentRunner { // The `restartEvent` variable will be null if restart succeeded. We will // only handle the case when it failed here. if (restartEvent != null) { - _analytics.send( + analytics.send( Event.hotRunnerInfo( label: restartEvent, targetPlatform: targetPlatform!, @@ -904,10 +899,7 @@ class HotRunner extends ResidentRunner { String? reason, bool? pause, }) async { - Status status = globals.logger.startProgress( - 'Performing hot reload...', - progressId: 'hot.reload', - ); + Status status = logger.startProgress('Performing hot reload...', progressId: 'hot.reload'); OperationResult result; try { result = await _reloadSources( @@ -918,7 +910,7 @@ class HotRunner extends ResidentRunner { pause: pause, onSlow: (String message) { status.cancel(); - status = globals.logger.startProgress(message, progressId: 'hot.reload'); + status = logger.startProgress(message, progressId: 'hot.reload'); }, ); } on vm_service.RPCError catch (error) { @@ -931,7 +923,7 @@ class HotRunner extends ResidentRunner { 'the source code. Please address the error and then use "R" to ' 'restart the app.\n' '${error.message} (error code: ${error.code})'; - _analytics.send( + analytics.send( Event.hotRunnerInfo( label: 'reload-barred', targetPlatform: targetPlatform!, @@ -942,7 +934,7 @@ class HotRunner extends ResidentRunner { ), ); } else { - _analytics.send( + analytics.send( Event.hotRunnerInfo( label: 'exception', targetPlatform: targetPlatform!, @@ -981,7 +973,7 @@ class HotRunner extends ResidentRunner { final Stopwatch reloadTimer = _stopwatchFactory.createStopwatch('reloadSources:reload') ..start(); - if ((await hotRunnerConfig!.setupHotReload()) != true) { + if ((await hotRunnerConfig.setupHotReload()) != true) { return OperationResult(1, 'setupHotReload failed'); } final devFSTimer = Stopwatch()..start(); @@ -989,7 +981,7 @@ class HotRunner extends ResidentRunner { try { updatedDevFS = await _updateDevFS(targetPlatforms: _targetPlatforms); } finally { - hotRunnerConfig!.updateDevFSComplete(); + hotRunnerConfig.updateDevFSComplete(); } // Record time it took to synchronize to DevFS. var shouldReportReloadTime = true; @@ -1016,7 +1008,7 @@ class HotRunner extends ResidentRunner { sdkName, emulator, reason, - globals.analytics, + analytics, ); if (result.code != 0) { return result; @@ -1033,12 +1025,9 @@ class HotRunner extends ResidentRunner { final Stopwatch reassembleTimer = _stopwatchFactory.createStopwatch('reloadSources:reassemble') ..start(); - final ReassembleResult reassembleResult = await _reassembleHelper( - flutterDevices, - viewCache, - onSlow, - reloadMessage, - ); + final ReassembleResult reassembleResult = _reassembleHelper != null + ? await _reassembleHelper(flutterDevices, viewCache, onSlow, reloadMessage) + : await _defaultReassembleHelper(this, flutterDevices, viewCache, onSlow, reloadMessage); shouldReportReloadTime = reassembleResult.shouldReportReloadTime; if (reassembleResult.reassembleViews.isEmpty) { return OperationResult(OperationResult.ok.code, reloadMessage); @@ -1060,7 +1049,7 @@ class HotRunner extends ResidentRunner { // many libraries were affected by the hot reload request. // Relation of [invalidatedSourcesCount] to [syncedLibraryCount] should help // understand sync/transfer "overhead" of updating this number of source files. - _analytics.send( + analytics.send( Event.hotRunnerInfo( label: 'reload', targetPlatform: targetPlatform!, @@ -1085,7 +1074,7 @@ class HotRunner extends ResidentRunner { ); if (shouldReportReloadTime) { - globals.printTrace('Hot reload performed in ${getElapsedAsMilliseconds(reloadDuration)}.'); + logger.printTrace('Hot reload performed in ${getElapsedAsMilliseconds(reloadDuration)}.'); // Record complete time it took for the reload. _addBenchmarkData('hotReloadMillisecondsToFrame', reloadInMs); } @@ -1093,7 +1082,7 @@ class HotRunner extends ResidentRunner { if ((reassembleResult.reassembleViews.length == 1) && !reassembleResult.failedReassemble && shouldReportReloadTime) { - _analytics.send( + analytics.send( Event.timing( workflow: 'hot', variableName: 'reload', @@ -1112,7 +1101,7 @@ class HotRunner extends ResidentRunner { Future confirmAssetDirectory(FlutterDevice device, List views) async { if (!device.devFS!.hasSetAssetDirectory) { final Uri deviceAssetsDirectoryUri = device.devFS!.baseUri!.resolveUri( - globals.fs.path.toUri(getAssetBuildDirectory()), + fileSystem.path.toUri(getAssetBuildDirectory()), ); final List activeViews = views .where((FlutterView view) => view.uiIsolate != null) @@ -1124,14 +1113,13 @@ class HotRunner extends ResidentRunner { uiIsolateId: view.uiIsolate!.id, viewId: view.id, windows: - (device.targetPlatform == TargetPlatform.tester && globals.platform.isWindows) || - device.targetPlatform == TargetPlatform.windows_x64 || - device.targetPlatform == TargetPlatform.windows_arm64, + (device.targetPlatform == TargetPlatform.tester && platform.isWindows) || + device.targetPlatform.os == .windows, ), ), ); for (final view in activeViews) { - globals.printTrace('Set asset directory in $view.'); + logger.printTrace('Set asset directory in $view.'); } device.devFS!.hasSetAssetDirectory = true; } @@ -1140,7 +1128,7 @@ class HotRunner extends ResidentRunner { @override Future cleanupAfterSignal() async { await stopEchoingDeviceLog(); - await hotRunnerConfig!.runPreShutdownOperations(); + await hotRunnerConfig.runPreShutdownOperations(); await shutdownDartDevelopmentService(); if (stopAppDuringCleanup) { return exitApp(); @@ -1151,7 +1139,7 @@ class HotRunner extends ResidentRunner { @override Future preExit() async { await _cleanupDevFS(); - await hotRunnerConfig!.runPreShutdownOperations(); + await hotRunnerConfig.runPreShutdownOperations(); await super.preExit(); } @@ -1165,18 +1153,17 @@ class HotRunner extends ResidentRunner { } } -typedef ReloadSourcesHelper = - Future Function( - HotRunner hotRunner, - List flutterDevices, - bool? pause, - Map firstReloadDetails, - String? targetPlatform, - String? sdkName, - bool? emulator, - String? reason, - Analytics analytics, - ); +typedef ReloadSourcesHelper = Future Function( + HotRunner hotRunner, + List flutterDevices, + bool? pause, + Map firstReloadDetails, + String? targetPlatform, + String? sdkName, + bool? emulator, + String? reason, + Analytics analytics, +); @visibleForTesting Future defaultReloadSourcesHelper( @@ -1201,29 +1188,28 @@ Future defaultReloadSourcesHelper( pause: pause, ); allReportsFutures.add( - Future.wait(reportFutures).then(( - List reports, - ) async { - // TODO(aam): Investigate why we are validating only first reload report, - // which seems to be current behavior - if (reports.isEmpty) { - return null; - } - final vm_service.ReloadReport firstReport = reports.first; - // Don't print errors because they will be printed further down when - // `validateReloadReport` is called again. - await device.updateReloadStatus( - HotRunner.validateReloadReport(firstReport, printErrors: false), - ); - return DeviceReloadReport(device, reports); - }), + Future.wait(reportFutures) + .then((List reports) async { + // TODO(aam): Investigate why we are validating only first reload report, + // which seems to be current behavior + if (reports.isEmpty) { + return null; + } + final vm_service.ReloadReport firstReport = reports.first; + // Don't print errors because they will be printed further down when + // `validateReloadReport` is called again. + await device.updateReloadStatus( + HotRunner.validateReloadReport(firstReport, printErrors: false), + ); + return DeviceReloadReport(device, reports); + }), ); } - final Iterable reports = (await Future.wait( - allReportsFutures, - )).whereType(); + final Iterable reports = (await Future.wait(allReportsFutures)) + .whereType(); final vm_service.ReloadReport? reloadReport = reports.isEmpty ? null : reports.first.reports[0]; - if (reloadReport == null || !HotRunner.validateReloadReport(reloadReport)) { + if (reloadReport == null || + !HotRunner.validateReloadReport(reloadReport, logger: hotRunner.logger)) { analytics.send( Event.hotRunnerInfo( label: 'reload-reject', @@ -1250,7 +1236,7 @@ Future defaultReloadSourcesHelper( final details = reloadReport.json!['details'] as Map; final loadedLibraryCount = details['loadedLibraryCount'] as int?; final finalLibraryCount = details['finalLibraryCount'] as int?; - globals.printTrace('reloaded $loadedLibraryCount of $finalLibraryCount libraries'); + hotRunner.logger.printTrace('reloaded $loadedLibraryCount of $finalLibraryCount libraries'); // reloadMessage = 'Reloaded $loadedLibraryCount of $finalLibraryCount libraries'; // Record time it took for the VM to reload the sources. hotRunner._addBenchmarkData( @@ -1291,20 +1277,21 @@ class ReassembleResult { final bool shouldReportReloadTime; } -typedef ReassembleHelper = - Future Function( - List flutterDevices, - Map> viewCache, - void Function(String message)? onSlow, - String reloadMessage, - ); +typedef ReassembleHelper = Future Function( + List flutterDevices, + Map> viewCache, + void Function(String message)? onSlow, + String reloadMessage, +); Future _defaultReassembleHelper( + HotRunner hotRunner, List flutterDevices, Map> viewCache, void Function(String message)? onSlow, String reloadMessage, ) async { + final Logger logger = hotRunner.logger; // Check if any isolates are paused and reassemble those that aren't. final reassembleViews = {}; final reassembleFutures = >[]; @@ -1344,9 +1331,7 @@ Future _defaultReassembleHelper( return Future.error(error, stackTrace); } failedReassemble = true; - globals.printError( - 'Reassembling ${view.uiIsolate!.name} failed: $error\n$stackTrace', - ); + logger.printError('Reassembling ${view.uiIsolate!.name} failed: $error\n$stackTrace'); }, ), ); @@ -1360,13 +1345,13 @@ Future _defaultReassembleHelper( ); } if (reassembleViews.isEmpty) { - globals.printTrace('Skipping reassemble because all isolates are paused.'); + logger.printTrace('Skipping reassemble because all isolates are paused.'); return ReassembleResult(reassembleViews, failedReassemble, shouldReportReloadTime); } } assert(reassembleViews.isNotEmpty); - globals.printTrace('Reassembling application'); + logger.printTrace('Reassembling application'); final Future reassembleFuture = Future.wait(reassembleFutures).then((void _) => null); await reassembleFuture.timeout( @@ -1377,7 +1362,7 @@ Future _defaultReassembleHelper( return; // probably no point waiting, they're probably deadlocked and we've already warned. } // Check if any isolate is newly paused. - globals.printTrace('This is taking a long time; will now check for paused isolates.'); + logger.printTrace('This is taking a long time; will now check for paused isolates.'); var postReloadPausedIsolatesFound = 0; String? serviceEventKind; for (final FlutterView view in reassembleViews.keys) { @@ -1392,7 +1377,7 @@ Future _defaultReassembleHelper( } } } - globals.printTrace('Found $postReloadPausedIsolatesFound newly paused isolate(s).'); + logger.printTrace('Found $postReloadPausedIsolatesFound newly paused isolate(s).'); if (postReloadPausedIsolatesFound == 0) { await reassembleFuture; // must just be taking a long time... keep waiting! return; @@ -1444,12 +1429,10 @@ class InvalidationResult { /// application to determine when they are dirty. class ProjectFileInvalidator { ProjectFileInvalidator({ - required FileSystem fileSystem, - required Platform platform, - required Logger logger, - }) : _fileSystem = fileSystem, - _platform = platform, - _logger = logger; + required this._fileSystem, + required this._platform, + required this._logger, + }); final FileSystem _fileSystem; final Platform _platform; @@ -1486,6 +1469,20 @@ class ProjectFileInvalidator { if (_isNotInPubCache(uri)) uri, ]; final invalidatedFiles = []; + + final bool Function(DateTime) isInvalidated; + if (_platform.isWindows) { + // On Windows, FileStat.modified truncates to second precision (via GetFileAttributesExW). + // However, lastCompiled is recorded with millisecond precision. + final lastCompiledTruncated = DateTime.fromMillisecondsSinceEpoch( + (lastCompiled.millisecondsSinceEpoch ~/ 1000) * 1000, + isUtc: lastCompiled.isUtc, + ); + isInvalidated = (DateTime updatedAt) => !updatedAt.isBefore(lastCompiledTruncated); + } else { + isInvalidated = (DateTime updatedAt) => updatedAt.isAfter(lastCompiled); + } + if (asyncScanning) { final pool = Pool(_kMaxPendingStats); final waitList = >[]; @@ -1502,7 +1499,7 @@ class ProjectFileInvalidator { : _fileSystem.stat(uri.toFilePath(windows: _platform.isWindows))) .then((FileStat stat) { final DateTime updatedAt = stat.modified; - if (updatedAt.isAfter(lastCompiled)) { + if (isInvalidated(updatedAt)) { invalidatedFiles.add(uri); } }), @@ -1517,7 +1514,7 @@ class ProjectFileInvalidator { final DateTime updatedAt = uri.hasScheme && uri.scheme != 'file' ? _fileSystem.file(uri).statSync().modified : _fileSystem.statSync(uri.toFilePath(windows: _platform.isWindows)).modified; - if (updatedAt.isAfter(lastCompiled)) { + if (isInvalidated(updatedAt)) { invalidatedFiles.add(uri); } } @@ -1527,7 +1524,7 @@ class ProjectFileInvalidator { final File packageFile = _fileSystem.file(packagesPath); final Uri packageUri = packageFile.uri; final DateTime updatedAt = packageFile.statSync().modified; - if (updatedAt.isAfter(lastCompiled)) { + if (isInvalidated(updatedAt)) { invalidatedFiles.add(packageUri); } diff --git a/packages/flutter_tools/lib/src/runner/flutter_command.dart b/packages/flutter_tools/lib/src/runner/flutter_command.dart index 057ffbb3..21192e1c 100644 --- a/packages/flutter_tools/lib/src/runner/flutter_command.dart +++ b/packages/flutter_tools/lib/src/runner/flutter_command.dart @@ -4,7 +4,6 @@ import 'package:args/args.dart'; import 'package:args/command_runner.dart'; -import 'package:file/file.dart'; import 'package:meta/meta.dart'; import 'package:package_config/package_config_types.dart'; import 'package:unified_analytics/unified_analytics.dart'; @@ -12,26 +11,45 @@ import 'package:unified_analytics/unified_analytics.dart'; import '../application_package.dart'; import '../base/common.dart'; import '../base/context.dart'; +import '../base/file_system.dart'; import '../base/io.dart' as io; import '../base/io.dart'; +import '../base/logger.dart'; import '../base/os.dart'; +import '../base/platform.dart'; +import '../base/signals.dart'; +import '../base/terminal.dart'; +import '../base/time.dart'; +import '../base/user_messages.dart'; import '../base/utils.dart'; import '../build_info.dart'; import '../bundle.dart' as bundle; import '../cache.dart'; +import '../context/tool_context.dart'; import '../convert.dart'; import '../dart/package_map.dart'; import '../dart/pub.dart'; import '../device.dart'; import '../features.dart'; import '../globals.dart' as globals; +import '../persistent_tool_state.dart'; +import '../pre_run_validator.dart'; import '../project.dart'; import '../reporting/unified_analytics.dart'; import '../version.dart'; +import '../web/web_options.dart'; import 'flutter_command_runner.dart'; +import 'options/common_options.dart'; +import 'options/option_bundle.dart'; +import 'options/option_descriptor.dart'; +import 'options/safe_arg_results.dart'; import 'target_devices.dart'; export '../cache.dart' show DevelopmentArtifact; +export 'options/common_options.dart'; +export 'options/option_bundle.dart'; +export 'options/option_descriptor.dart'; +export 'options/safe_arg_results.dart'; abstract class DotEnvRegex { // Dot env multi-line block value regex @@ -148,10 +166,10 @@ abstract final class FlutterOptions { static const kWebBrowserFlag = 'web-browser-flag'; static const kWebResourcesCdnFlag = 'web-resources-cdn'; static const kWebWasmFlag = 'wasm'; - static const kWebExperimentalHotReload = 'web-experimental-hot-reload'; static const kEnableImpeller = 'enable-impeller'; static const kCodesignIdentity = 'codesign-identity'; static const kCodesign = 'codesign'; + static const kAdbLogFiltering = 'adb-log-filtering'; } /// flutter command categories for usage. @@ -162,7 +180,37 @@ abstract final class FlutterCommandCategory { } abstract class FlutterCommand extends Command { + FlutterCommand({this.verboseHelp = false, ToolContext? toolContext, this._outputPreferences}) + : _explicitToolContext = toolContext; + + /// Whether this command was invoked with verbose help enabled. + final bool verboseHelp; + + final ToolContext? _explicitToolContext; + final OutputPreferences? _outputPreferences; + + /// The [ToolContext] providing explicit dependency injection for this command. + ToolContext? get toolContext => _explicitToolContext ?? runner?.toolContext; + + SystemClock get _clock => toolContext?.systemClock ?? globals.systemClock; + Logger get _logger => toolContext?.logger ?? globals.logger; + Signals get _signals => toolContext?.signals ?? globals.signals; + UserMessages get _userMessages => toolContext?.userMessages ?? globals.userMessages; + PreRunValidator get _preRunValidator => toolContext?.preRunValidator ?? globals.preRunValidator; + OperatingSystemUtils get _os => toolContext?.os ?? globals.os; + PersistentToolState? get _persistentToolState => + toolContext?.persistentToolState ?? globals.persistentToolState; + Platform get _platform => toolContext?.platform ?? globals.platform; + FileSystem get _fs => toolContext?.fs ?? globals.fs; + FlutterProjectFactory get _projectFactory => + toolContext?.projectFactory ?? globals.projectFactory; + Analytics get _analytics => runner?.analytics ?? globals.analytics; + Cache get _cache => toolContext?.cache ?? globals.cache; + FlutterVersion get _flutterVersion => toolContext?.flutterVersion ?? globals.flutterVersion; + FileSystemUtils get _fsUtils => toolContext?.fileSystemUtils ?? globals.fsUtils; + /// The currently executing command (or sub-command). + /// /// Will be `null` until the top-most command has begun execution. static FlutterCommand? get current => context.get(); @@ -215,12 +263,16 @@ abstract class FlutterCommand extends Command { ]; @override - ArgParser get argParser => _argParser; - final _argParser = ArgParser( - usageLineLength: globals.outputPreferences.wrapText - ? globals.outputPreferences.wrapColumn - : null, - ); + ArgParser get argParser => _argParser ??= ArgParser(usageLineLength: _usageLineLength); + ArgParser? _argParser; + + int? get _usageLineLength { + final OutputPreferences? prefs = _outputPreferences ?? toolContext?.outputPreferences; + if (prefs != null) { + return prefs.wrapText ? prefs.wrapColumn : null; + } + return null; + } @override FlutterCommandRunner? get runner => super.runner as FlutterCommandRunner?; @@ -230,27 +282,50 @@ abstract class FlutterCommand extends Command { /// Whether this command uses the 'target' option. var _usesTargetOption = false; + /// Enables the target option flag behavior on this command. + void enableUsesTargetOption() { + _usesTargetOption = true; + } + var _usesPubOption = false; + /// Enables the pub option flag behavior on this command. + void enableUsesPubOption() { + _usesPubOption = true; + } + var _usesPortOption = false; + /// Enables the VM service port option behavior on this command. + void enableUsesPortOption() { + _usesPortOption = true; + } + var _usesIpv6Flag = false; + /// Enables the IPv6 flag behavior on this command. + void enableUsesIpv6Flag() { + _usesIpv6Flag = true; + } + var _usesFatalWarnings = false; + /// Enables the fatal warnings flag behavior on this command. + void enableUsesFatalWarnings() { + _usesFatalWarnings = true; + } + DeprecationBehavior get deprecationBehavior => DeprecationBehavior.none; - bool get shouldRunPub => _usesPubOption && boolArg('pub'); + bool get shouldRunPub => _usesPubOption && getValue(CommonOptions.pub); - bool get outputMachineFormat => - argParser.options.containsKey(FlutterGlobalOptions.kMachineFlag) && - boolArg(FlutterGlobalOptions.kMachineFlag); + bool get outputMachineFormat => getValue(CommonOptions.machine); bool get shouldUpdateCache => true; bool get deprecated => false; - ProcessInfo get processInfo => globals.processInfo; + ProcessInfo get processInfo => toolContext?.processInfo ?? globals.processInfo; /// When the command runs and this is true, trigger an async process to /// discover devices from discoverers that support wireless devices for an @@ -266,230 +341,110 @@ abstract class FlutterCommand extends Command { /// Grabs the [Analytics] instance from the global context. It is defined /// at the [FlutterCommand] level to enable any classes that extend it to /// easily reference it or overwrite as necessary. - Analytics get analytics => globals.analytics; + Analytics get analytics => _analytics; + + /// Hook called by the command runner before parsing arguments, + /// allowing the command to perform asynchronous initialization + /// (e.g. querying extensions) to populate its dynamic options or subcommands. + Future initializeDynamicOptions() async {} + + /// Registers an [OptionBundle] with this command. + void registerOptionBundle(OptionBundle bundle) { + bundle.register(this, argParser); + } + + /// Registers multiple [OptionBundle] instances with this command. + void registerOptionBundles(List bundles) => bundles.forEach(registerOptionBundle); void requiresPubspecYaml() { _requiresPubspecYaml = true; } void usesWebOptions({required bool verboseHelp}) { - argParser.addMultiOption( - 'web-header', - help: - 'Additional key-value pairs that will added by the web server ' - 'as headers to all responses. Multiple headers can be passed by ' - 'repeating "--web-header" multiple times.', - valueHelp: 'X-Custom-Header=header-value', - splitCommas: false, - hide: !verboseHelp, - ); - argParser.addOption( - 'web-hostname', - help: - 'The hostname that the web server will use to resolve an IP to serve ' - 'from. The unresolved hostname is used to launch Chrome when using ' - 'the chrome Device. The name "any" may also be used to serve on any ' - 'IPV4 for either the Chrome or web-server device.', - hide: !verboseHelp, - ); - argParser.addOption( - 'web-port', - help: - 'The host port to serve the web application from. If not provided, the tool ' - 'will select a random open port on the host.', - hide: !verboseHelp, - ); - argParser.addOption( - 'web-tls-cert-path', - help: - 'The certificate that host will use to serve using TLS connection. ' - 'If not provided, the tool will use default http scheme.', - ); - argParser.addOption( - 'web-tls-cert-key-path', - help: - 'The certificate key that host will use to authenticate cert. ' - 'If not provided, the tool will use default http scheme.', - ); - argParser.addOption( - 'web-server-debug-protocol', - allowed: ['sse', 'ws'], - defaultsTo: 'ws', - help: - 'The protocol (SSE or WebSockets) to use for the debug service proxy ' - 'when using the Web Server device and Dart Debug extension. ' - 'This is useful for editors/debug adapters that do not support debugging ' - 'over SSE (the default protocol for Web Server/Dart Debugger extension).', - hide: !verboseHelp, - ); - argParser.addOption( - 'web-server-debug-backend-protocol', - allowed: ['sse', 'ws'], - defaultsTo: 'ws', - help: - 'The protocol (SSE or WebSockets) to use for the Dart Debug Extension ' - 'backend service when using the Web Server device. ' - 'Using WebSockets can improve performance but may fail when connecting through ' - 'some proxy servers.', - hide: !verboseHelp, - ); - argParser.addOption( - 'web-server-debug-injected-client-protocol', - allowed: ['sse', 'ws'], - defaultsTo: 'ws', - help: - 'The protocol (SSE or WebSockets) to use for the injected client ' - 'when using the Web Server device. ' - 'Using WebSockets can improve performance but may fail when connecting through ' - 'some proxy servers.', - hide: !verboseHelp, - ); - argParser.addFlag( - 'web-allow-expose-url', - help: - 'Enables daemon-to-editor requests (app.exposeUrl) for exposing URLs ' - 'when running on remote machines.', - hide: !verboseHelp, - ); - argParser.addFlag( - 'web-run-headless', - help: - 'Launches the browser in headless mode. Currently only Chrome ' - 'supports this option.', - hide: !verboseHelp, - ); - argParser.addOption( - 'web-browser-debug-port', - help: - 'The debug port the browser should use. If not specified, a ' - 'random port is selected. Currently only Chrome supports this option. ' - 'It serves the Chrome DevTools Protocol ' - '(https://chromedevtools.github.io/devtools-protocol/).', - hide: !verboseHelp, - ); - argParser.addFlag( - 'web-enable-expression-evaluation', - defaultsTo: true, - help: 'Enables expression evaluation in the debugger.', - hide: !verboseHelp, - ); - argParser.addOption( - 'web-launch-url', - help: - 'The URL to provide to the browser. Defaults to an HTTP URL with the host ' - 'name of "--web-hostname", the port of "--web-port", and the path set to "/".', - ); - argParser.addMultiOption( - FlutterOptions.kWebBrowserFlag, - help: - 'Additional flag to pass to a browser instance at startup.\n' - 'Chrome: https://www.chromium.org/developers/how-tos/run-chromium-with-flags/\n' - 'Firefox: https://wiki.mozilla.org/Firefox/CommandLineOptions\n' - 'Multiple flags can be passed by repeating "--${FlutterOptions.kWebBrowserFlag}" multiple times.', - valueHelp: '--foo=bar', - hide: !verboseHelp, - ); - argParser.addFlag( - 'cross-origin-isolation', - help: - 'Adds the Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy ' - 'headers to the web server. These headers are required for using APIs like ' - 'SharedArrayBuffer. This is on by default for the "skwasm" web renderer, ' - 'and this flag can be used to override the default. To disable this for the ' - 'skwasm renderer, use "--no-cross-origin-isolation".', - hide: !verboseHelp, - ); + argParser.addDescriptors(const >[ + WebOptions.webHeader, + WebOptions.webHostname, + WebOptions.webPort, + WebOptions.webTlsCertPath, + WebOptions.webTlsCertKeyPath, + WebOptions.webServerDebugProtocol, + WebOptions.webServerDebugBackendProtocol, + WebOptions.webServerDebugInjectedClientProtocol, + WebOptions.webAllowExposeUrl, + WebOptions.webRunHeadless, + WebOptions.webBrowserDebugPort, + WebOptions.webEnableExpressionEvaluation, + WebOptions.webLaunchUrl, + WebOptions.webBrowserFlags, + WebOptions.crossOriginIsolation, + ], verboseHelp: verboseHelp); usesBaseHrefOption(); } void usesBaseHrefOption() { - argParser.addOption( - 'base-href', - help: - 'Overrides the href attribute of the tag in web/index.html. ' - 'No change is made to web/index.html file if this flag is not provided. ' - 'The value must start and end with "/". ' - 'For more information: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base', - ); + argParser.addDescriptor(WebOptions.baseHref); + } + + /// Adds the `--[no-]deprecated-js-interop` flag, which is forwarded to the + /// web compilers through [BuildInfo.deprecatedJsInterop]. + void usesDeprecatedJsInteropFlag({required bool verboseHelp}) { + argParser.addDescriptor(WebOptions.deprecatedJsInterop, verboseHelp: verboseHelp); } void usesTargetOption() { - argParser.addOption( - 'target', - abbr: 't', - defaultsTo: bundle.defaultMainPath, - help: - 'The main entry-point file of the application, as run on the device.\n' - 'If the "--target" option is omitted, but a file name is provided on ' - 'the command line, then that is used instead.', - valueHelp: 'path', - ); + CommonOptions.target.addTo(argParser); _usesTargetOption = true; } void usesFatalWarningsOption({required bool verboseHelp}) { - argParser.addFlag( - FlutterOptions.kFatalWarnings, - hide: !verboseHelp, - help: - 'Causes the command to fail if warnings are sent to the console ' - 'during its execution.', - ); + CommonOptions.fatalWarnings.addTo(argParser, verboseHelp: verboseHelp); _usesFatalWarnings = true; } String get targetFile { - if (argResults?.wasParsed('target') ?? false) { - return stringArg('target')!; + if (wasParsed(CommonOptions.target)) { + return getValue(CommonOptions.target); } final List? rest = argResults?.rest; if (rest != null && rest.isNotEmpty) { return rest.first; } - return bundle.defaultMainPath; + return toolContext?.fs.path.join('lib', 'main.dart') ?? bundle.defaultMainPath; } /// Indicates if the current command running has a terminal attached. - bool get hasTerminal => globals.stdio.hasTerminal; + bool get hasTerminal => toolContext?.stdio.hasTerminal ?? globals.stdio.hasTerminal; /// Path to the Dart's package config file. /// /// This can be overridden by some of its subclasses. - String? get packagesPath => stringArg(FlutterGlobalOptions.kPackagesOption, global: true); + String? get packagesPath => getValue(CommonOptions.packages); /// Whether flutter is being run from our CI. /// /// This is true if `--ci` is passed to the command or if environment /// variable `LUCI_CI` is `True`. bool get usingCISystem { - return boolArg(FlutterGlobalOptions.kContinuousIntegrationFlag, global: true) || - globals.platform.environment['LUCI_CI'] == 'True'; + return getValue(CommonOptions.ci) || (_platform.environment['LUCI_CI'] == 'True'); } - String? get debugLogsDirectoryPath => - stringArg(FlutterGlobalOptions.kDebugLogsDirectoryFlag, global: true); + String? get debugLogsDirectoryPath => getValue(CommonOptions.debugLogsDir); /// The value of the `--filesystem-scheme` argument. /// /// This can be overridden by some of its subclasses. - String? get fileSystemScheme => argParser.options.containsKey(FlutterOptions.kFileSystemScheme) - ? stringArg(FlutterOptions.kFileSystemScheme) + String? get fileSystemScheme => hasOption(BuildInfoOptions.fileSystemScheme) + ? getValue(BuildInfoOptions.fileSystemScheme) : null; /// The values of the `--filesystem-root` argument. /// /// This can be overridden by some of its subclasses. - List? get fileSystemRoots => argParser.options.containsKey(FlutterOptions.kFileSystemRoot) - ? stringsArg(FlutterOptions.kFileSystemRoot) - : null; + List? get fileSystemRoots => + hasOption(BuildInfoOptions.fileSystemRoot) ? getValue(BuildInfoOptions.fileSystemRoot) : null; void usesPubOption({bool hide = false}) { - argParser.addFlag( - 'pub', - defaultsTo: true, - hide: hide, - help: 'Whether to run "flutter pub get" before executing this command.', - ); + CommonOptions.pub.addTo(argParser, hideOverride: hide); _usesPubOption = true; } @@ -498,137 +453,46 @@ abstract class FlutterCommand extends Command { /// The `hide` argument indicates whether or not to hide these options when /// the user asks for help. void usesFilesystemOptions({required bool hide}) { - argParser - ..addOption( - 'output-dill', - hide: hide, - help: 'Specify the path to frontend server output kernel file.', - ) - ..addMultiOption( - FlutterOptions.kFileSystemRoot, - hide: hide, - help: - 'Specify the path that is used as the root of a virtual file system ' - 'during compilation. The input file name should be specified as a URL ' - 'using the scheme given in "--${FlutterOptions.kFileSystemScheme}".\n' - 'Requires the "--output-dill" option to be explicitly specified.', - ) - ..addOption( - FlutterOptions.kFileSystemScheme, - defaultsTo: 'org-dartlang-root', - hide: hide, - help: - 'Specify the scheme that is used for virtual file system used in ' - 'compilation. See also the "--${FlutterOptions.kFileSystemRoot}" option.', - ); + const FilesystemOptionsBundle().register(this, argParser, hideOverride: hide); } /// Adds options for connecting to the Dart VM Service port. void usesPortOptions({required bool verboseHelp}) { - argParser.addOption( - vmServicePortOption, - help: - '(deprecated; use host-vmservice-port instead) ' - 'Listen to the given port for a Dart VM Service connection.\n' - 'Specifying port 0 (the default) will find a random free port.\n ' - 'if the Dart Development Service (DDS) is enabled, this will not be the port ' - 'of the VmService instance advertised on the command line.', - hide: !verboseHelp, - ); - argParser.addOption( - 'device-vmservice-port', - help: - 'Look for vmservice connections only from the specified port.\n' - 'Specifying port 0 (the default) will accept the first vmservice ' - 'discovered.', - ); - argParser.addOption( - 'host-vmservice-port', - help: - 'When a device-side vmservice port is forwarded to a host-side ' - 'port, use this value as the host port.\nSpecifying port 0 ' - '(the default) will find a random free host port.', - ); - _usesPortOption = true; + const VmServicePortOptionsBundle(includeIpv6: false) + .register(this, argParser, verboseHelp: verboseHelp); } /// Add option values for output directory of artifacts void usesOutputDir() { - // TODO(eliasyishak): this feature has been added to [BuildWebCommand] and - // [BuildAarCommand] - argParser.addOption( - 'output', - abbr: 'o', - aliases: ['output-dir'], - help: - 'The absolute path to the directory where the repository is generated. ' - 'By default, this is /build/.\n' - 'Currently supported for subcommands: aar, web.', - ); + CommonOptions.outputDir.addTo(argParser); } void addDevToolsOptions({required bool verboseHelp, bool includeEnableDevTools = true}) { if (includeEnableDevTools) { - argParser.addFlag( - kEnableDevTools, - hide: !verboseHelp, - defaultsTo: true, - help: - 'Enable (or disable, with "--no-$kEnableDevTools") the launching of the ' - 'Flutter DevTools debugger and profiler. ' - 'If "--no-$kEnableDevTools" is specified, "--$kDevToolsServerAddress" is ignored.', - ); + argParser.addDescriptor(DebuggingOptionDescriptors.enableDevTools, verboseHelp: verboseHelp); } - final ignoredMessage = includeEnableDevTools - ? ' Ignored if "--no-$kEnableDevTools" is specified.' - : ''; - argParser.addOption( - kDevToolsServerAddress, - hide: !verboseHelp, - help: - 'When this value is provided, the Flutter tool will not spin up a ' - 'new DevTools server instance, and will instead use the one provided ' - 'at the given address.$ignoredMessage', + argParser.addDescriptor( + DebuggingOptionDescriptors.devToolsServerAddress( + includeEnableDevTools: includeEnableDevTools, + ), + verboseHelp: verboseHelp, ); } void addDdsOptions({required bool verboseHelp}) { - argParser.addOption( - 'dds-port', - help: - 'When this value is provided, the Dart Development Service (DDS) will be ' - 'bound to the provided port.\n' - 'Specifying port 0 (the default) will find a random free port.', - ); - argParser.addFlag( - 'dds', - defaultsTo: true, - help: - 'Enable the Dart Developer Service (DDS).\n' - 'It may be necessary to disable this when attaching to an application with ' - 'an existing DDS instance (e.g., attaching to an application currently ' - 'connected to by "flutter run"), or when running certain tests.\n' - 'Disabling this feature may degrade IDE functionality if a DDS instance is ' - 'not already connected to the target application.', - ); - argParser.addFlag( - 'disable-dds', - hide: !verboseHelp, - help: - '(deprecated; use "--no-dds" instead) ' - 'Disable the Dart Developer Service (DDS).', - ); + const DdsAndDevToolsOptionsBundle(includeDevTools: false) + .register(this, argParser, verboseHelp: verboseHelp); } - late final bool enableDds = boolArg('dds'); + late final bool enableDds = getValue(DebuggingOptionDescriptors.dds); bool get _hostVmServicePortProvided => - (argResults?.wasParsed(vmServicePortOption) ?? false) || - (argResults?.wasParsed('host-vmservice-port') ?? false); + wasParsed(DebuggingOptionDescriptors.vmServicePort) || + wasParsed(DebuggingOptionDescriptors.hostVmservicePort); int _tryParseHostVmservicePort() { - final String? vmServicePort = stringArg(vmServicePortOption); - final String? hostPort = stringArg('host-vmservice-port'); + final String? vmServicePort = getValue(DebuggingOptionDescriptors.vmServicePort); + final String? hostPort = getValue(DebuggingOptionDescriptors.hostVmservicePort); if (vmServicePort == null && hostPort == null) { throwToolExit('Invalid port for `--vm-service-port/--host-vmservice-port`'); } @@ -640,20 +504,18 @@ abstract class FlutterCommand extends Command { } int get ddsPort { - if (argResults?.wasParsed('dds-port') != true && _hostVmServicePortProvided) { + if (!wasParsed(DebuggingOptionDescriptors.ddsPort) && _hostVmServicePortProvided) { // If an explicit DDS port is _not_ provided, use the host-vmservice-port for DDS. return _tryParseHostVmservicePort(); - } else if (argResults?.wasParsed('dds-port') ?? false) { - // If an explicit DDS port is provided, use dds-port for DDS. - return int.tryParse(stringArg('dds-port')!) ?? 0; } - // Otherwise, DDS can bind to a random port. - return 0; + return getValue(DebuggingOptionDescriptors.ddsPort); } Uri? get devToolsServerAddress { - if (argResults?.wasParsed(kDevToolsServerAddress) ?? false) { - final Uri? uri = Uri.tryParse(stringArg(kDevToolsServerAddress)!); + if (wasParsed(DebuggingOptionDescriptors.devToolsServerAddressOption)) { + final Uri? uri = Uri.tryParse( + getValue(DebuggingOptionDescriptors.devToolsServerAddressOption)!, + ); if (uri != null && uri.host.isNotEmpty && uri.port != 0) { return uri; } @@ -672,8 +534,8 @@ abstract class FlutterCommand extends Command { if (!_usesPortOption || !_hostVmServicePortProvided) { return null; } - if ((argResults?.wasParsed(vmServicePortOption) ?? false) && - (argResults?.wasParsed('host-vmservice-port') ?? false)) { + if (wasParsed(DebuggingOptionDescriptors.vmServicePort) && + wasParsed(DebuggingOptionDescriptors.hostVmservicePort)) { throwToolExit( 'Only one of "--vm-service-port" and ' '"--host-vmservice-port" may be specified.', @@ -682,7 +544,7 @@ abstract class FlutterCommand extends Command { // If DDS is enabled and no explicit DDS port is provided, use the // host-vmservice-port for DDS instead and bind the VM service to a random // port. - if (enableDds && argResults?.wasParsed('dds-port') != true) { + if (enableDds && !wasParsed(DebuggingOptionDescriptors.ddsPort)) { return null; } return _tryParseHostVmservicePort(); @@ -692,7 +554,7 @@ abstract class FlutterCommand extends Command { /// /// If no port is set, returns null. int? get deviceVmservicePort { - final String? devicePort = stringArg('device-vmservice-port'); + final String? devicePort = getValue(DebuggingOptionDescriptors.deviceVmservicePort); if (!_usesPortOption || devicePort == null) { return null; } @@ -704,162 +566,73 @@ abstract class FlutterCommand extends Command { } void addPublishPort({bool enabledByDefault = true, bool verboseHelp = false}) { - argParser.addFlag( - 'publish-port', - hide: !verboseHelp, - help: - 'Publish the VM service port over mDNS. Disable to prevent the ' - 'local network permission app dialog in debug and profile build modes (iOS devices only).', - defaultsTo: enabledByDefault, + argParser.addDescriptor( + DebuggingOptionDescriptors.publishPortOption(enabledByDefault: enabledByDefault), + verboseHelp: verboseHelp, ); } - Future get disablePortPublication async => !boolArg('publish-port'); + Future get disablePortPublication async => + !getValue(DebuggingOptionDescriptors.publishPort); void usesIpv6Flag({required bool verboseHelp}) { - argParser.addFlag( - ipv6Flag, - negatable: false, - help: - 'Binds to IPv6 localhost instead of IPv4 when the flutter tool ' - 'forwards the host port to a device port.', - hide: !verboseHelp, - ); + argParser.addDescriptor(DebuggingOptionDescriptors.ipv6, verboseHelp: verboseHelp); _usesIpv6Flag = true; } - bool? get ipv6 => _usesIpv6Flag ? boolArg('ipv6') : null; + bool? get ipv6 => _usesIpv6Flag ? getValue(DebuggingOptionDescriptors.ipv6) : null; void usesBuildNumberOption() { - argParser.addOption( - 'build-number', - help: - 'An identifier used as an internal version number.\n' - 'Each build must have a unique identifier to differentiate it from previous builds.\n' - 'It is used to determine whether one build is more recent than another, with higher numbers indicating more recent build.\n' - 'On Android it is used as "versionCode".\n' - 'On Xcode builds it is used as "CFBundleVersion".\n' - 'On Windows it is used as the build suffix for the product and file versions.', - ); + CommonOptions.buildNumber.addTo(argParser); } void usesBuildNameOption() { - argParser.addOption( - 'build-name', - help: - 'A "x.y.z" string used as the version number shown to users.\n' - 'For each new version of your app, you will provide a version number to differentiate it from previous versions.\n' - 'On Android it is used as "versionName".\n' - 'On Xcode builds it is used as "CFBundleShortVersionString".\n' - 'On Windows it is used as the major, minor, and patch parts of the product and file versions.', - valueHelp: 'x.y.z', - ); + CommonOptions.buildName.addTo(argParser); } void usesDartDefineOption() { - argParser.addMultiOption( - FlutterOptions.kDartDefinesOption, - abbr: 'D', - aliases: [kDartDefines], // supported for historical reasons - help: - 'Additional key-value pairs that will be available as constants ' - 'from the String.fromEnvironment, bool.fromEnvironment, and int.fromEnvironment ' - 'constructors.\n' - 'Multiple defines can be passed by repeating "--${FlutterOptions.kDartDefinesOption}" multiple times.', - valueHelp: 'foo=bar', - splitCommas: false, - ); + CommonOptions.dartDefines.addTo(argParser); _usesDartDefineFromFileOption(); } void usesWebDefineOption() { - argParser.addMultiOption( - FlutterOptions.kWebDefinesOption, - help: - 'Additional key-value pairs that will be available as template variables ' - 'in web/index.html and web/flutter_bootstrap.js files during development and build.\n' - 'Variables are replaced in the format {{VARIABLE_NAME}}.\n' - 'Multiple defines can be passed by repeating "--${FlutterOptions.kWebDefinesOption}" multiple times.\n' - 'If a template contains a variable placeholder but no corresponding "--web-define" is provided, ' - 'it will warn that you have an unhandled variable.', - valueHelp: 'API_URL=https://api.example.com', - splitCommas: false, - ); + WebOptions.webDefines.addTo(argParser); } void _usesDartDefineFromFileOption() { - argParser.addMultiOption( - FlutterOptions.kDartDefineFromFileOption, - help: - 'The path of a .json or .env file containing key-value pairs that will be available as environment variables.\n' - 'These can be accessed using the String.fromEnvironment, bool.fromEnvironment, and int.fromEnvironment constructors.\n' - 'Multiple defines can be passed by repeating "--${FlutterOptions.kDartDefineFromFileOption}" multiple times.\n' - 'Entries from "--${FlutterOptions.kDartDefinesOption}" with identical keys take precedence over entries from these files.', - valueHelp: 'use-define-config.json|.env', - splitCommas: false, - ); + CommonOptions.dartDefineFromFile.addTo(argParser); } void usesWebResourcesCdnFlag() { - argParser.addFlag( - FlutterOptions.kWebResourcesCdnFlag, - defaultsTo: true, - help: 'Use Web static resources hosted on a CDN.', - ); + WebOptions.webResourcesCdn.addTo(argParser); } void usesDeviceUserOption() { - argParser.addOption( - FlutterOptions.kDeviceUser, - help: - 'Identifier number for a user or work profile on Android only. Run "adb shell pm list users" for available identifiers.', - valueHelp: '10', - ); + CommonOptions.deviceUser.addTo(argParser); } void usesDeviceTimeoutOption() { - argParser.addOption( - FlutterOptions.kDeviceTimeout, - help: - 'Time in seconds to wait for devices to attach. Longer timeouts may be necessary for networked devices.', - valueHelp: '10', - ); + CommonOptions.deviceTimeout.addTo(argParser); } void usesDeviceConnectionOption() { - argParser.addOption( - FlutterOptions.kDeviceConnection, - defaultsTo: 'both', - help: 'Discover devices based on connection type.', - allowed: ['attached', 'wireless', 'both'], - allowedHelp: { - 'both': 'Searches for both attached and wireless devices.', - 'attached': - 'Only searches for devices connected by USB or built-in (such as simulators/emulators, MacOS/Windows, Chrome)', - 'wireless': - 'Only searches for devices connected wirelessly. Discovering wireless devices may take longer.', - }, - ); + CommonOptions.deviceConnection.addTo(argParser); + } + + void usesAdbLogFilteringOption({required bool hide}) { + argParser.addDescriptor(DebuggingOptionDescriptors.adbLogFiltering, hideOverride: hide); } void usesApplicationBinaryOption() { - argParser.addOption( - FlutterOptions.kUseApplicationBinary, - help: - 'Specify a pre-built application binary to use when running. For Android applications, ' - 'this must be the path to an APK. For iOS applications, the path to an IPA. Other device types ' - 'do not yet support prebuilt application binaries.', - valueHelp: 'path/to/app.apk', - ); + CommonOptions.useApplicationBinary.addTo(argParser); } /// Whether it is safe for this command to use a cached pub invocation. bool get cachePubGet => true; late final Duration? deviceDiscoveryTimeout = () { - if ((argResults?.options.contains(FlutterOptions.kDeviceTimeout) ?? false) && - (argResults?.wasParsed(FlutterOptions.kDeviceTimeout) ?? false)) { - final int? timeoutSeconds = int.tryParse(stringArg(FlutterOptions.kDeviceTimeout)!); + if (wasParsed(CommonOptions.deviceTimeout)) { + final int? timeoutSeconds = int.tryParse(getValue(CommonOptions.deviceTimeout)!); if (timeoutSeconds == null) { throwToolExit( 'Could not parse "--${FlutterOptions.kDeviceTimeout}" argument. It must be an integer.', @@ -871,9 +644,8 @@ abstract class FlutterCommand extends Command { }(); DeviceConnectionInterface? get deviceConnectionInterface { - if ((argResults?.options.contains(FlutterOptions.kDeviceConnection) ?? false) && - (argResults?.wasParsed(FlutterOptions.kDeviceConnection) ?? false)) { - return switch (stringArg(FlutterOptions.kDeviceConnection)) { + if (wasParsed(CommonOptions.deviceConnection)) { + return switch (getValue(CommonOptions.deviceConnection)) { 'attached' => DeviceConnectionInterface.attached, 'wireless' => DeviceConnectionInterface.wireless, _ => null, @@ -883,282 +655,96 @@ abstract class FlutterCommand extends Command { } late final _targetDevices = TargetDevices( - platform: globals.platform, deviceManager: globals.deviceManager!, - logger: globals.logger, + doctor: globals.doctor!, + toolContext: toolContext!, deviceConnectionInterface: deviceConnectionInterface, ); - void addBuildModeFlags({ - required bool verboseHelp, + /// Configures the default build mode and excluded build modes for this command. + void configureBuildModes({ bool defaultToRelease = true, bool excludeDebug = false, bool excludeRelease = false, }) { // A release build must be the default if a debug build is not possible. assert(defaultToRelease || !excludeDebug); + assert(!defaultToRelease || !excludeRelease); _excludeDebug = excludeDebug; _excludeRelease = excludeRelease; defaultBuildMode = defaultToRelease ? BuildMode.release : BuildMode.debug; + } - if (!excludeDebug) { - argParser.addFlag( - 'debug', - negatable: false, - help: 'Build a debug version of your app${defaultToRelease ? '' : ' (default mode)'}.', - ); - } - argParser.addFlag( - 'profile', - negatable: false, - help: 'Build a version of your app specialized for performance profiling.', - ); - if (!excludeRelease) { - argParser.addFlag( - 'release', - negatable: false, - help: 'Build a release version of your app${defaultToRelease ? ' (default mode)' : ''}.', - ); - argParser.addFlag( - 'jit-release', - negatable: false, - hide: !verboseHelp, - help: - 'Build a JIT release version of your app${defaultToRelease ? ' (default mode)' : ''}.', - ); - } + void addBuildModeFlags({ + required bool verboseHelp, + bool defaultToRelease = true, + bool excludeDebug = false, + bool excludeRelease = false, + }) { + BuildModeOptionsBundle( + defaultToRelease: defaultToRelease, + excludeDebug: excludeDebug, + excludeRelease: excludeRelease, + ).register(this, argParser, verboseHelp: verboseHelp); } void addSplitDebugInfoOption() { - argParser.addOption( - FlutterOptions.kSplitDebugInfoOption, - help: - 'In a release build, this flag reduces application size by storing ' - 'Dart program symbols in a separate file on the host rather than in the ' - 'application. The value of the flag should be a directory where program ' - 'symbol files can be stored for later use. These symbol files contain ' - 'the information needed to symbolize Dart stack traces. For an app built ' - 'with this flag, the "flutter symbolize" command with the right program ' - 'symbol file is required to obtain a human readable stack trace.\n' - 'This flag cannot be combined with "--${FlutterOptions.kAnalyzeSize}".', - valueHelp: 'v1.2.3/', - ); + BuildInfoOptions.splitDebugInfo.addTo(argParser); } void addDartObfuscationOption() { - argParser.addFlag( - FlutterOptions.kDartObfuscationOption, - help: - 'In a release build, this flag removes identifiers and replaces them ' - 'with randomized values for the purposes of source code obfuscation. This ' - 'flag must always be combined with "--${FlutterOptions.kSplitDebugInfoOption}" option, the ' - 'mapping between the values and the original identifiers is stored in the ' - 'symbol map created in the specified directory. For an app built with this ' - 'flag, the "flutter symbolize" command with the right program ' - 'symbol file is required to obtain a human readable stack trace.\n' - '\n' - 'Because all identifiers are renamed, methods like Object.runtimeType, ' - 'Type.toString, Enum.toString, Stacktrace.toString, Symbol.toString ' - '(for constant symbols or those generated by runtime system) will ' - 'return obfuscated results. Any code or tests that rely on exact names ' - 'will break.', - ); + BuildInfoOptions.obfuscate.addTo(argParser); } void addTreeShakeIconsFlag({bool? enabledByDefault}) { - argParser.addFlag( - 'tree-shake-icons', - defaultsTo: enabledByDefault ?? kIconTreeShakerEnabledDefault, - help: 'Tree shake icon fonts so that only glyphs used by the application remain.', - ); - } - - void addShrinkingFlag({required bool verboseHelp}) { - argParser.addFlag( - 'shrink', - hide: !verboseHelp, - help: - 'This flag has no effect. Code shrinking is always enabled in release builds. ' - 'To learn more, see: https://developer.android.com/studio/build/shrink-code', - ); + CommonOptions.treeShakeIcons.addTo(argParser, hideOverride: enabledByDefault == false); } void usesFrontendServerStarterPathOption({required bool verboseHelp}) { - argParser.addOption( - FlutterOptions.kFrontendServerStarterPath, - help: - 'When this value is provided, the frontend server will be started ' - 'in JIT mode from the specified file, instead of from the AOT ' - 'snapshot shipped with the Dart SDK. The specified file can either ' - 'be a Dart source file, or an AppJIT snapshot. This option does ' - 'not affect web builds.', - hide: !verboseHelp, - ); + BuildInfoOptions.frontendServerStarterPath.addTo(argParser, verboseHelp: verboseHelp); } /// Enables support for the hidden options --extra-front-end-options and /// --extra-gen-snapshot-options. void usesExtraDartFlagOptions({required bool verboseHelp}) { - argParser.addMultiOption( - FlutterOptions.kExtraFrontEndOptions, - aliases: [kExtraFrontEndOptions], // supported for historical reasons - help: - 'A comma-separated list of additional command line arguments that will be passed directly to the Dart front end. ' - 'For example, "--${FlutterOptions.kExtraFrontEndOptions}=--enable-experiment=nonfunction-type-aliases".', - valueHelp: '--foo,--bar', - hide: !verboseHelp, - ); - argParser.addMultiOption( - FlutterOptions.kExtraGenSnapshotOptions, - aliases: [kExtraGenSnapshotOptions], // supported for historical reasons - help: - 'A comma-separated list of additional command line arguments that will be passed directly to the Dart native compiler. ' - '(Only used in "--profile" or "--release" builds.) ' - 'For example, "--${FlutterOptions.kExtraGenSnapshotOptions}=--no-strip".', - valueHelp: '--foo,--bar', - hide: !verboseHelp, - ); + BuildInfoOptions.extraFrontEndOptions.addTo(argParser, verboseHelp: verboseHelp); + BuildInfoOptions.extraGenSnapshotOptions.addTo(argParser, verboseHelp: verboseHelp); } void usesFuchsiaOptions({bool hide = false}) { - argParser.addOption( - 'target-model', - help: 'Target model that determines what core libraries are available.', - defaultsTo: 'flutter', - hide: hide, - allowed: const ['flutter', 'flutter_runner'], - ); - argParser.addOption( - 'module', - abbr: 'm', - hide: hide, - help: 'The name of the module (required if attaching to a fuchsia device).', - valueHelp: 'module-name', - ); + argParser.addDescriptors(const >[ + DebuggingOptionDescriptors.fuchsiaTargetModel, + DebuggingOptionDescriptors.fuchsiaModule, + ], hideOverride: hide); } void addEnableExperimentation({required bool hide}) { - argParser.addMultiOption( - FlutterOptions.kEnableExperiment, - help: - 'The name of an experimental Dart feature to enable. For more information see: ' - 'https://github.com/dart-lang/sdk/blob/main/docs/process/experimental-flags.md', - hide: hide, - ); + CommonOptions.enableExperiment.addTo(argParser, hideOverride: hide); } void addBuildPerformanceFile({bool hide = false}) { - argParser.addOption( - FlutterOptions.kPerformanceMeasurementFile, - help: - 'The name of a file where flutter assemble performance and ' - 'cached-ness information will be written in a JSON format.', - hide: hide, - ); + BuildInfoOptions.performanceMeasurementFile.addTo(argParser, hideOverride: hide); } void addAndroidSpecificBuildOptions({bool hide = false}) { - argParser.addFlag( - FlutterOptions.kAndroidGradleDaemon, - help: - 'Whether to enable the Gradle daemon when performing an Android build. ' - 'Starting the daemon is the default behavior of the gradle wrapper script created ' - 'in a Flutter project. Setting this flag to false corresponds to passing ' - '"--no-daemon" to the gradle wrapper script. This flag will cause the daemon ' - 'process to terminate after the build is completed.', - defaultsTo: true, - hide: hide, - ); - argParser.addFlag( - FlutterOptions.kAndroidSkipBuildDependencyValidation, - help: - 'Whether to skip version checking for Java, Gradle, ' - 'the Android Gradle Plugin (AGP), and the Kotlin Gradle Plugin (KGP)' - ' during Android builds.', - ); - argParser.addMultiOption( - FlutterOptions.kAndroidProjectArgs, - help: - 'Additional arguments specified as key=value that are passed directly to the gradle ' - 'project via the -P flag. These can be accessed in build.gradle via the "project.property" API.', - splitCommas: false, - abbr: 'P', - ); - argParser.addOption( - FlutterOptions.kAndroidGradleProjectCacheDir, - help: 'Specifies the project-specific cache directory. Defaults to .gradle.', - ); + const AndroidGradleOptionsBundle().register(this, argParser, hideOverride: hide); } void addNativeNullAssertions({bool hide = false}) { - argParser.addFlag( - 'native-null-assertions', - defaultsTo: true, - hide: hide, - help: - 'Enables additional runtime null checks in web applications to ensure ' - 'the correct nullability of native (such as in dart:html) and external ' - '(such as with JS interop) types. This is enabled by default but only takes ' - 'effect in sound mode. To report an issue with a null assertion failure in ' - 'dart:html or the other dart web libraries, please file a bug at: ' - 'https://github.com/dart-lang/sdk/issues/labels/web-libraries', - ); + CommonOptions.nativeNullAssertions.addTo(argParser, hideOverride: hide); } void usesInitializeFromDillOption({required bool hide}) { - argParser.addOption( - FlutterOptions.kInitializeFromDill, - help: - 'Initializes the resident compiler with a specific kernel file instead of ' - 'the default cached location.', - hide: hide, - ); - argParser.addFlag( - FlutterOptions.kAssumeInitializeFromDillUpToDate, - help: - 'If set, assumes that the file passed in initialize-from-dill is up ' - 'to date and skip the check and potential invalidation of files.', - hide: hide, - ); + BuildInfoOptions.initializeFromDill.addTo(argParser, hideOverride: hide); + BuildInfoOptions.assumeInitializeFromDillUpToDate.addTo(argParser, hideOverride: hide); } void usesNativeAssetsOption({required bool hide}) { - argParser.addOption( - FlutterOptions.kNativeAssetsYamlFile, - help: - 'Initializes the resident compiler with a custom native assets ' - 'yaml file instead of the default cached location.', - hide: hide, - ); + BuildInfoOptions.nativeAssetsYamlFile.addTo(argParser, hideOverride: hide); } void addIgnoreDeprecationOption({bool hide = false}) { - argParser.addFlag( - 'ignore-deprecation', - negatable: false, - help: - 'Indicates that the app should ignore deprecation warnings and continue to build ' - 'using deprecated APIs. Use of this flag may cause your app to fail to build when ' - 'deprecated APIs are removed.', - ); - } - - /// Adds build options common to all of the desktop build commands. - void addCommonDesktopBuildOptions({required bool verboseHelp}) { - addBuildModeFlags(verboseHelp: verboseHelp); - addBuildPerformanceFile(hide: !verboseHelp); - addDartObfuscationOption(); - addEnableExperimentation(hide: !verboseHelp); - addSplitDebugInfoOption(); - addTreeShakeIconsFlag(); - usesAnalyzeSizeFlag(); - usesDartDefineOption(); - usesExtraDartFlagOptions(verboseHelp: verboseHelp); - usesPubOption(); - usesTargetOption(); - usesTrackWidgetCreation(verboseHelp: verboseHelp); - usesBuildNumberOption(); - usesBuildNameOption(); + BuildInfoOptions.ignoreDeprecation.addTo(argParser, hideOverride: hide); } /// The build mode that this command will use if no build mode is @@ -1170,14 +756,10 @@ abstract class FlutterCommand extends Command { BuildMode getBuildMode() { // No debug when _excludeDebug is true. If debug is not excluded, then take // the command line flag (if such exists for this command). - bool argIfDefined(String flagName, bool ifNotDefined) { - return argParser.options.containsKey(flagName) ? boolArg(flagName) : ifNotDefined; - } - - final bool debugResult = !_excludeDebug && argIfDefined('debug', false); - final bool jitReleaseResult = !_excludeRelease && argIfDefined('jit-release', false); - final bool releaseResult = !_excludeRelease && argIfDefined('release', false); - final bool profileResult = argIfDefined('profile', false); + final bool debugResult = !_excludeDebug && getValue(CommonOptions.debugMode); + final bool jitReleaseResult = !_excludeRelease && getValue(CommonOptions.jitReleaseMode); + final bool releaseResult = !_excludeRelease && getValue(CommonOptions.releaseMode); + final bool profileResult = getValue(CommonOptions.profileMode); final modeFlags = [debugResult, profileResult, jitReleaseResult, releaseResult]; if (modeFlags.where((bool flag) => flag).length > 1) { throw UsageException( @@ -1202,130 +784,47 @@ abstract class FlutterCommand extends Command { } void usesFlavorOption() { - argParser.addOption( - 'flavor', - help: - 'Build a custom app flavor as defined by platform-specific build setup.\n' - 'Supports the use of product flavors in Android Gradle scripts, and ' - 'the use of custom Xcode schemes.\n' - 'Overrides the value of the "default-flavor" entry in the flutter pubspec.', - ); - } - - void usesDarwinCodeSignXCFrameworksOption() { - argParser.addFlag( - FlutterOptions.kCodesign, - defaultsTo: true, - help: 'Whether to code-sign XCFrameworks.', - ); - argParser.addOption( - FlutterOptions.kCodesignIdentity, - help: - 'The identity to use for code-signing XCFrameworks. If an identity is not provided and ' - '"${FlutterOptions.kCodesign}" is enabled, a code signing identity will be selected ' - "automatically from the Flutter app's Xcode project settings or Flutter config. To see " - 'a list of valid identities run "security find-identity -p codesigning -v".', - ); + BuildInfoOptions.flavor.addTo(argParser); } void usesTrackWidgetCreation({bool hasEffect = true, required bool verboseHelp}) { - argParser.addFlag( - 'track-widget-creation', - hide: !hasEffect && !verboseHelp, - defaultsTo: true, - help: - 'Track widget creation locations. This enables features such as the widget inspector. ' - 'This parameter is only functional in debug mode (i.e. when compiling JIT, not AOT).', + BuildInfoOptions.trackWidgetCreation.addTo( + argParser, + verboseHelp: verboseHelp, + hideOverride: !hasEffect && !verboseHelp, ); } void usesAnalyzeSizeFlag() { - argParser.addFlag( - FlutterOptions.kAnalyzeSize, - help: - 'Whether to produce additional profile information for artifact output size. ' - 'This flag is only supported on "--release" builds. When building for Android, a single ' - 'ABI must be specified at a time with the "--target-platform" flag. When building for iOS, ' - 'only the symbols from the arm64 architecture are used to analyze code size.\n' - 'By default, the intermediate output files will be placed in a transient directory in the ' - 'build directory. This can be overridden with the "--${FlutterOptions.kCodeSizeDirectory}" option.\n' - 'This flag cannot be combined with "--${FlutterOptions.kSplitDebugInfoOption}".', - ); - - argParser.addOption( - FlutterOptions.kCodeSizeDirectory, - help: - 'The location to write code size analysis files. If this is not specified, files ' - 'are written to a temporary directory under the build directory.', - ); + BuildInfoOptions.analyzeSize.addTo(argParser); + BuildInfoOptions.codeSizeDirectory.addTo(argParser); } void addEnableImpellerFlag({required bool verboseHelp}) { - argParser.addFlag( - FlutterOptions.kEnableImpeller, - hide: !verboseHelp, - defaultsTo: null, - help: - 'Whether to enable the Impeller rendering engine. ' - 'Impeller is the default renderer on iOS. On Android, Impeller ' - 'is available but not the default. This flag will cause Impeller ' - 'to be used on Android. On other platforms, this flag will be ' - 'ignored.', - ); + argParser.addDescriptor(DebuggingOptionDescriptors.enableImpeller, verboseHelp: verboseHelp); } void addEnableFlutterGpuFlag({required bool verboseHelp}) { - argParser.addFlag( - 'enable-flutter-gpu', - hide: !verboseHelp, - defaultsTo: null, - help: - 'Whether to enable the Flutter GPU API (https://api.flutter.dev/flutter/flutter_gpu/). ' - 'This feature is only supported with the Impeller rendering engine, ' - 'which can be enabled via the "--${FlutterOptions.kEnableImpeller}" ' - 'option.', - ); + argParser.addDescriptor(DebuggingOptionDescriptors.enableFlutterGpu, verboseHelp: verboseHelp); } void addEnableVulkanValidationFlag({required bool verboseHelp}) { - argParser.addFlag( - 'enable-vulkan-validation', - hide: !verboseHelp, - help: - 'Enable vulkan validation on the Impeller rendering backend if ' - 'Vulkan is in use and the validation layers are available to the ' - 'application.', + argParser.addDescriptor( + DebuggingOptionDescriptors.enableVulkanValidation, + verboseHelp: verboseHelp, ); } void addEnableEmbedderApiFlag({required bool verboseHelp}) { - argParser.addFlag( - 'enable-embedder-api', - hide: !verboseHelp, - help: 'Whether to enable the experimental embedder API on iOS.', - ); + argParser.addDescriptor(DebuggingOptionDescriptors.enableEmbedderApi, verboseHelp: verboseHelp); } void addMachineOutputFlag({required bool verboseHelp}) { - argParser.addFlag( - FlutterGlobalOptions.kMachineFlag, - negatable: false, - help: 'Outputs in a machine readable structured JSON format.', - hide: !verboseHelp, - ); + CommonOptions.machine.addTo(argParser, verboseHelp: verboseHelp); } void addEnableHcppFlag({required bool verboseHelp}) { - argParser.addFlag( - 'enable-hcpp', - hide: !verboseHelp, - help: - 'Enable the use of the HCPP platform view rendering mode on the Impeller rendering ' - 'backend. An explicit value takes priority over the EnableHcpp metadata in ' - 'AndroidManifest.xml: build commands write it into the manifest of the artifact they ' - 'produce, and "run", "test", and "drive" additionally apply it at launch. Without the ' - 'flag, the manifest decides.', - ); + argParser.addDescriptor(DebuggingOptionDescriptors.enableHcpp, verboseHelp: verboseHelp); } /// The explicit `--[no-]enable-hcpp` value, or null when the flag was not @@ -1336,34 +835,29 @@ abstract class FlutterCommand extends Command { /// manifest over any value already there. Commands that launch the app /// (run/test/drive) additionally forward it to the device. bool? get explicitEnableHcpp { - final ArgResults? results = argResults; - if (results == null || - !results.options.contains('enable-hcpp') || - !results.wasParsed('enable-hcpp')) { + if (!hasOption(DebuggingOptionDescriptors.enableHcpp) || + !wasParsed(DebuggingOptionDescriptors.enableHcpp)) { return null; } - return boolArg('enable-hcpp'); + return getValue(DebuggingOptionDescriptors.enableHcpp); } /// The HCPP value for an Android artifact when the developer did not pass - /// `--[no-]enable-hcpp`: currently always false. + /// `--[no-]enable-hcpp`: the `enable-hcpp` feature flag, which is on by + /// default on master and beta. /// /// This is only a default. Gradle injects it when the merged manifest does /// not set `io.flutter.embedding.android.EnableHcpp` at all, so an entry in /// the manifest wins over it. [explicitEnableHcpp] in turn wins over both. - bool get enableHcpp => explicitEnableHcpp ?? false; + bool get enableHcpp => explicitEnableHcpp ?? runner?.featureFlags?.isHcppEnabled ?? false; void addTestFlag({required bool verboseHelp}) { - argParser.addFlag( - 'test-flag', - hide: !verboseHelp, - help: 'No-op flag for testing purposes; use for testing flag priorities only.', - ); + argParser.addDescriptor(DebuggingOptionDescriptors.testFlag, verboseHelp: verboseHelp); } /// Returns a [FlutterProject] view of the current directory or a ToolExit error, /// if `pubspec.yaml` or `example/pubspec.yaml` is invalid. - FlutterProject get project => FlutterProject.current(); + FlutterProject get project => _projectFactory.fromDirectory(_fs.currentDirectory); /// The path to the package config for the current project. /// @@ -1388,40 +882,28 @@ abstract class FlutterCommand extends Command { BuildMode? forcedBuildMode, File? forcedTargetFile, bool? forcedUseLocalCanvasKit, - // TODO(nshahan): Delete when fully migrated to new module system, - // https://github.com/flutter/flutter/issues/142060. - bool? forcedWebEnableHotReload, }) async { final bool trackWidgetCreation = - argParser.options.containsKey('track-widget-creation') && boolArg('track-widget-creation'); + hasOption(BuildInfoOptions.trackWidgetCreation) && + getValue(BuildInfoOptions.trackWidgetCreation); - final String? buildNumber = argParser.options.containsKey('build-number') - ? stringArg('build-number') - : null; + final String? buildNumber = getValue(CommonOptions.buildNumber); - final String? buildName = argParser.options.containsKey('build-name') - ? stringArg('build-name') - : null; + final String? buildName = getValue(CommonOptions.buildName); - final File packageConfigFile = globals.fs.file(packageConfigPath()); + final File packageConfigFile = _fs.file(packageConfigPath()); final PackageConfig packageConfig = await loadPackageConfigWithLogging( packageConfigFile, - logger: globals.logger, + logger: _logger, throwOnError: false, ); - final List experiments = argParser.options.containsKey(FlutterOptions.kEnableExperiment) - ? stringsArg(FlutterOptions.kEnableExperiment).toList() - : []; - final List extraGenSnapshotOptions = - argParser.options.containsKey(FlutterOptions.kExtraGenSnapshotOptions) - ? stringsArg(FlutterOptions.kExtraGenSnapshotOptions).toList() - : []; - final List extraFrontEndOptions = - argParser.options.containsKey(FlutterOptions.kExtraFrontEndOptions) - ? stringsArg(FlutterOptions.kExtraFrontEndOptions).toList() - : []; + final List experiments = getValue(CommonOptions.enableExperiment); + final List extraGenSnapshotOptions = getValue(BuildInfoOptions.extraGenSnapshotOptions) + .toList(); + final List extraFrontEndOptions = getValue(BuildInfoOptions.extraFrontEndOptions) + .toList(); if (experiments.isNotEmpty) { for (final expFlag in experiments) { @@ -1431,46 +913,30 @@ abstract class FlutterCommand extends Command { } } String? codeSizeDirectory; - if (argParser.options.containsKey(FlutterOptions.kAnalyzeSize) && - boolArg(FlutterOptions.kAnalyzeSize)) { - Directory directory = globals.fsUtils.getUniqueDirectory( - globals.fs.directory(getBuildDirectory()), - 'flutter_size', - ); - if (argParser.options.containsKey(FlutterOptions.kCodeSizeDirectory) && - stringArg(FlutterOptions.kCodeSizeDirectory) != null) { - directory = globals.fs.directory(stringArg(FlutterOptions.kCodeSizeDirectory)); - } + if (getValue(BuildInfoOptions.analyzeSize)) { + final String? customDir = getValue(BuildInfoOptions.codeSizeDirectory); + final Directory directory = (customDir != null) + ? _fs.directory(customDir) + : _fsUtils.getUniqueDirectory(_fs.directory(getBuildDirectory()), 'flutter_size'); directory.createSync(recursive: true); codeSizeDirectory = directory.path; } - final bool dartObfuscation = - argParser.options.containsKey(FlutterOptions.kDartObfuscationOption) && - boolArg(FlutterOptions.kDartObfuscationOption); + final bool dartObfuscation = getValue(BuildInfoOptions.obfuscate); - final String? splitDebugInfoPath = - argParser.options.containsKey(FlutterOptions.kSplitDebugInfoOption) - ? stringArg(FlutterOptions.kSplitDebugInfoOption) - : null; + final String? splitDebugInfoPath = getValue(BuildInfoOptions.splitDebugInfo); final bool androidGradleDaemon = - !argParser.options.containsKey(FlutterOptions.kAndroidGradleDaemon) || - boolArg(FlutterOptions.kAndroidGradleDaemon); + !hasOption(BuildInfoOptions.androidGradleDaemon) || + getValue(BuildInfoOptions.androidGradleDaemon); final bool androidSkipBuildDependencyValidation = - !argParser.options.containsKey(FlutterOptions.kAndroidSkipBuildDependencyValidation) || - boolArg(FlutterOptions.kAndroidSkipBuildDependencyValidation); + !hasOption(BuildInfoOptions.androidSkipBuildDependencyValidation) || + getValue(BuildInfoOptions.androidSkipBuildDependencyValidation); - final List androidProjectArgs = - argParser.options.containsKey(FlutterOptions.kAndroidProjectArgs) - ? stringsArg(FlutterOptions.kAndroidProjectArgs) - : []; + final List androidProjectArgs = getValue(BuildInfoOptions.androidProjectArg); - final String? androidGradleProjectCacheDir = - argParser.options.containsKey(FlutterOptions.kAndroidGradleProjectCacheDir) - ? stringArg(FlutterOptions.kAndroidGradleProjectCacheDir) - : null; + final String? androidGradleProjectCacheDir = getValue(BuildInfoOptions.androidProjectCacheDir); if (dartObfuscation && (splitDebugInfoPath == null || splitDebugInfoPath.isEmpty)) { throwToolExit( @@ -1489,29 +955,24 @@ abstract class FlutterCommand extends Command { } final bool treeShakeIcons = - argParser.options.containsKey('tree-shake-icons') && + hasOption(CommonOptions.treeShakeIcons) && buildMode.isPrecompiled && - boolArg('tree-shake-icons'); + getValue(CommonOptions.treeShakeIcons); - final String? performanceMeasurementFile = - argParser.options.containsKey(FlutterOptions.kPerformanceMeasurementFile) - ? stringArg(FlutterOptions.kPerformanceMeasurementFile) - : null; + final String? performanceMeasurementFile = getValue( + BuildInfoOptions.performanceMeasurementFile, + ); final Map defineConfigJsonMap = extractDartDefineConfigJsonMap(); final List dartDefines = extractDartDefines(defineConfigJsonMap: defineConfigJsonMap); - final bool useCdn = - !argParser.options.containsKey(FlutterOptions.kWebResourcesCdnFlag) || - boolArg(FlutterOptions.kWebResourcesCdnFlag); - var useLocalWebSdk = false; - if (globalResults?.wasParsed(FlutterGlobalOptions.kLocalWebSDKOption) ?? false) { - useLocalWebSdk = stringArg(FlutterGlobalOptions.kLocalWebSDKOption, global: true) != null; - } + final bool useCdn = getValue(WebOptions.webResourcesCdn); + final bool useLocalWebSdk = + wasParsed(CommonOptions.localWebSdk) && getValue(CommonOptions.localWebSdk) != null; final bool useLocalCanvasKit = forcedUseLocalCanvasKit ?? (!useCdn || useLocalWebSdk); final String? defaultFlavor = project.manifest.defaultFlavor; - final String? cliFlavor = argParser.options.containsKey('flavor') ? stringArg('flavor') : null; + final String? cliFlavor = getValue(BuildInfoOptions.flavor); final String? flavor = cliFlavor ?? defaultFlavor; _ensureReservedDartDefineIsUnset(kAppFlavor, dartDefines); @@ -1527,17 +988,14 @@ abstract class FlutterCommand extends Command { dartDefines.add('$define=$value'); } } - _addFlutterVersionToDartDefines(globals.flutterVersion, dartDefines); + _addFlutterVersionToDartDefines(_flutterVersion, dartDefines); _addFeatureFlagsToDartDefines(dartDefines); return BuildInfo( buildMode, flavor, trackWidgetCreation: trackWidgetCreation, - frontendServerStarterPath: - argParser.options.containsKey(FlutterOptions.kFrontendServerStarterPath) - ? stringArg(FlutterOptions.kFrontendServerStarterPath) - : null, + frontendServerStarterPath: getValue(BuildInfoOptions.frontendServerStarterPath), extraFrontEndOptions: extraFrontEndOptions.isNotEmpty ? extraFrontEndOptions : null, extraGenSnapshotOptions: extraGenSnapshotOptions.isNotEmpty ? extraGenSnapshotOptions : null, fileSystemRoots: fileSystemRoots, @@ -1552,21 +1010,19 @@ abstract class FlutterCommand extends Command { performanceMeasurementFile: performanceMeasurementFile, packageConfigPath: packagesPath ?? packageConfigFile.path, codeSizeDirectory: codeSizeDirectory, - androidGradleDaemon: androidGradleDaemon, - androidSkipBuildDependencyValidation: androidSkipBuildDependencyValidation, - androidEnableHcpp: enableHcpp, - explicitAndroidEnableHcpp: explicitEnableHcpp, + androidGradleConfig: AndroidGradleConfig( + gradleDaemon: androidGradleDaemon, + skipBuildDependencyValidation: androidSkipBuildDependencyValidation, + enableHcpp: enableHcpp, + explicitEnableHcpp: explicitEnableHcpp, + projectArgs: androidProjectArgs, + projectCacheDir: androidGradleProjectCacheDir, + ), packageConfig: packageConfig, - androidProjectArgs: androidProjectArgs, - androidGradleProjectCacheDir: androidGradleProjectCacheDir, - initializeFromDill: argParser.options.containsKey(FlutterOptions.kInitializeFromDill) - ? stringArg(FlutterOptions.kInitializeFromDill) - : null, - assumeInitializeFromDillUpToDate: - argParser.options.containsKey(FlutterOptions.kAssumeInitializeFromDillUpToDate) && - boolArg(FlutterOptions.kAssumeInitializeFromDillUpToDate), + initializeFromDill: getValue(BuildInfoOptions.initializeFromDill), + assumeInitializeFromDillUpToDate: getValue(BuildInfoOptions.assumeInitializeFromDillUpToDate), useLocalCanvasKit: useLocalCanvasKit, - webEnableHotReload: true, + deprecatedJsInterop: getValue(WebOptions.deprecatedJsInterop), ); } @@ -1574,7 +1030,7 @@ abstract class FlutterCommand extends Command { /// framework, has been set either in the environment or through /// `--${FlutterOptions.kDartDefinesOption}` / `--${FlutterOptions.kDartDefineFromFileOption}`. void _ensureReservedDartDefineIsUnset(String define, List dartDefines) { - if (globals.platform.environment[define] != null) { + if (_platform.environment[define] != null) { throwToolExit('$define is used by the framework and cannot be set in the environment.'); } if (dartDefines.any((String d) => d == define || d.startsWith('$define='))) { @@ -1617,8 +1073,13 @@ abstract class FlutterCommand extends Command { ); } - final String enabledFeatureFlags = featureFlags.allFeatures - .where((Feature feature) => featureFlags.isEnabled(feature)) + final FeatureFlags? flags = runner?.featureFlags; + if (flags == null) { + return; + } + + final String enabledFeatureFlags = flags.allFeatures + .where((Feature feature) => flags.isEnabled(feature)) .where((Feature feature) => feature.runtimeId != null) .map((Feature feature) => feature.runtimeId!) .join(','); @@ -1628,10 +1089,6 @@ abstract class FlutterCommand extends Command { } } - void setupApplicationPackages() { - applicationPackages ??= ApplicationPackageFactory.instance; - } - /// The path to send to Google Analytics. Return null here to disable /// tracking of the command. Future get usagePath async { @@ -1661,14 +1118,14 @@ abstract class FlutterCommand extends Command { /// so that this method can record and report the overall time to analytics. @override Future run() { - final DateTime startTime = globals.systemClock.now(); + final DateTime startTime = _clock.now(); return context.run( name: 'command', overrides: {FlutterCommand: () => this}, body: () async { if (_usesFatalWarnings) { - globals.logger.fatalWarnings = boolArg(FlutterOptions.kFatalWarnings); + _logger.fatalWarnings = getValue(CommonOptions.fatalWarnings); } _printDeprecationWarning(); final String? commandPath = await usagePath; @@ -1679,9 +1136,9 @@ abstract class FlutterCommand extends Command { try { commandResult = await verifyThenRunCommand(commandPath); } finally { - final DateTime endTime = globals.systemClock.now(); - globals.printTrace( - globals.userMessages.flutterElapsedTime( + final DateTime endTime = _clock.now(); + _logger.printTrace( + _userMessages.flutterElapsedTime( name, getElapsedAsMilliseconds(endTime.difference(startTime)), ), @@ -1690,7 +1147,7 @@ abstract class FlutterCommand extends Command { _sendPostUsage(commandPath, commandResult, startTime, endTime); } if (_usesFatalWarnings) { - globals.logger.checkForFatalLogs(); + _logger.checkForFatalLogs(); } } }, @@ -1699,12 +1156,12 @@ abstract class FlutterCommand extends Command { @override void printUsage() { - globals.logger.printStatus(usage); + _logger.printStatus(usage); } @visibleForOverriding String get deprecationWarning { - return '${globals.logger.terminal.warningMark} The "$name" command is ' + return '${_logger.terminal.warningMark} The "$name" command is ' 'deprecated and will be removed in a future version of Flutter. ' 'See https://flutter.dev/to/previous-releases ' 'for previous releases of Flutter.\n'; @@ -1712,7 +1169,7 @@ abstract class FlutterCommand extends Command { void _printDeprecationWarning() { if (deprecated) { - globals.printWarning(deprecationWarning); + _logger.printWarning(deprecationWarning); } } @@ -1723,12 +1180,9 @@ abstract class FlutterCommand extends Command { dartDefines.add('$key=$value'); }); - if (argParser.options.containsKey(FlutterOptions.kDartDefinesOption)) { - final Iterable defines = stringsArg( - FlutterOptions.kDartDefinesOption, - ).where((string) => string.isNotEmpty); - dartDefines.addAll(defines); - } + final Iterable defines = getValue(CommonOptions.dartDefines) + .where((string) => string.isNotEmpty); + dartDefines.addAll(defines); return dartDefines; } @@ -1736,20 +1190,17 @@ abstract class FlutterCommand extends Command { Map extractWebDefines() { final webDefines = {}; - if (argParser.options.containsKey(FlutterOptions.kWebDefinesOption)) { - final List defines = stringsArg(FlutterOptions.kWebDefinesOption); - for (final define in defines) { - final int separatorIndex = define.indexOf('='); - if (separatorIndex == -1 || separatorIndex == 0) { - throwToolExit( - 'Invalid web-define format: $define\n' - 'Expected format: KEY=VALUE (e.g., API_URL=https://api.example.com)', - ); - } - final String key = define.substring(0, separatorIndex); - final String value = define.substring(separatorIndex + 1); - webDefines[key] = value; + for (final String define in getValue(WebOptions.webDefines)) { + final int separatorIndex = define.indexOf('='); + if (separatorIndex == -1 || separatorIndex == 0) { + throwToolExit( + 'Invalid web-define format: $define\n' + 'Expected format: KEY=VALUE (e.g., API_URL=https://api.example.com)', + ); } + final String key = define.substring(0, separatorIndex); + final String value = define.substring(separatorIndex + 1); + webDefines[key] = value; } return webDefines; @@ -1758,39 +1209,41 @@ abstract class FlutterCommand extends Command { Map extractDartDefineConfigJsonMap() { final dartDefineConfigJsonMap = {}; - if (argParser.options.containsKey(FlutterOptions.kDartDefineFromFileOption)) { - final List configFilePaths = stringsArg(FlutterOptions.kDartDefineFromFileOption); - - for (final path in configFilePaths) { - if (!globals.fs.isFileSync(path)) { - throwToolExit( - 'Did not find the file passed to "--${FlutterOptions.kDartDefineFromFileOption}". Path: $path', - ); - } + for (final String path in getValue(CommonOptions.dartDefineFromFile)) { + if (!_fs.isFileSync(path)) { + throwToolExit( + 'Did not find the file passed to "--${FlutterOptions.kDartDefineFromFileOption}". Path: $path', + ); + } - final String configRaw = globals.fs.file(path).readAsStringSync(); + String configRaw; + try { + configRaw = decodeUtf8OrUtf16(_fs.file(path).readAsBytesSync()); + } on Exception catch (err) { + throwToolExit( + 'Unable to decode the file at path "$path". ' + 'Ensure that the file is encoded in UTF-8 or UTF-16.\n' + 'Error details: $err', + ); + } - // Determine whether the file content is JSON or .env format. - String configJsonRaw; - if (configRaw.trim().startsWith('{')) { - configJsonRaw = configRaw; - } else { - // Convert env file to JSON. - configJsonRaw = convertEnvFileToJsonRaw(configRaw); - } + // Determine whether the file content is JSON or .env format. + String configJsonRaw; + if (configRaw.trim().startsWith('{')) { + configJsonRaw = configRaw; + } else { + // Convert env file to JSON. + configJsonRaw = convertEnvFileToJsonRaw(configRaw); + } - try { - // Fix json convert Object value :type '_InternalLinkedHashMap' is not a subtype of type 'Map' in type cast - (json.decode(configJsonRaw) as Map).forEach((String key, Object? value) { - dartDefineConfigJsonMap[key] = value; - }); - } on FormatException catch (err) { - throwToolExit( - 'Unable to parse the file at path "$path" due to a formatting error. ' - 'Ensure that the file contains valid JSON.\n' - 'Error details: $err', - ); - } + try { + dartDefineConfigJsonMap.addAll(json.decode(configJsonRaw) as Map); + } on FormatException catch (err) { + throwToolExit( + 'Unable to parse the file at path "$path" due to a formatting error. ' + 'Ensure that the file contains valid JSON.\n' + 'Error details: $err', + ); } } @@ -1878,8 +1331,8 @@ abstract class FlutterCommand extends Command { Map extractWebHeaders() { final webHeaders = {}; - if (argParser.options.containsKey('web-header')) { - final List candidates = stringsArg('web-header'); + final List candidates = getValue(WebOptions.webHeader); + if (candidates.isNotEmpty) { final invalidHeaders = []; for (final candidate in candidates) { final Match? keyValueMatch = _HttpRegex.httpHeader.firstMatch(candidate); @@ -1901,17 +1354,17 @@ abstract class FlutterCommand extends Command { void _registerSignalHandlers(String commandPath, DateTime startTime) { void handler(io.ProcessSignal s) { - globals.cache.releaseLock(); + _cache.releaseLock(); _sendPostUsage( commandPath, const FlutterCommandResult(ExitStatus.killed), startTime, - globals.systemClock.now(), + _clock.now(), ); } - globals.signals.addHandler(io.ProcessSignal.sigterm, handler); - globals.signals.addHandler(io.ProcessSignal.sigint, handler); + _signals.addHandler(ProcessSignal.sigterm, handler); + _signals.addHandler(ProcessSignal.sigint, handler); } /// Logs data about this command. @@ -1925,13 +1378,14 @@ abstract class FlutterCommand extends Command { DateTime endTime, ) { // Send command result. - final int? maxRss = getMaxRss(processInfo); - analytics.send( + final int? maxRss = getMaxRss(processInfo, logger: _logger); + _analytics.send( Event.flutterCommandResult( commandPath: commandPath, result: commandResult.toString(), - maxRss: maxRss, commandHasTerminal: hasTerminal, + hostArch: _os.hostPlatform.cliName, + maxRss: maxRss, ), ); @@ -1950,7 +1404,7 @@ abstract class FlutterCommand extends Command { final Duration elapsedDuration = (commandResult.endTimeOverride ?? endTime).difference( startTime, ); - analytics.send( + _analytics.send( Event.timing( workflow: 'flutter', variableName: name, @@ -1971,19 +1425,32 @@ abstract class FlutterCommand extends Command { /// rather than calling [runCommand] directly. @mustCallSuper Future verifyThenRunCommand(String? commandPath) async { - globals.preRunValidator.validate(); + _preRunValidator.validate(); + + if (wasParsed(DebuggingOptionDescriptors.enableImpeller)) { + if (getBuildMode().isRelease) { + final bool enableImpeller = getValue(DebuggingOptionDescriptors.enableImpeller)!; + final flagName = enableImpeller + ? '--${FlutterOptions.kEnableImpeller}' + : '--no-${FlutterOptions.kEnableImpeller}'; + _logger.printWarning( + 'The "$flagName" flag is ignored in release builds. ' + 'The rendering backend is determined at build time.', + ); + } + } - if (globals.os.hostPlatform == .darwin_x64 && - globals.persistentToolState!.shouldShowIntelMacWarning) { - globals.logger.printWarning( + final PersistentToolState? persistentToolState = _persistentToolState; + if (_os.hostPlatform == HostPlatform.darwin_x64 && + (persistentToolState?.shouldShowIntelMacWarning ?? true)) { + _logger.printWarning( 'Flutter is deprecating support for Intel-based Macs. ' 'A future version of Flutter will require an Apple Silicon Mac to build applications.', ); - globals.persistentToolState!.shouldShowIntelMacWarning = false; + persistentToolState?.shouldShowIntelMacWarning = false; } if (refreshWirelessDevices) { - // Loading wireless devices takes longer so start it early. _targetDevices.startExtendedWirelessDeviceDiscovery( deviceDiscoveryTimeout: deviceDiscoveryTimeout, ); @@ -1993,7 +1460,7 @@ abstract class FlutterCommand extends Command { try { project = await _updateCacheAndRunPubGet(); } finally { - globals.cache.releaseLock(); + _cache.releaseLock(); } if (regeneratePlatformSpecificToolingDuringVerify) { @@ -2003,10 +1470,8 @@ abstract class FlutterCommand extends Command { ); } - setupApplicationPackages(); - if (commandPath != null) { - analytics.send(await unifiedAnalyticsUsageValues(commandPath)); + _analytics.send(await unifiedAnalyticsUsageValues(commandPath)); } return runCommand(); @@ -2018,20 +1483,15 @@ abstract class FlutterCommand extends Command { if (shouldUpdateCache) { // First always update universal artifacts, as some of these (e.g. // ios-deploy on macOS) are required to determine `requiredArtifacts`. - final bool offline; - if (argParser.options.containsKey('offline')) { - offline = boolArg('offline'); - } else { - offline = false; - } - await globals.cache.updateAll({ + final bool offline = getValue(CommonOptions.offline); + await _cache.updateAll({ DevelopmentArtifact.universal, }, offline: offline); - await globals.cache.updateAll(await requiredArtifacts, offline: offline); + await _cache.updateAll(await requiredArtifacts, offline: offline); } await validateCommand(); - final FlutterProject project = FlutterProject.current(); + final FlutterProject project = _projectFactory.fromDirectory(_fs.currentDirectory); project.checkForDeprecation(deprecationBehavior: deprecationBehavior); if (shouldRunPub) { @@ -2122,10 +1582,10 @@ abstract class FlutterCommand extends Command { return null; } if (deviceList.length > 1) { - globals.printStatus(globals.userMessages.flutterSpecifyDevice); + _logger.printStatus(_userMessages.flutterSpecifyDevice); final List allDevices = await globals.deviceManager!.getAllDevices(); - globals.printStatus(''); - await Device.printDevices(allDevices, globals.logger); + _logger.printStatus(''); + await Device.printDevices(allDevices, _logger); return null; } return deviceList.single; @@ -2134,27 +1594,25 @@ abstract class FlutterCommand extends Command { @protected @mustCallSuper Future validateCommand() async { - if (_requiresPubspecYaml && globalResults?.wasParsed('packages') != true) { + if (_requiresPubspecYaml && !wasParsed(CommonOptions.packages)) { // Don't expect a pubspec.yaml file if the user passed in an explicit package_config.json file path. // If there is no pubspec in the current directory, look in the parent // until one can be found. - final String? path = findProjectRoot(globals.fs, globals.fs.currentDirectory.path); + final String? path = findProjectRoot(_fs, _fs.currentDirectory.path); if (path == null) { - throwToolExit(globals.userMessages.flutterNoPubspec); + throwToolExit(_userMessages.flutterNoPubspec); } - if (path != globals.fs.currentDirectory.path) { - globals.fs.currentDirectory = path; - globals.printStatus( - 'Changing current working directory to: ${globals.fs.currentDirectory.path}', - ); + if (path != _fs.currentDirectory.path) { + _fs.currentDirectory = path; + _logger.printStatus('Changing current working directory to: ${_fs.currentDirectory.path}'); } } if (_usesTargetOption) { final String targetPath = targetFile; - if (!globals.fs.isFileSync(targetPath)) { - throwToolExit(globals.userMessages.flutterTargetFileMissing(targetPath)); + if (!_fs.isFileSync(targetPath)) { + throwToolExit(_userMessages.flutterTargetFileMissing(targetPath)); } } } @@ -2167,7 +1625,7 @@ abstract class FlutterCommand extends Command { ); final String help = [ if (deprecated) - '${globals.logger.terminal.warningMark} Deprecated. This command will be removed in a future version of Flutter.', + '${_logger.terminal.warningMark} Deprecated. This command will be removed in a future version of Flutter.', description, '', 'Global options:', @@ -2178,7 +1636,14 @@ abstract class FlutterCommand extends Command { return help; } - ApplicationPackageFactory? applicationPackages; + // TODO(bkonyi): Remove once commands using ApplicationPackageFactory are migrated to explicit dependency injection, https://github.com/flutter/flutter/issues/47161. + ApplicationPackageFactory? get applicationPackages => + _applicationPackages ?? ApplicationPackageFactory.instance; + set applicationPackages(ApplicationPackageFactory? value) { + _applicationPackages = value; + } + + ApplicationPackageFactory? _applicationPackages; /// Gets the parsed command-line flag named [name] as a `bool`. /// @@ -2220,7 +1685,10 @@ mixin DeviceBasedDevelopmentArtifacts on FlutterCommand { final artifacts = {DevelopmentArtifact.universal}; for (final device in devices) { final TargetPlatform targetPlatform = await device.targetPlatform; - final DevelopmentArtifact? developmentArtifact = artifactFromTargetPlatform(targetPlatform); + final DevelopmentArtifact? developmentArtifact = artifactFromTargetPlatform( + targetPlatform, + featureFlags, + ); if (developmentArtifact != null) { artifacts.add(developmentArtifact); } @@ -2232,39 +1700,35 @@ mixin DeviceBasedDevelopmentArtifacts on FlutterCommand { // Returns the development artifact for the target platform, or null // if none is supported @protected -DevelopmentArtifact? artifactFromTargetPlatform(TargetPlatform targetPlatform) { - switch (targetPlatform) { - case TargetPlatform.android: - case TargetPlatform.android_arm: - case TargetPlatform.android_arm64: - case TargetPlatform.android_x64: +DevelopmentArtifact? artifactFromTargetPlatform( + TargetPlatform targetPlatform, + FeatureFlags featureFlags, +) { + switch (targetPlatform.os) { + case .android: return DevelopmentArtifact.androidGenSnapshot; - case TargetPlatform.web_javascript: + case .web: return DevelopmentArtifact.web; - case TargetPlatform.ios: + case .fuchsia: + return null; + case .ios: return DevelopmentArtifact.iOS; - case TargetPlatform.darwin: + case .macos: if (featureFlags.isMacOSEnabled) { return DevelopmentArtifact.macOS; } return null; - case TargetPlatform.windows_x64: - case TargetPlatform.windows_arm64: + case .windows: if (featureFlags.isWindowsEnabled) { return DevelopmentArtifact.windows; } return null; - case TargetPlatform.linux_x64: - case TargetPlatform.linux_arm64: - case TargetPlatform.linux_riscv64: + case .linux: if (featureFlags.isLinuxEnabled) { return DevelopmentArtifact.linux; } return null; - case TargetPlatform.fuchsia_arm64: - case TargetPlatform.fuchsia_x64: - case TargetPlatform.tester: - case TargetPlatform.unsupported: + case .tester || .unsupported: return null; } } diff --git a/packages/flutter_tools/lib/src/runner/flutter_command_runner.dart b/packages/flutter_tools/lib/src/runner/flutter_command_runner.dart index 983c8281..bdaf6fd9 100644 --- a/packages/flutter_tools/lib/src/runner/flutter_command_runner.dart +++ b/packages/flutter_tools/lib/src/runner/flutter_command_runner.dart @@ -6,22 +6,34 @@ import 'package:args/args.dart'; import 'package:args/command_runner.dart'; import 'package:completion/completion.dart'; import 'package:file/file.dart'; +import 'package:process/process.dart'; import 'package:unified_analytics/unified_analytics.dart'; import '../artifacts.dart'; +import '../base/bot_detector.dart'; import '../base/common.dart'; import '../base/context.dart'; import '../base/file_system.dart'; +import '../base/io.dart'; +import '../base/logger.dart'; +import '../base/os.dart'; +import '../base/platform.dart'; import '../base/process.dart'; import '../base/terminal.dart'; +import '../base/time.dart'; +import '../base/user_messages.dart'; import '../base/utils.dart'; import '../cache.dart'; +import '../context/tool_context.dart'; import '../convert.dart'; +import '../features.dart'; import '../globals.dart' as globals; import '../resident_runner.dart'; import '../tester/flutter_tester.dart'; import '../version.dart'; import '../web/web_device.dart'; +import 'flutter_command.dart'; +import 'local_engine.dart'; /// Common flutter command line options. abstract final class FlutterGlobalOptions { @@ -51,19 +63,41 @@ abstract final class FlutterGlobalOptions { } class FlutterCommandRunner extends CommandRunner { - FlutterCommandRunner({bool verboseHelp = false}) - : super( - 'flutter', - 'Manage your Flutter app development.\n' - '\n' - 'Common commands:\n' - '\n' - ' flutter create \n' - ' Create a new Flutter project in the specified directory.\n' - '\n' - ' flutter run [options]\n' - ' Run your Flutter application on an attached device or in an emulator.', - ) { + FlutterCommandRunner({ + required ToolContext toolContext, + this._analytics = const NoOpAnalytics(), + bool verboseHelp = false, + this._featureFlags, + }) : _toolContext = toolContext, + _verboseHelp = verboseHelp, + _argParser = ArgParser( + allowTrailingOptions: false, + usageLineLength: toolContext.outputPreferences.wrapText + ? toolContext.outputPreferences.wrapColumn + : null, + ), + super( + 'flutter', + 'Manage your Flutter app development.\n' + '\n' + 'Common commands:\n' + '\n' + ' flutter create \n' + ' Create a new Flutter project in the specified directory.\n' + '\n' + ' flutter run [options]\n' + ' Run your Flutter application on an attached device or in an emulator.', + ) { + _populateOptions(verboseHelp: verboseHelp); + } + + final bool _verboseHelp; + + @override + ArgParser get argParser => _argParser; + ArgParser _argParser; + + void _populateOptions({required bool verboseHelp}) { argParser.addFlag( FlutterGlobalOptions.kVerboseFlag, abbr: 'v', @@ -147,17 +181,8 @@ class FlutterCommandRunner extends CommandRunner { negatable: false, help: 'Suppress analytics reporting for the current CLI invocation.', ); - argParser.addOption( - FlutterGlobalOptions.kPackagesOption, - hide: !verboseHelp, - help: 'Path to your "package_config.json" file.', - ); - argParser.addFlag( - FlutterGlobalOptions.kPrintDtd, - negatable: false, - help: 'Print the address of the Dart Tooling Daemon, if one is hosted by the Flutter CLI.', - hide: !verboseHelp, - ); + argParser.addDescriptor(CommonOptions.packages, verboseHelp: verboseHelp); + argParser.addDescriptor(CommonOptions.printDtd, verboseHelp: verboseHelp); if (verboseHelp) { argParser.addSeparator('Local build selection options (not normally required):'); @@ -192,14 +217,7 @@ class FlutterCommandRunner extends CommandRunner { 'platform (e.g. MacOS ARM64) but intend to run Flutter on another (e.g. Android).', ); - argParser.addOption( - FlutterGlobalOptions.kLocalWebSDKOption, - hide: !verboseHelp, - help: - 'Name of a build output within the engine out directory, if you are building Flutter locally.\n' - 'Use this to select a specific version of the web sdk if you have built multiple engine targets.\n' - 'This path is relative to "--local-engine-src-path" (see above).', - ); + argParser.addDescriptor(CommonOptions.localWebSdk, verboseHelp: verboseHelp); if (verboseHelp) { argParser.addSeparator('Options for testing the "flutter" tool itself:'); @@ -218,44 +236,40 @@ class FlutterCommandRunner extends CommandRunner { hide: !verboseHelp, help: 'List the special "web-server" device in device listings.', ); - argParser.addFlag( - FlutterGlobalOptions.kContinuousIntegrationFlag, - negatable: false, - help: 'Enable a set of CI-specific test debug settings.', - hide: !verboseHelp, - ); - argParser.addOption( - FlutterGlobalOptions.kDebugLogsDirectoryFlag, - help: 'Path to a directory where logs for debugging may be added.', - hide: !verboseHelp, - ); + argParser.addDescriptor(CommonOptions.ci, verboseHelp: verboseHelp); + argParser.addDescriptor(CommonOptions.debugLogsDir, verboseHelp: verboseHelp); } - @override - ArgParser get argParser => _argParser; - final _argParser = ArgParser( - allowTrailingOptions: false, - usageLineLength: globals.outputPreferences.wrapText - ? globals.outputPreferences.wrapColumn - : null, - ); + void _updateArgParser({required int? wrapColumn}) { + _argParser = ArgParser(allowTrailingOptions: false, usageLineLength: wrapColumn); + + argParser.addFlag('help', abbr: 'h', negatable: false, help: 'Print this usage information.'); + + _populateOptions(verboseHelp: _verboseHelp); + + for (final MapEntry> entry in commands.entries) { + argParser.addCommand(entry.key, entry.value.argParser); + } + } @override String get usageFooter { + final OutputPreferences outputPreferences = _toolContext.outputPreferences; return wrapText( 'Run "flutter help -v" for verbose help output, including less commonly used options.', - columnWidth: globals.outputPreferences.wrapColumn, - shouldWrap: globals.outputPreferences.wrapText, + columnWidth: outputPreferences.wrapColumn, + shouldWrap: outputPreferences.wrapText, ); } @override String get usage { final String usageWithoutDescription = super.usage.substring(description.length + 2); + final OutputPreferences outputPreferences = _toolContext.outputPreferences; final String prefix = wrapText( description, - shouldWrap: globals.outputPreferences.wrapText, - columnWidth: globals.outputPreferences.wrapColumn, + shouldWrap: outputPreferences.wrapText, + columnWidth: outputPreferences.wrapColumn, ); return '$prefix\n\n$usageWithoutDescription'; } @@ -282,11 +296,24 @@ class FlutterCommandRunner extends CommandRunner { } } + final Analytics _analytics; + final FeatureFlags? _featureFlags; + final ToolContext _toolContext; + + /// The [Analytics] instance. + Analytics get analytics => _analytics; + + /// The [ToolContext] instance. + ToolContext get toolContext => _toolContext; + + /// The [FeatureFlags] instance, if provided. + FeatureFlags? get featureFlags => _featureFlags; + // See https://github.com/flutter/flutter/issues/145158. late bool _machineFlagPresentInAnyCliArg; @override - Future run(Iterable args) { + Future run(Iterable args) async { var exitWithCodeOne = false; // Have invocations of 'build', 'custom-devices', and 'pub' print out @@ -304,12 +331,88 @@ class FlutterCommandRunner extends CommandRunner { } _machineFlagPresentInAnyCliArg = args.contains('--${FlutterGlobalOptions.kMachineFlag}'); - return super.run(args).then((_) async { - if (exitWithCodeOne) { - // No need to print anything because the help was already printed. - await exitWithHooks(1, shutdownHooks: globals.shutdownHooks); + await _initializeDynamicOptions(args); + await super.run(args); + if (exitWithCodeOne) { + // No need to print anything because the help was already printed. + await exitWithHooks(1, shutdownHooks: _toolContext.shutdownHooks); + } + } + + /// Traverses [args] to identify the target command being invoked and triggers + /// dynamic option initialization (via [FlutterCommand.initializeDynamicOptions]) + /// before argument parsing begins. + Future _initializeDynamicOptions(Iterable args) async { + if (_featureFlags?.isToolExtensionsEnabled != true) { + return; + } + if (_findTargetCommand(args) case var command?) { + if (command.name == 'help') { + // If the top-level 'help' command was matched (e.g. `flutter help config` or `flutter help --verbose config`), + // identify the target command whose help documentation is requested rather than the help command itself. + command = + _findTargetCommand( + args.skipWhile((String arg) => arg == 'help' || arg.startsWith('-')), + ) ?? + command; + } + Command? current = command; + while (current != null) { + if (current case final FlutterCommand flutterCommand) { + await flutterCommand.initializeDynamicOptions(); + } + current = current.parent; } - }); + } + } + + /// Helper to find the target command from the list of arguments, traversing subcommands and skipping options. + /// + /// Skips global and command-level options (e.g., `--verbose`, `-v`, `--device-id `, `--device-id=id`) + /// so that non-option arguments are matched against registered command and subcommand names. + Command? _findTargetCommand(Iterable args) { + Map> commandsMap = commands; + Command? lastFoundCommand; + ArgParser currentParser = argParser; + final List argsList = args.toList(); + var i = 0; + while (i < argsList.length) { + final String arg = argsList[i]; + // Options and flags start with '-'. + if (arg.startsWith('-')) { + var name = arg; + while (name.startsWith('-')) { + name = name.substring(1); + } + // Option with inline value (e.g., '--device-id=foo' or '-d=foo'): consumes 1 argument token. + if (name.contains('=')) { + i++; + continue; + } + // Look up option in the current command or runner parser to check if it expects a separate value token. + Option? option; + for (final Option opt in currentParser.options.values) { + if (opt.name == name || opt.abbr == name) { + option = opt; + break; + } + } + // Non-flag options (single/multi options) consume the subsequent argument as their value (e.g., '-d' 'linux'). + if (option != null && !option.isFlag) { + i += 2; + } else { + i++; + } + } else { + if (commandsMap[arg] case final matchedCommand?) { + lastFoundCommand = matchedCommand; + commandsMap = matchedCommand.subcommands; + currentParser = matchedCommand.argParser; + } + i++; + } + } + return lastFoundCommand; } /// Whether to perform a flutter version check, which prints a warning if old. @@ -317,10 +420,12 @@ class FlutterCommandRunner extends CommandRunner { /// This method should be narrowly used in the following manner: /// ```dart /// if (await _shouldCheckForUpdates(topLevelResult)) { - /// await globals.flutterVersion.checkFlutterVersionFreshness(); + /// await flutterVersion.checkFlutterVersionFreshness(); /// } /// ``` Future _shouldCheckForUpdates(ArgResults topLevelResults) async { + final Stdio stdio = _toolContext.stdio; + final BotDetector botDetector = _toolContext.botDetector; // Check if the user has explicitly requested a version check. final bool versionCheckFlag = topLevelResults[FlutterGlobalOptions.kVersionCheckFlag] as bool? ?? false; @@ -348,8 +453,8 @@ class FlutterCommandRunner extends CommandRunner { // e.g. `flutter bash-completion` or `flutter zsh-completion` final bool isShellCompletionCommand = - !globals.stdio.hasTerminal && (topLevelResults.command?.name ?? '').endsWith('-completion'); - if (isShellCompletionCommand || await globals.botDetector.isRunningOnBot) { + !stdio.hasTerminal && (topLevelResults.command?.name ?? '').endsWith('-completion'); + if (isShellCompletionCommand || await botDetector.isRunningOnBot) { return false; } @@ -359,6 +464,15 @@ class FlutterCommandRunner extends CommandRunner { @override Future runCommand(ArgResults topLevelResults) async { + final Stdio stdio = _toolContext.stdio; + final UserMessages userMessages = _toolContext.userMessages; + final LocalEngineLocator localEngineLocator = _toolContext.localEngineLocator; + final Platform platform = _toolContext.platform; + final Cache cache = _toolContext.cache; + final FlutterVersion flutterVersion = _toolContext.flutterVersion; + final Logger logger = _toolContext.logger; + final SystemClock systemClock = _toolContext.systemClock; + final contextOverrides = {}; // If the flag for enabling or disabling telemetry is passed in, @@ -377,14 +491,14 @@ class FlutterCommandRunner extends CommandRunner { wrapColumn = int.parse(topLevelResults[FlutterGlobalOptions.kWrapColumnOption] as String); if (wrapColumn < 0) { throwToolExit( - globals.userMessages.runnerWrapColumnInvalid( + userMessages.runnerWrapColumnInvalid( topLevelResults[FlutterGlobalOptions.kWrapColumnOption], ), ); } } on FormatException { throwToolExit( - globals.userMessages.runnerWrapColumnParseError( + userMessages.runnerWrapColumnParseError( topLevelResults[FlutterGlobalOptions.kWrapColumnOption], ), ); @@ -395,14 +509,17 @@ class FlutterCommandRunner extends CommandRunner { // anything, unless the user explicitly said to. final bool useWrapping = topLevelResults.wasParsed(FlutterGlobalOptions.kWrapFlag) ? topLevelResults[FlutterGlobalOptions.kWrapFlag] as bool - : globals.stdio.terminalColumns != null && - topLevelResults[FlutterGlobalOptions.kWrapFlag] as bool; + : stdio.terminalColumns != null && topLevelResults[FlutterGlobalOptions.kWrapFlag] as bool; contextOverrides[OutputPreferences] = OutputPreferences( wrapText: useWrapping, showColor: topLevelResults[FlutterGlobalOptions.kColorFlag] as bool?, wrapColumn: wrapColumn, ); + _updateArgParser( + wrapColumn: useWrapping ? (wrapColumn ?? toolContext.stdio.terminalColumns) : null, + ); + if (((topLevelResults[FlutterGlobalOptions.kShowTestDeviceFlag] as bool?) ?? false) || topLevelResults[FlutterGlobalOptions.kDeviceIdOption] == FlutterTesterDevices.kTesterDeviceId) { @@ -415,7 +532,7 @@ class FlutterCommandRunner extends CommandRunner { } // Set up the tooling configuration. - final EngineBuildPaths? engineBuildPaths = await globals.localEngineLocator?.findEnginePath( + final EngineBuildPaths? engineBuildPaths = await localEngineLocator.findEnginePath( engineSourcePath: topLevelResults[FlutterGlobalOptions.kLocalEngineSrcPathOption] as String?, localEngine: topLevelResults[FlutterGlobalOptions.kLocalEngineOption] as String?, localHostEngine: topLevelResults[FlutterGlobalOptions.kLocalEngineHostOption] as String?, @@ -423,9 +540,26 @@ class FlutterCommandRunner extends CommandRunner { packagePath: topLevelResults[FlutterGlobalOptions.kPackagesOption] as String?, ); if (engineBuildPaths != null) { - contextOverrides.addAll({ - Artifacts: Artifacts.getLocalEngine(engineBuildPaths), - }); + final ToolContext( + :Cache cache, + :FileSystem fs, + :OperatingSystemUtils os, + :Platform platform, + :ProcessManager processManager, + ) = _toolContext; + final Artifacts localArtifacts = Artifacts.getLocalEngine( + engineBuildPaths, + cache: cache, + fileSystem: fs, + operatingSystemUtils: os, + platform: platform, + processManager: processManager, + ); + contextOverrides.addAll({Artifacts: localArtifacts}); + // Update the artifacts the commands were created with. + if (_toolContext.artifacts case final DeferredArtifacts artifacts) { + artifacts.resolve(localArtifacts); + } } await context.run( @@ -433,22 +567,22 @@ class FlutterCommandRunner extends CommandRunner { return MapEntry(type, () => value); }), body: () async { - globals.logger.quiet = (topLevelResults[FlutterGlobalOptions.kQuietFlag] as bool?) ?? false; + logger.quiet = (topLevelResults[FlutterGlobalOptions.kQuietFlag] as bool?) ?? false; - if (globals.platform.environment['FLUTTER_ALREADY_LOCKED'] != 'true') { - await globals.cache.lock(); + if (platform.environment['FLUTTER_ALREADY_LOCKED'] != 'true') { + await cache.lock(); } if ((topLevelResults[FlutterGlobalOptions.kSuppressAnalyticsFlag] as bool?) ?? false) { - globals.analytics.suppressTelemetry(); + _analytics.suppressTelemetry(); } // Required to support `flutter --version` before artifacts are cached. - await globals.cache.updateAll({DevelopmentArtifact.informative}); + await cache.updateAll({DevelopmentArtifact.informative}); - globals.flutterVersion.ensureVersionFile(); + flutterVersion.ensureVersionFile(); if (await _shouldCheckForUpdates(topLevelResults)) { - await globals.flutterVersion.checkFlutterVersionFreshness(); + await flutterVersion.checkFlutterVersionFreshness(); } // See if the user specified a specific device. @@ -460,16 +594,15 @@ class FlutterCommandRunner extends CommandRunner { final bool topLevelMachineFlag = topLevelResults[FlutterGlobalOptions.kMachineFlag] as bool? ?? false; if ((topLevelResults[FlutterGlobalOptions.kVersionFlag] as bool?) ?? false) { - globals.analytics.send( + _analytics.send( Event.flutterCommandResult( commandPath: 'version', result: 'success', - commandHasTerminal: globals.stdio.hasTerminal, + commandHasTerminal: stdio.hasTerminal, + hostArch: _toolContext.os.hostPlatform.cliName, ), ); - final FlutterVersion version = globals.flutterVersion.fetchTagsAndGetVersion( - clock: globals.systemClock, - ); + final FlutterVersion version = flutterVersion.fetchTagsAndGetVersion(clock: systemClock); final String status; if (topLevelMachineFlag) { final Map jsonOut = version.toJson(); @@ -478,7 +611,7 @@ class FlutterCommandRunner extends CommandRunner { } else { status = version.toString(); } - globals.printStatus(status); + logger.printStatus(status); return; } if (topLevelMachineFlag && topLevelResults.command?.name != 'analyze') { @@ -491,7 +624,9 @@ class FlutterCommandRunner extends CommandRunner { // TODO(bkonyi): can this be removed and passed solely via DebuggingOptions? final bool shouldPrintDtdUri = topLevelResults[FlutterGlobalOptions.kPrintDtd] as bool? ?? false; - DevtoolsLauncher.instance!.printDtdUri = shouldPrintDtdUri; + if (shouldPrintDtdUri) { + DevtoolsLauncher.instance?.printDtdUri = shouldPrintDtdUri; + } await super.runCommand(topLevelResults); }, @@ -500,38 +635,46 @@ class FlutterCommandRunner extends CommandRunner { /// Get the root directories of the repo - the directories containing Dart packages. List getRepoRoots() { - final String root = globals.fs.path.absolute(Cache.flutterRoot!); + final FileSystem fs = _toolContext.fs; + final String root = fs.path.absolute(Cache.flutterRoot!); // not bin, and not the root return ['dev', 'examples', 'packages'].map((String item) { - return globals.fs.path.join(root, item); + return fs.path.join(root, item); }).toList(); } /// Get all pub packages in the Flutter repo. List getRepoPackages() { + final FileSystem fs = _toolContext.fs; return getRepoRoots() - .expand((String root) => _gatherProjectPaths(root)) - .map((String dir) => globals.fs.directory(dir)) + .expand((String root) => _gatherProjectPaths(fs, root)) + .map((String dir) => fs.directory(dir)) .toList(); } - static List _gatherProjectPaths(String rootPath) { - if (globals.fs.isFileSync(globals.fs.path.join(rootPath, '.dartignore'))) { + /// Directory names to skip when scanning repository packages to avoid + /// traversing build caches and generated artifacts. + static const _ignoredDirectoryNames = {'.dart_tool', 'build'}; + + static List _gatherProjectPaths(FileSystem fs, String rootPath) { + if (fs.isFileSync(fs.path.join(rootPath, '.dartignore'))) { + return []; + } + final Directory directory = fs.directory(rootPath); + if (!directory.existsSync()) { return []; } - final List projectPaths = globals.fs - .directory(rootPath) - .listSync(followLinks: false) - .expand((FileSystemEntity entity) { - if (entity is Directory && !globals.fs.path.split(entity.path).contains('.dart_tool')) { - return _gatherProjectPaths(entity.path); - } - return []; - }) - .toList(); + final List projectPaths = directory.listSync(followLinks: false).expand(( + FileSystemEntity entity, + ) { + if (entity is Directory && !_ignoredDirectoryNames.contains(fs.path.basename(entity.path))) { + return _gatherProjectPaths(fs, entity.path); + } + return []; + }).toList(); - if (globals.fs.isFileSync(globals.fs.path.join(rootPath, 'pubspec.yaml'))) { + if (fs.isFileSync(fs.path.join(rootPath, 'pubspec.yaml'))) { projectPaths.add(rootPath); } diff --git a/packages/flutter_tools/lib/src/runner/local_engine.dart b/packages/flutter_tools/lib/src/runner/local_engine.dart index 1b779be9..716bab27 100644 --- a/packages/flutter_tools/lib/src/runner/local_engine.dart +++ b/packages/flutter_tools/lib/src/runner/local_engine.dart @@ -25,16 +25,12 @@ import '../dart/package_map.dart'; /// For more information on local engines, see README.md. class LocalEngineLocator { LocalEngineLocator({ - required Platform platform, - required Logger logger, - required FileSystem fileSystem, - required String flutterRoot, - required UserMessages userMessages, - }) : _platform = platform, - _logger = logger, - _fileSystem = fileSystem, - _flutterRoot = flutterRoot, - _userMessages = userMessages; + required this._platform, + required this._logger, + required this._fileSystem, + required this._flutterRoot, + required this._userMessages, + }); final Platform _platform; final Logger _logger; diff --git a/packages/flutter_tools/lib/src/runner/options/common_options.dart b/packages/flutter_tools/lib/src/runner/options/common_options.dart new file mode 100644 index 00000000..1b01045e --- /dev/null +++ b/packages/flutter_tools/lib/src/runner/options/common_options.dart @@ -0,0 +1,1285 @@ +// Copyright 2014 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 '../../build_info.dart'; +import '../flutter_command.dart'; +import '../flutter_command_runner.dart'; + +/// Common typed option descriptors across flutter commands. +abstract final class CommonOptions { + static const ci = FlagOptionDescriptor( + name: FlutterGlobalOptions.kContinuousIntegrationFlag, + negatable: false, + scope: OptionScope.global, + verboseOnly: true, + help: 'Enable a set of CI-specific test debug settings.', + ); + + static const treeShakeIcons = FlagOptionDescriptor( + name: 'tree-shake-icons', + defaultsTo: true, + help: 'Tree shake icon fonts so that only glyphs used by the application are included.', + ); + + static const target = DefaultedStringOptionDescriptor( + name: 'target', + abbr: 't', + defaultsTo: 'lib/main.dart', + help: + 'The main entrypoint file of the application, as run on the device.\n' + 'If the "--target" option is omitted, but a file name is provided on ' + 'the command line, then that file is used instead.', + ); + + static const outputDir = StringOptionDescriptor( + name: 'output', + abbr: 'o', + aliases: ['output-dir'], + help: + 'The absolute path to the directory where the repository is generated. ' + 'By default, this is /build/.\n' + 'Currently supported for subcommands: aar, web.', + ); + + static const pub = FlagOptionDescriptor( + name: 'pub', + defaultsTo: true, + help: 'Whether to run "flutter pub get" before executing this command.', + ); + + static const buildNumber = StringOptionDescriptor( + name: 'build-number', + valueHelp: '1.0.0', + help: + 'An identifier used as an internal version number.\n' + 'Each build must have a unique identifier to differentiate it from previous builds.', + ); + + static const buildName = StringOptionDescriptor( + name: 'build-name', + valueHelp: 'x.y.z', + help: + 'A "x.y.z" string used as the version number shown to users.\n' + 'For each new version of your app, you will provide a version number to differentiate it.', + ); + + static const debugMode = FlagOptionDescriptor( + name: 'debug', + negatable: false, + help: 'Build a debug version of your app.', + ); + + static const profileMode = FlagOptionDescriptor( + name: 'profile', + negatable: false, + help: 'Build a version of your app specialized for performance profiling.', + ); + + static const releaseMode = FlagOptionDescriptor( + name: 'release', + negatable: false, + help: 'Build a release version of your app.', + ); + + static const jitReleaseMode = FlagOptionDescriptor( + name: 'jit-release', + negatable: false, + verboseOnly: true, + help: 'Build a JIT release version of your app.', + ); + + static const fatalWarnings = FlagOptionDescriptor( + name: FlutterOptions.kFatalWarnings, + verboseOnly: true, + help: + 'Causes the command to fail if warnings are sent to the console ' + 'during its execution.', + ); + + static const machine = FlagOptionDescriptor( + name: FlutterGlobalOptions.kMachineFlag, + negatable: false, + verboseOnly: true, + help: 'Outputs in a machine readable structured JSON format.', + ); + + static const packages = StringOptionDescriptor( + name: FlutterGlobalOptions.kPackagesOption, + scope: OptionScope.global, + verboseOnly: true, + help: 'Path to your "package_config.json" file.', + ); + + static const debugLogsDir = StringOptionDescriptor( + name: FlutterGlobalOptions.kDebugLogsDirectoryFlag, + scope: OptionScope.global, + verboseOnly: true, + help: 'Path to a directory where logs for debugging may be added.', + ); + + static const printDtd = FlagOptionDescriptor( + name: FlutterGlobalOptions.kPrintDtd, + negatable: false, + scope: OptionScope.global, + verboseOnly: true, + help: 'Print the address of the Dart Tooling Daemon, if one is hosted by the Flutter CLI.', + ); + + static const localWebSdk = StringOptionDescriptor( + name: FlutterGlobalOptions.kLocalWebSDKOption, + scope: OptionScope.global, + verboseOnly: true, + help: + 'Name of a build output within the engine out directory, if you are building Flutter locally.\n' + 'Use this to select a specific version of the web sdk if you have built multiple engine targets.\n' + 'This path is relative to "--local-engine-src-path" (see above).', + ); + + static const offline = FlagOptionDescriptor( + name: 'offline', + help: + 'When "flutter pub get" is run by the create command, this indicates ' + 'whether to run it in offline mode or not. In offline mode, it will need to ' + 'have all dependencies already available in the pub cache to succeed.', + ); + + static const deviceUser = StringOptionDescriptor( + name: FlutterOptions.kDeviceUser, + valueHelp: '10', + help: + 'Identifier number for a user or work profile on Android only. ' + 'Run "adb shell pm list users" for available identifiers.', + ); + + static const deviceTimeout = StringOptionDescriptor( + name: FlutterOptions.kDeviceTimeout, + valueHelp: '10', + help: + 'Time in seconds to wait for devices to attach. ' + 'Longer timeouts may be necessary for networked devices.', + ); + + static const deviceConnection = DefaultedStringOptionDescriptor( + name: FlutterOptions.kDeviceConnection, + defaultsTo: 'both', + help: 'Discover devices based on connection type.', + allowed: ['attached', 'wireless', 'both'], + allowedHelp: { + 'both': 'Searches for both attached and wireless devices.', + 'attached': 'Only searches for devices connected by USB or built-in (such as simulators/emulators, MacOS/Windows, Chrome)', + 'wireless': 'Only searches for devices connected wirelessly. Discovering wireless devices may take longer.', + }, + ); + + static const useApplicationBinary = StringOptionDescriptor( + name: FlutterOptions.kUseApplicationBinary, + valueHelp: 'path/to/app.apk', + help: + 'Specify a pre-built application binary to use when running. For Android applications, ' + 'this must be the path to an APK. For iOS applications, the path to an IPA. Other device types ' + 'do not yet support prebuilt application binaries.', + ); + + static const dartDefines = MultiOptionDescriptor( + name: FlutterOptions.kDartDefinesOption, + abbr: 'D', + splitCommas: false, + aliases: [kDartDefines, 'dart-defines'], + help: + 'Additional key-value pairs that will be available as constants ' + 'from the String.fromEnvironment, bool.fromEnvironment, and int.fromEnvironment ' + 'constructors. Multiple defines can be passed by repeating "--dart-define".', + valueHelp: 'foo=bar', + ); + + static const dartDefineFromFile = MultiOptionDescriptor( + name: FlutterOptions.kDartDefineFromFileOption, + help: + 'The path of a .json or .env file containing key-value pairs that will be available as environment ' + 'variables. These can be accessed using the String.fromEnvironment, bool.fromEnvironment, and ' + 'int.fromEnvironment constructors. Multiple define files can be passed by repeating "--dart-define-from-file".', + valueHelp: 'use-keys.json', + ); + + static const enableExperiment = MultiOptionDescriptor( + name: FlutterOptions.kEnableExperiment, + verboseOnly: true, + help: + 'The name of an experimental Dart feature to enable. ' + 'Multiple experiments can be enabled by repeating "--enable-experiment".', + valueHelp: 'experiment-name', + ); + + static const nullAssertions = FlagOptionDescriptor( + name: 'null-assertions', + negatable: false, + help: + 'Perform additional checks in sound mode to catch dereferences of null ' + 'values, and runtime type checks on types containing the Never type.', + ); + + static const nativeNullAssertions = FlagOptionDescriptor( + name: 'native-null-assertions', + defaultsTo: true, + help: + 'Enables additional runtime null checks in web applications to ensure ' + 'the correct nullability of native (such as in dart:html) and external ' + '(such as with JS interop) types. This is enabled by default but only takes ' + 'effect in sound mode. To report an issue with a null assertion failure in ' + 'dart:html or the other dart web libraries, please file a bug at: ' + 'https://github.com/dart-lang/sdk/issues/labels/web-libraries', + ); +} + +/// Typed option descriptors specific to [BuildInfo]. +abstract final class BuildInfoOptions { + static const trackWidgetCreation = FlagOptionDescriptor( + name: 'track-widget-creation', + defaultsTo: true, + help: + 'Track widget creation locations. This enables features such as the widget inspector. ' + 'This parameter is only functional in debug mode (i.e. when compiling JIT, not AOT).', + ); + + static const analyzeSize = FlagOptionDescriptor( + name: FlutterOptions.kAnalyzeSize, + help: + 'Whether to produce additional profile information for artifact output size. ' + 'This flag is only supported on "--release" builds. When building for Android, a single ' + 'ABI must be specified at a time with the "--target-platform" flag. When building for iOS, ' + 'only the symbols from the arm64 architecture are used to analyze code size.\n' + 'This flag cannot be combined with "--${FlutterOptions.kSplitDebugInfoOption}".', + ); + + static const codeSizeDirectory = StringOptionDescriptor( + name: FlutterOptions.kCodeSizeDirectory, + help: + 'The location to write code size analysis files. If this is not specified, files ' + 'are written to a temporary directory under the build directory.', + ); + + static const obfuscate = FlagOptionDescriptor( + name: FlutterOptions.kDartObfuscationOption, + help: + 'In a release build, this flag removes identifiers and replaces them ' + 'with randomized values for the purposes of source code obfuscation. This ' + 'flag must always be combined with "--${FlutterOptions.kSplitDebugInfoOption}", ' + 'the mapping between the values and the original identifiers is stored in the ' + 'symbol map created in the specified directory. For an app built with this ' + 'flag, the "flutter symbolize" command with the right program ' + 'symbol file is required to obtain a human readable stack trace.\n' + 'Because all identifiers are renamed, methods like Object.runtimeType, ' + 'Type.toString, Enum.toString, Stacktrace.toString, Symbol.toString ' + '(for constant symbols or those generated by runtime system) will ' + 'return obfuscated results. Any code or tests that rely on exact names ' + 'will break.', + ); + + static const splitDebugInfo = StringOptionDescriptor( + name: FlutterOptions.kSplitDebugInfoOption, + valueHelp: 'v1.2.3/', + help: + 'In a release build, this flag reduces application size by storing ' + 'Dart program symbols in a separate file on the host rather than in the ' + 'application. The value of the flag should be a directory where program ' + 'symbol files can be stored for later use. These symbol files contain ' + 'the information needed to symbolize Dart stack traces. For an app built ' + 'with this flag, the "flutter symbolize" command with the right program ' + 'symbol file is required to obtain a human readable stack trace.\n' + 'This flag cannot be combined with "--${FlutterOptions.kAnalyzeSize}".', + ); + + static const androidGradleDaemon = FlagOptionDescriptor( + name: FlutterOptions.kAndroidGradleDaemon, + defaultsTo: true, + verboseOnly: true, + help: 'Whether to enable the Gradle daemon when performing an Android build.', + ); + + static const androidProjectArg = MultiOptionDescriptor( + name: FlutterOptions.kAndroidProjectArgs, + abbr: 'P', + aliases: ['android-project-args'], + verboseOnly: true, + help: + 'Additional arguments specified as key=value that are passed directly to the gradle project ' + 'via the -P flag. These can be accessed in build.gradle via the "project.property" API.', + ); + + static const androidProjectCacheDir = StringOptionDescriptor( + name: FlutterOptions.kAndroidGradleProjectCacheDir, + valueHelp: 'path/to/project/cache/', + verboseOnly: true, + help: + 'In an Android build, this flag allows the Gradle project cache directory to be specified ' + 'to an absolute path. Setting this is roughly equivalent to setting the ' + 'GRADLE_PROJECT_CACHE_DIR environment variable.', + ); + + static const androidSkipBuildDependencyValidation = FlagOptionDescriptor( + name: FlutterOptions.kAndroidSkipBuildDependencyValidation, + verboseOnly: true, + help: 'Skips Android Gradle project dependency verification.', + ); + + static const performanceMeasurementFile = StringOptionDescriptor( + name: FlutterOptions.kPerformanceMeasurementFile, + help: 'Output individual target performance to a JSON file.', + ); + + static const configOnly = FlagOptionDescriptor( + name: 'config-only', + help: + 'Update the project configuration without performing a build. ' + 'This can be used in CI/CD process that create an archive to avoid ' + 'performing duplicate work.', + ); + + static const shrink = FlagOptionDescriptor( + name: 'shrink', + verboseOnly: true, + help: + 'This flag has no effect. Code shrinking is always enabled in release builds. ' + 'To learn more, see: https://developer.android.com/studio/build/shrink-code', + ); + + static const ignoreDeprecation = FlagOptionDescriptor( + name: 'ignore-deprecation', + negatable: false, + help: + 'Indicates that the app should ignore deprecation warnings and continue to build ' + 'using deprecated APIs. Use of this flag may cause your app to fail to build when ' + 'deprecated APIs are removed.', + ); + + static const flavor = StringOptionDescriptor( + name: 'flavor', + help: + 'Build a custom app flavor as defined by platform-specific build setup.\n' + 'Supports the use of product flavors in Android Gradle scripts, and ' + 'the use of custom Xcode schemes.\n' + 'Overrides the value of the "default-flavor" entry in the flutter pubspec.', + ); + + static const codesign = FlagOptionDescriptor( + name: FlutterOptions.kCodesign, + defaultsTo: true, + help: 'Whether to code-sign XCFrameworks.', + ); + + static const codesignIdentity = StringOptionDescriptor( + name: FlutterOptions.kCodesignIdentity, + help: + 'The identity to use for code-signing XCFrameworks. If an identity is not provided and ' + '"${FlutterOptions.kCodesign}" is enabled, a code signing identity will be selected ' + "automatically from the Flutter app's Xcode project settings or Flutter config. To see " + 'a list of valid identities run "security find-identity -p codesigning -v".', + ); + + static const frontendServerStarterPath = StringOptionDescriptor( + name: FlutterOptions.kFrontendServerStarterPath, + verboseOnly: true, + help: + 'When this value is provided, the frontend server will be started ' + 'in JIT mode from the specified file, instead of from the AOT ' + 'snapshot shipped with the Dart SDK. The specified file can either ' + 'be a dart source file or a kernel file.\n' + 'This flag is not used by most normal users and is for backend ' + 'testing only.', + ); + + static const initializeFromDill = StringOptionDescriptor( + name: FlutterOptions.kInitializeFromDill, + help: + 'Initializes the resident compiler with a specific kernel file instead of ' + 'the default cached location.', + verboseOnly: true, + ); + + static const extraFrontEndOptions = MultiOptionDescriptor( + name: FlutterOptions.kExtraFrontEndOptions, + aliases: [kExtraFrontEndOptions], // supported for historical reasons + help: + 'A comma-separated list of additional command line arguments that will be passed directly to the Dart front end. ' + 'For example, "--${FlutterOptions.kExtraFrontEndOptions}=--enable-experiment=nonfunction-type-aliases".', + valueHelp: '--foo,--bar', + verboseOnly: true, + ); + + static const extraGenSnapshotOptions = MultiOptionDescriptor( + name: FlutterOptions.kExtraGenSnapshotOptions, + aliases: [kExtraGenSnapshotOptions], // supported for historical reasons + help: + 'A comma-separated list of additional command line arguments that will be passed directly to the Dart native compiler. ' + '(Requires the "--release", "--profile", or "--jit-release" flag.)', + valueHelp: '--foo,--bar', + verboseOnly: true, + ); + + static const assumeInitializeFromDillUpToDate = FlagOptionDescriptor( + name: FlutterOptions.kAssumeInitializeFromDillUpToDate, + help: + 'If set, assumes that the file passed in initialize-from-dill is up ' + 'to date and skip the check and potential invalidation of files.', + verboseOnly: true, + ); + + static const outputDill = StringOptionDescriptor( + name: 'output-dill', + verboseOnly: true, + help: 'Specify the path to frontend server output kernel file.', + ); + + static const fileSystemRoot = MultiOptionDescriptor( + name: FlutterOptions.kFileSystemRoot, + verboseOnly: true, + help: + 'Specify the path that is used as the root of a virtual file system ' + 'during compilation. The input file name should be specified as a URL ' + 'using the scheme given in "--${FlutterOptions.kFileSystemScheme}".\n' + 'Requires the "--output-dill" option to be explicitly specified.', + ); + + static const fileSystemScheme = DefaultedStringOptionDescriptor( + name: FlutterOptions.kFileSystemScheme, + defaultsTo: 'org-dartlang-root', + verboseOnly: true, + help: + 'Specify the scheme that is used for virtual file system used in ' + 'compilation. See also the "--${FlutterOptions.kFileSystemRoot}" option.', + ); + + static const nativeAssetsYamlFile = StringOptionDescriptor( + name: FlutterOptions.kNativeAssetsYamlFile, + verboseOnly: true, + help: + 'Initializes the resident compiler with a custom native assets ' + 'yaml file instead of the default cached location.', + ); +} + +/// A bundle encapsulating general options for building. +class BuildInfoOptionsBundle extends OptionBundle { + const BuildInfoOptionsBundle(); + + @override + List> get descriptors => const [ + BuildInfoOptions.trackWidgetCreation, + BuildInfoOptions.analyzeSize, + BuildInfoOptions.codeSizeDirectory, + BuildInfoOptions.obfuscate, + BuildInfoOptions.splitDebugInfo, + BuildInfoOptions.androidGradleDaemon, + BuildInfoOptions.androidProjectArg, + BuildInfoOptions.androidProjectCacheDir, + BuildInfoOptions.androidSkipBuildDependencyValidation, + BuildInfoOptions.performanceMeasurementFile, + BuildInfoOptions.flavor, + BuildInfoOptions.codesign, + BuildInfoOptions.frontendServerStarterPath, + BuildInfoOptions.initializeFromDill, + BuildInfoOptions.extraFrontEndOptions, + BuildInfoOptions.extraGenSnapshotOptions, + BuildInfoOptions.assumeInitializeFromDillUpToDate, + ]; +} + +/// A bundle encapsulating standard build mode flags (`--debug`, `--profile`, `--release`, `--jit-release`). +class BuildModeOptionsBundle extends OptionBundle { + const BuildModeOptionsBundle({ + this.defaultToRelease = true, + this.excludeDebug = false, + this.excludeRelease = false, + }) : assert(defaultToRelease || !excludeDebug), + assert(!defaultToRelease || !excludeRelease); + + final bool defaultToRelease; + final bool excludeDebug; + final bool excludeRelease; + + @override + void onRegister(FlutterCommand command) { + command.configureBuildModes( + defaultToRelease: defaultToRelease, + excludeDebug: excludeDebug, + excludeRelease: excludeRelease, + ); + } + + @override + List> get descriptors => >[ + if (!excludeDebug) CommonOptions.debugMode, + CommonOptions.profileMode, + if (!excludeRelease) ...const >[ + CommonOptions.releaseMode, + CommonOptions.jitReleaseMode, + ], + ]; +} + +/// A bundle encapsulating device discovery options (`--device-timeout`, `--device-connection`, and optionally `--device-user`). +class DeviceDiscoveryOptionsBundle extends OptionBundle { + const DeviceDiscoveryOptionsBundle({this.includeDeviceUser = false}); + + final bool includeDeviceUser; + + @override + List> get descriptors => >[ + CommonOptions.deviceTimeout, + CommonOptions.deviceConnection, + if (includeDeviceUser) CommonOptions.deviceUser, + ]; +} + +/// A bundle encapsulating virtual filesystem options (`--output-dill`, `--filesystem-root`, `--filesystem-scheme`). +class FilesystemOptionsBundle extends OptionBundle { + const FilesystemOptionsBundle(); + + @override + List> get descriptors => const >[ + BuildInfoOptions.outputDill, + BuildInfoOptions.fileSystemRoot, + BuildInfoOptions.fileSystemScheme, + ]; +} + +/// A bundle encapsulating Dart VM Service port and IPv6 options (`--vm-service-port`, `--device-vmservice-port`, `--host-vmservice-port`, and optionally `--ipv6`). +class VmServicePortOptionsBundle extends OptionBundle { + const VmServicePortOptionsBundle({this.includeIpv6 = true}); + + final bool includeIpv6; + + @override + void onRegister(FlutterCommand command) { + command.enableUsesPortOption(); + if (includeIpv6) { + command.enableUsesIpv6Flag(); + } + } + + @override + List> get descriptors => >[ + DebuggingOptionDescriptors.vmServicePort, + DebuggingOptionDescriptors.deviceVmservicePort, + DebuggingOptionDescriptors.hostVmservicePort, + if (includeIpv6) DebuggingOptionDescriptors.ipv6, + ]; +} + +/// A bundle encapsulating DDS and DevTools options (`--dds`, `--dds-port`, `--disable-dds`, `--devtools`, `--devtools-server-address`). +class DdsAndDevToolsOptionsBundle extends OptionBundle { + const DdsAndDevToolsOptionsBundle({ + this.includeDevTools = true, + this.includeEnableDevTools = true, + }); + + final bool includeDevTools; + final bool includeEnableDevTools; + + @override + List> get descriptors => >[ + DebuggingOptionDescriptors.ddsPort, + DebuggingOptionDescriptors.dds, + DebuggingOptionDescriptors.disableDds, + if (includeDevTools) ...>[ + if (includeEnableDevTools) DebuggingOptionDescriptors.enableDevTools, + DebuggingOptionDescriptors.devToolsServerAddress( + includeEnableDevTools: includeEnableDevTools, + ), + ], + ]; +} + +/// A bundle encapsulating resident compiler options (`--initialize-from-dill`, `--assume-initialize-from-dill-up-to-date`, `--native-assets-yaml-file`, and optional compiler flags). +class ResidentCompilerOptionsBundle extends OptionBundle { + const ResidentCompilerOptionsBundle({ + this.includeFrontendServerStarterPath = false, + this.includeExtraDartFlags = false, + }); + + final bool includeFrontendServerStarterPath; + final bool includeExtraDartFlags; + + @override + List> get descriptors => >[ + BuildInfoOptions.initializeFromDill, + BuildInfoOptions.assumeInitializeFromDillUpToDate, + BuildInfoOptions.nativeAssetsYamlFile, + if (includeFrontendServerStarterPath) BuildInfoOptions.frontendServerStarterPath, + if (includeExtraDartFlags) ...const >[ + BuildInfoOptions.extraFrontEndOptions, + BuildInfoOptions.extraGenSnapshotOptions, + ], + ]; +} + +/// A bundle encapsulating Impeller and Flutter GPU rendering engine flags. +class EngineRenderingFlagsBundle extends OptionBundle { + const EngineRenderingFlagsBundle({ + this.includeVulkanAndEmbedder = true, + this.includeTestFlag = false, + }); + + final bool includeVulkanAndEmbedder; + final bool includeTestFlag; + + @override + List> get descriptors => >[ + DebuggingOptionDescriptors.enableImpeller, + DebuggingOptionDescriptors.enableFlutterGpu, + DebuggingOptionDescriptors.enableHcpp, + if (includeVulkanAndEmbedder) ...const >[ + DebuggingOptionDescriptors.enableVulkanValidation, + DebuggingOptionDescriptors.enableEmbedderApi, + ], + if (includeTestFlag) DebuggingOptionDescriptors.testFlag, + ]; +} + +/// A bundle encapsulating general Dart compilation options. +class DartCompileOptionsBundle extends OptionBundle { + const DartCompileOptionsBundle(); + + @override + List> get descriptors => const [ + CommonOptions.dartDefines, + CommonOptions.dartDefineFromFile, + CommonOptions.enableExperiment, + ]; +} + +/// A bundle encapsulating basic build parameters (target, pub, build-number/name). +class CommonBuildOptionsBundle extends OptionBundle { + const CommonBuildOptionsBundle(); + + @override + void onRegister(FlutterCommand command) { + command.enableUsesTargetOption(); + command.enableUsesPubOption(); + } + + @override + List> get descriptors => const [ + CommonOptions.treeShakeIcons, + CommonOptions.target, + CommonOptions.pub, + CommonOptions.buildNumber, + CommonOptions.buildName, + ]; +} + +/// A bundle encapsulating Gradle-specific Android build options. +class AndroidGradleOptionsBundle extends OptionBundle { + const AndroidGradleOptionsBundle(); + + @override + List> get descriptors => const [ + BuildInfoOptions.androidGradleDaemon, + BuildInfoOptions.androidSkipBuildDependencyValidation, + BuildInfoOptions.androidProjectArg, + BuildInfoOptions.androidProjectCacheDir, + ]; +} + +/// A bundle encapsulating general options for Android builds. +class AndroidBuildOptionsBundle extends OptionBundle { + const AndroidBuildOptionsBundle(); + + static const _kAndroidTargetPlatforms = ['android-arm', 'android-arm64', 'android-x64']; + + static const targetPlatform = MultiOptionDescriptor( + name: 'target-platform', + allowed: _kAndroidTargetPlatforms, + help: 'The target platform for which the app is compiled.', + ); + + static const targetPlatformWithDefaults = MultiOptionDescriptor( + name: 'target-platform', + defaultsTo: _kAndroidTargetPlatforms, + allowed: _kAndroidTargetPlatforms, + help: 'The target platform for which the app is compiled.', + ); + + @override + List get subBundles => const [AndroidGradleOptionsBundle()]; + + @override + List> get descriptors => const [ + BuildInfoOptions.flavor, + BuildInfoOptions.shrink, + BuildInfoOptions.splitDebugInfo, + BuildInfoOptions.obfuscate, + BuildInfoOptions.extraFrontEndOptions, + BuildInfoOptions.extraGenSnapshotOptions, + BuildInfoOptions.performanceMeasurementFile, + BuildInfoOptions.analyzeSize, + BuildInfoOptions.codeSizeDirectory, + BuildInfoOptions.trackWidgetCreation, + BuildInfoOptions.ignoreDeprecation, + DebuggingOptionDescriptors.enableHcpp, + ]; +} + +/// A bundle encapsulating general options for Apple (iOS and macOS) builds. +class AppleBuildOptionsBundle extends OptionBundle { + const AppleBuildOptionsBundle(); + + static const FlagOptionDescriptor configOnly = BuildInfoOptions.configOnly; + + @override + List> get descriptors => const [ + BuildInfoOptions.flavor, + BuildInfoOptions.splitDebugInfo, + BuildInfoOptions.obfuscate, + BuildInfoOptions.extraFrontEndOptions, + BuildInfoOptions.extraGenSnapshotOptions, + BuildInfoOptions.performanceMeasurementFile, + BuildInfoOptions.analyzeSize, + BuildInfoOptions.codeSizeDirectory, + ]; +} + +/// A bundle encapsulating Darwin XCFramework code-signing options (`--codesign` and `--codesign-identity`). +class DarwinCodeSignXCFrameworksOptionsBundle extends OptionBundle { + const DarwinCodeSignXCFrameworksOptionsBundle(); + + @override + List> get descriptors => const [ + BuildInfoOptions.codesign, + BuildInfoOptions.codesignIdentity, + ]; +} + +/// A bundle encapsulating shared options for Darwin Add-to-App builds (`build ios-framework`, `build macos-framework`, and `build swift-package`). +class DarwinAddToAppOptionsBundle extends OptionBundle { + const DarwinAddToAppOptionsBundle(); + + static const staticFrameworks = FlagOptionDescriptor( + name: 'static', + help: + 'Build plugins as static frameworks. Link on, but do not embed these ' + 'frameworks in the existing Xcode project.', + ); + + @override + void onRegister(FlutterCommand command) { + command.enableUsesTargetOption(); + command.enableUsesPubOption(); + } + + @override + List get subBundles => const [ + DartCompileOptionsBundle(), + DarwinCodeSignXCFrameworksOptionsBundle(), + ]; + + @override + List> get descriptors => const [ + CommonOptions.treeShakeIcons, + CommonOptions.target, + CommonOptions.pub, + BuildInfoOptions.splitDebugInfo, + BuildInfoOptions.obfuscate, + BuildInfoOptions.extraFrontEndOptions, + BuildInfoOptions.extraGenSnapshotOptions, + staticFrameworks, + ]; +} + +/// A bundle encapsulating general options for desktop (`linux` and `windows`) builds. +class DesktopBuildOptionsBundle extends OptionBundle { + const DesktopBuildOptionsBundle(); + + static const FlagOptionDescriptor configOnly = BuildInfoOptions.configOnly; + + @override + List get subBundles => const [ + CommonBuildOptionsBundle(), + BuildModeOptionsBundle(), + DartCompileOptionsBundle(), + ]; + + @override + List> get descriptors => const >[ + BuildInfoOptions.flavor, + BuildInfoOptions.splitDebugInfo, + BuildInfoOptions.obfuscate, + BuildInfoOptions.extraFrontEndOptions, + BuildInfoOptions.extraGenSnapshotOptions, + BuildInfoOptions.performanceMeasurementFile, + BuildInfoOptions.analyzeSize, + BuildInfoOptions.codeSizeDirectory, + BuildInfoOptions.trackWidgetCreation, + configOnly, + ]; +} + +/// Typed option descriptors specific to `DebuggingOptions` and resident runners (`run`, `drive`, `test`). +abstract final class DebuggingOptionDescriptors { + static const enableImpeller = NullableFlagOptionDescriptor( + name: FlutterOptions.kEnableImpeller, + verboseOnly: true, + help: + 'Whether to enable the Impeller rendering engine. ' + 'Impeller is the default renderer on iOS. On Android, Impeller ' + 'is available but not the default. This flag will cause Impeller ' + 'to be used on Android. On other platforms, this flag will be ' + 'ignored.', + ); + + static const enableFlutterGpu = NullableFlagOptionDescriptor( + name: 'enable-flutter-gpu', + verboseOnly: true, + help: + 'Whether to enable the Flutter GPU API (https://api.flutter.dev/flutter/flutter_gpu/). ' + 'This feature is only supported with the Impeller rendering engine, ' + 'which can be enabled via the "--${FlutterOptions.kEnableImpeller}" ' + 'option.', + ); + + static const enableVulkanValidation = FlagOptionDescriptor( + name: 'enable-vulkan-validation', + verboseOnly: true, + help: + 'Enable vulkan validation on the Impeller rendering backend if ' + 'Vulkan is in use and the validation layers are available to the ' + 'application.', + ); + + static const enableEmbedderApi = FlagOptionDescriptor( + name: 'enable-embedder-api', + verboseOnly: true, + help: 'Whether to enable the experimental embedder API on iOS.', + ); + + static const enableHcpp = NullableFlagOptionDescriptor( + name: 'enable-hcpp', + verboseOnly: true, + help: + 'Enable the use of the HCPP platform view rendering mode on the Impeller rendering ' + 'backend. An explicit value takes priority over the EnableHcpp metadata in ' + 'AndroidManifest.xml: build commands write it into the manifest of the artifact they ' + 'produce, and "run", "test", and "drive" additionally apply it at launch. Without the ' + 'flag, the manifest decides, and if the manifest does not set it either, the ' + 'enable-hcpp feature flag does.', + ); + + static const testFlag = FlagOptionDescriptor( + name: 'test-flag', + verboseOnly: true, + help: 'No-op flag for testing purposes; use for testing flag priorities only.', + ); + + static const adbLogFiltering = FlagOptionDescriptor( + name: 'adb-log-filtering', + defaultsTo: true, + verboseOnly: true, + help: + 'Filter adb logs so that logs not emitted by the current flutter application are not ' + 'displayed.', + ); + + static const dds = FlagOptionDescriptor( + name: 'dds', + defaultsTo: true, + help: + 'Enable the Dart Developer Service (DDS).\n' + 'It may be necessary to disable this when attaching to an application with ' + 'an existing DDS instance (e.g., attaching to an application currently ' + 'connected to by "flutter run"), or when running certain tests.\n' + 'Disabling this feature may degrade IDE functionality if a DDS instance is ' + 'not already connected to the target application.', + ); + + static const ddsPort = DefaultedIntOptionDescriptor( + name: 'dds-port', + defaultsTo: 0, + help: + 'When this value is provided, the Dart Development Service (DDS) will be ' + 'bound to the provided port.\n' + 'Specifying port 0 (the default) will find a random free port.', + ); + + static const publishPort = FlagOptionDescriptor( + name: 'publish-port', + defaultsTo: true, + verboseOnly: true, + help: + 'Publish the VM service port over mDNS. Disable to prevent the ' + 'local network permission app dialog in debug and profile build modes (iOS devices only).', + ); + + static FlagOptionDescriptor publishPortOption({bool enabledByDefault = true}) { + if (enabledByDefault) { + return publishPort; + } + return const FlagOptionDescriptor( + name: 'publish-port', + verboseOnly: true, + help: + 'Publish the VM service port over mDNS. Disable to prevent the ' + 'local network permission app dialog in debug and profile build modes (iOS devices only).', + ); + } + + static const disableDds = FlagOptionDescriptor( + name: 'disable-dds', + verboseOnly: true, + help: + '(deprecated; use "--no-dds" instead) ' + 'Disable the Dart Developer Service (DDS).', + ); + + static const enableDevTools = FlagOptionDescriptor( + name: FlutterCommand.kEnableDevTools, + defaultsTo: true, + verboseOnly: true, + help: + 'Enable (or disable, with "--no-${FlutterCommand.kEnableDevTools}") the launching of the ' + 'Flutter DevTools debugger and profiler. ' + 'If "--no-${FlutterCommand.kEnableDevTools}" is specified, "--${FlutterCommand.kDevToolsServerAddress}" is ignored.', + ); + + static StringOptionDescriptor devToolsServerAddress({bool includeEnableDevTools = true}) { + final ignoredMessage = includeEnableDevTools + ? ' Ignored if "--no-${FlutterCommand.kEnableDevTools}" is specified.' + : ''; + return StringOptionDescriptor( + name: FlutterCommand.kDevToolsServerAddress, + verboseOnly: true, + help: + 'When this value is provided, the Flutter tool will not spin up a ' + 'new DevTools server instance, and will instead use the one provided ' + 'at the given address.$ignoredMessage', + ); + } + + static const devToolsServerAddressOption = StringOptionDescriptor( + name: FlutterCommand.kDevToolsServerAddress, + verboseOnly: true, + help: + 'When this value is provided, the Flutter tool will not spin up a ' + 'new DevTools server instance, and will instead use the one provided ' + 'at the given address.', + ); + + static const ipv6 = FlagOptionDescriptor( + name: FlutterCommand.ipv6Flag, + negatable: false, + verboseOnly: true, + help: + 'Binds to IPv6 localhost instead of IPv4 when the flutter tool ' + 'forwards the host port to a device port.', + ); + + static const traceStartup = FlagOptionDescriptor( + name: 'trace-startup', + negatable: false, + help: + 'Trace application startup, then exit, saving the trace to a file. ' + 'By default, this will be saved in the "build" directory. If the ' + 'FLUTTER_TEST_OUTPUTS_DIR environment variable is set, the file ' + 'will be written there instead.', + ); + + static const cacheStartupProfile = FlagOptionDescriptor( + name: 'cache-startup-profile', + help: + 'Caches the CPU profile collected before the first frame for startup ' + 'analysis.', + ); + + static const verboseSystemLogs = FlagOptionDescriptor( + name: 'verbose-system-logs', + negatable: false, + help: 'Include verbose logging from the Flutter engine.', + ); + + static const purgePersistentCache = FlagOptionDescriptor( + name: 'purge-persistent-cache', + negatable: false, + help: + 'Removes all existing persistent caches. This allows reproducing ' + 'shader compilation jank that normally only happens the first time ' + 'an app is run, or for reliable testing of compilation jank fixes ' + '(e.g. shader warm-up).', + ); + + static const route = StringOptionDescriptor( + name: 'route', + help: 'Which route to load when running the app.', + ); + + static const vmserviceOutFile = StringOptionDescriptor( + name: 'vmservice-out-file', + valueHelp: 'project/example/out.txt', + verboseOnly: true, + help: + 'A file to write the attached vmservice URL to after an ' + 'application is started.', + ); + + static const disableServiceAuthCodes = FlagOptionDescriptor( + name: 'disable-service-auth-codes', + negatable: false, + verboseOnly: true, + help: + '(deprecated) Allow connections to the VM service without using authentication codes. ' + '(Not recommended! This can open your device to remote code execution attacks!)', + ); + + static const disableServiceOriginCheck = FlagOptionDescriptor( + name: 'disable-service-origin-check', + negatable: false, + verboseOnly: true, + help: + 'Allow connections to the VM service from any origin. ' + '(Not recommended. This can open your device to remote code execution attacks.)', + ); + + static FlagOptionDescriptor startPaused({bool defaultsTo = false}) => FlagOptionDescriptor( + name: 'start-paused', + defaultsTo: defaultsTo, + help: 'Start in a paused mode and wait for a debugger to connect.', + ); + + static const startPausedOption = FlagOptionDescriptor( + name: 'start-paused', + help: 'Start in a paused mode and wait for a debugger to connect.', + ); + + static const dartFlags = StringOptionDescriptor( + name: 'dart-flags', + verboseOnly: true, + help: + 'Pass a list of comma separated flags to the Dart instance at ' + 'application startup. Flags passed through this option must be ' + 'present on the allowlist defined within the Flutter engine. If ' + 'a disallowed flag is encountered, the process will be ' + 'terminated immediately.\n\n' + 'This flag is not available on the stable channel and is only ' + 'applied in debug and profile modes. This option should only ' + 'be used for experiments and should not be used by typical users.', + ); + + static const endlessTraceBuffer = FlagOptionDescriptor( + name: 'endless-trace-buffer', + negatable: false, + help: + 'Enable tracing to an infinite buffer, instead of a ring buffer. ' + 'This is useful when recording large traces. To use an endless buffer to ' + 'record startup traces, combine this with "--trace-startup".', + ); + + static const traceSystrace = FlagOptionDescriptor( + name: 'trace-systrace', + negatable: false, + help: + 'Enable tracing to the system tracer. This is only useful on ' + 'platforms where such a tracer is available (Android, iOS, ' + 'macOS and Fuchsia).', + ); + + static const traceToFile = StringOptionDescriptor( + name: 'trace-to-file', + valueHelp: 'path/to/trace.binpb', + help: + 'Write the timeline trace to a file at the specified path. The ' + "file will be in Perfetto's proto format; it will be possible to " + "load the file into Perfetto's trace viewer.", + ); + + static const profileMicrotasks = FlagOptionDescriptor( + name: 'profile-microtasks', + negatable: false, + verboseOnly: true, + help: + 'Enable collection of information about each microtask. ' + 'Information about completed microtasks will be written to the ' + '"Microtask" timeline stream. Information about queued microtasks ' + 'will be accessible from Dart / Flutter DevTools.', + ); + + static const traceSkia = FlagOptionDescriptor( + name: 'trace-skia', + negatable: false, + help: + 'Enable tracing of Skia code. This is useful when debugging ' + 'the raster thread (formerly known as the GPU thread). ' + 'By default, Flutter will not log Skia code, as it introduces significant ' + 'overhead that may affect recorded performance metrics in a misleading way.', + ); + + static const traceAllowlist = StringOptionDescriptor( + name: 'trace-allowlist', + valueHelp: 'foo,bar', + verboseOnly: true, + help: + 'Filters out all trace events except those that are specified in ' + 'this comma separated list of allowed prefixes.', + ); + + static const traceSkiaAllowlist = StringOptionDescriptor( + name: 'trace-skia-allowlist', + valueHelp: 'skia.gpu,skia.shaders', + verboseOnly: true, + help: + 'Filters out all Skia trace events except those that are specified in ' + 'this comma separated list of allowed prefixes.', + ); + + static const enableDartProfiling = FlagOptionDescriptor( + name: 'enable-dart-profiling', + defaultsTo: true, + help: + 'Whether the Dart VM sampling CPU profiler is enabled. This flag ' + 'is only meaningful in debug and profile builds.', + ); + + static const profileStartup = FlagOptionDescriptor( + name: 'profile-startup', + negatable: false, + verboseOnly: true, + help: + 'Make the profiler discard new samples once the profiler sample ' + 'buffer is full. When this flag is not set, the profiler sample ' + 'buffer is used as a ring buffer, meaning that once it is full, ' + 'new samples start overwriting the oldest ones.', + ); + + static const enableSoftwareRendering = FlagOptionDescriptor( + name: 'enable-software-rendering', + negatable: false, + verboseOnly: true, + help: + '(deprecated) Enable rendering using the Skia software backend. ' + 'This is useful when testing Flutter on emulators. By default, ' + 'Flutter will attempt to either use OpenGL or Vulkan and fall back ' + 'to software when neither is available. This option is not supported ' + 'when using the Impeller rendering engine.', + ); + + static const skiaDeterministicRendering = FlagOptionDescriptor( + name: 'skia-deterministic-rendering', + negatable: false, + verboseOnly: true, + help: + '(deprecated) When combined with "--enable-software-rendering", this should provide completely ' + 'deterministic (i.e. reproducible) Skia rendering. This is useful for testing purposes ' + '(e.g. when comparing screenshots). This option is not supported ' + 'when using the Impeller rendering engine.', + ); + + static const dartEntrypointArgs = MultiOptionDescriptor( + name: 'dart-entrypoint-args', + abbr: 'a', + help: + 'Pass a list of arguments to the Dart entrypoint at application ' + 'startup. By default this is main(List args). Specify ' + 'this option multiple times each with one argument to pass ' + 'multiple arguments to the Dart entrypoint. Currently this is ' + 'only supported on desktop platforms.', + ); + + static const uninstallFirst = FlagOptionDescriptor( + name: 'uninstall-first', + verboseOnly: true, + help: + 'Uninstall previous versions of the app on the device ' + 'before reinstalling. Currently only supported on iOS.', + ); + + static const iosProfileDebugger = NullableFlagOptionDescriptor( + name: 'ios-profile-debugger', + negatable: false, + help: 'Whether to attach the LLDB debugger when running in profile mode on a physical iOS device. Only available with Xcode 26.', + ); + + static const useTestFonts = FlagOptionDescriptor( + name: 'use-test-fonts', + help: + 'Enable (and default to) the "Ahem" font. This is a special font ' + 'used in tests to remove any dependencies on the font metrics. It ' + 'is enabled when you use "flutter test". Set this flag when running ' + 'a test using "flutter run" for debugging purposes. This flag is ' + 'only available when running in debug mode.', + ); + + static const vmServicePort = StringOptionDescriptor( + name: FlutterCommand.vmServicePortOption, + verboseOnly: true, + help: + '(deprecated; use host-vmservice-port instead) ' + 'Listen to the given port for a Dart VM Service connection.\n' + 'Specifying port 0 (the default) will find a random free port.\n ' + 'if the Dart Development Service (DDS) is enabled, this will not be the port ' + 'of the VmService instance advertised on the command line.', + ); + + static const deviceVmservicePort = StringOptionDescriptor( + name: 'device-vmservice-port', + help: + 'Look for vmservice connections only from the specified port.\n' + 'Specifying port 0 (the default) will accept the first vmservice ' + 'discovered.', + ); + + static const hostVmservicePort = StringOptionDescriptor( + name: 'host-vmservice-port', + help: + 'When a device-side vmservice port is forwarded to a host-side ' + 'port, use this value as the host port.\nSpecifying port 0 ' + '(the default) will find a random free host port.', + ); + + static const fuchsiaTargetModel = DefaultedStringOptionDescriptor( + name: 'target-model', + defaultsTo: 'flutter', + allowed: ['flutter', 'flutter_runner'], + verboseOnly: true, + help: 'Target model that determines what core libraries are available.', + ); + + static const fuchsiaModule = StringOptionDescriptor( + name: 'module', + abbr: 'm', + valueHelp: 'module-name', + verboseOnly: true, + help: 'The name of the module (required if attaching to a fuchsia device).', + ); + + static const pidFile = StringOptionDescriptor( + name: 'pid-file', + help: + 'Specify a file to write the process ID to. ' + 'You can send SIGUSR1 to trigger a hot reload ' + 'and SIGUSR2 to trigger a hot restart. ' + 'The file is created when the signal handlers ' + 'are hooked and deleted when they are removed.', + ); + + static const reportReady = FlagOptionDescriptor( + name: 'report-ready', + verboseOnly: true, + help: + 'Print "ready" to the console after handling a keyboard command.\n' + 'This is primarily useful for tests and other automation, but consider ' + 'using "--machine" instead.', + ); + + static const projectRoot = StringOptionDescriptor( + name: 'project-root', + verboseOnly: true, + help: 'Specify the project root directory.', + ); + + static const build = FlagOptionDescriptor( + name: 'build', + defaultsTo: true, + verboseOnly: true, + help: + '(deprecated) If necessary, build the app before running. To use an existing app, ' + 'pass the "--${FlutterOptions.kUseApplicationBinary}" flag with an existing application artifact.', + ); +} diff --git a/packages/flutter_tools/lib/src/runner/options/option_bundle.dart b/packages/flutter_tools/lib/src/runner/options/option_bundle.dart new file mode 100644 index 00000000..d52c49f4 --- /dev/null +++ b/packages/flutter_tools/lib/src/runner/options/option_bundle.dart @@ -0,0 +1,43 @@ +// Copyright 2014 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 'package:args/args.dart'; + +import '../flutter_command.dart'; + +/// An aggregation of related [OptionDescriptor] instances that can be +/// registered and bound to a [FlutterCommand] as a cohesive domain unit. +abstract class OptionBundle { + const OptionBundle(); + + /// An optional visual section header displayed above this bundle's options + /// in `--help` usage output. + String? get title => null; + + /// The list of option descriptors contained within this bundle. + List> get descriptors => const >[]; + + /// Optional child bundles composed inside this bundle. + List get subBundles => const []; + + /// Optional lifecycle hook invoked when this bundle is registered on [command]. + void onRegister(FlutterCommand command) {} + + /// Registers all option descriptors and child bundles into [parser] and binds + /// to [command]. + void register(FlutterCommand command, ArgParser parser, {bool? verboseHelp, bool? hideOverride}) { + if (title != null) { + parser.addSeparator(title!); + } + onRegister(command); + parser.addDescriptors( + descriptors, + verboseHelp: verboseHelp ?? command.verboseHelp, + hideOverride: hideOverride, + ); + for (final OptionBundle subBundle in subBundles) { + subBundle.register(command, parser, verboseHelp: verboseHelp, hideOverride: hideOverride); + } + } +} diff --git a/packages/flutter_tools/lib/src/runner/options/option_descriptor.dart b/packages/flutter_tools/lib/src/runner/options/option_descriptor.dart new file mode 100644 index 00000000..5336bab7 --- /dev/null +++ b/packages/flutter_tools/lib/src/runner/options/option_descriptor.dart @@ -0,0 +1,657 @@ +// Copyright 2014 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 'package:args/args.dart'; + +import '../../base/utils.dart'; + +/// Defines the lookup scope for an [OptionDescriptor]. +enum OptionScope { + /// Look up only within the subcommand's local `ArgResults`. + local, + + /// Look up only within the root command runner's `ArgResults.globalResults`. + global, + + /// Check local `ArgResults` first, falling back to `globalResults`. + any, +} + +/// Registry mapping option names to their registered [OptionDescriptor] instances. +typedef ArgParserRegistry = Map>; + +// Uses an Expando (weak keys) rather than a top-level Map so that +// short-lived ArgParser instances created across tests and commands can be +// garbage-collected without leaking memory for the lifetime of the isolate. +final _parserRegistry = Expando('OptionDescriptorRegistry'); + +ArgParserRegistry _registryFor(ArgParser parser) => + _parserRegistry[parser] ??= >{}; + +/// A metadata-rich, type-safe descriptor for a command-line option or flag. +abstract class OptionDescriptor { + const OptionDescriptor({ + required this.name, + required this.help, + this.abbr, + this.valueHelp, + this.defaultsTo, + this.allowed, + this.allowedHelp, + this.scope = OptionScope.local, + this.hide = false, + this.verboseOnly = false, + }); + + /// The CLI option name (without leading dashes). + final String name; + + /// The help string displayed in usage output. + final String help; + + /// An optional single-character abbreviation. + final String? abbr; + + /// An optional label displayed in usage to represent the value. + final String? valueHelp; + + /// The default value if not specified on the command line. + final T? defaultsTo; + + /// An optional list of allowed values. + final List? allowed; + + /// An optional map of allowed values to their descriptions. + final Map? allowedHelp; + + /// The scope where this option's result is located. + final OptionScope scope; + + /// Whether to hide this option from usage output by default. + final bool hide; + + /// Whether to hide this option from usage output unless verbose help is requested. + final bool verboseOnly; + + /// The CLI flag representation (e.g., `--target`). + String get flag => '--$name'; + + /// Registers this option with [parser], maintaining descriptor identity per parser. + void addTo(ArgParser parser, {bool verboseHelp = false, bool? hideOverride}); + + /// Checks if this option was explicitly provided on the command line. + bool wasProvided(ArgResults? results, {ArgResults? globalResults}) { + final ArgResults? target = _resolveTargetResults(results, globalResults); + return target != null && target.options.contains(name) && target.wasParsed(name); + } + + /// Checks if this option is registered in [results]. + bool isRegistered(ArgResults? results, {ArgResults? globalResults}) { + final ArgResults? target = _resolveTargetResults(results, globalResults); + return target != null && target.options.contains(name); + } + + /// Checks if this option was explicitly parsed (alias for [wasProvided]). + bool wasParsed(ArgResults? results, {ArgResults? globalResults}) => + wasProvided(results, globalResults: globalResults); + + /// Returns the resolved value for this option. + T getValue(ArgResults? results, {ArgResults? globalResults}); + + ArgResults? _resolveTargetResults(ArgResults? results, ArgResults? globalResults) { + return switch (scope) { + .local => results, + .global => globalResults, + .any when results?.options.contains(name) == true && results!.wasParsed(name) => results, + .any => globalResults ?? results, + }; + } + + bool _computeEffectiveHide({required bool verboseHelp, bool? hideOverride}) => + hideOverride ?? (hide || (verboseOnly && !verboseHelp)); + + bool _isAlreadyRegistered(ArgParser parser) { + final ArgParserRegistry registry = _registryFor(parser); + if (parser.options.containsKey(name)) { + final OptionDescriptor? existing = registry[name]; + if (existing != null && identical(existing, this)) { + return true; + } + _throwConflictError(name, existing); + } + return false; + } + + void _recordRegistration(ArgParser parser) { + _registryFor(parser)[name] = this; + } + + void _throwConflictError(String name, OptionDescriptor? existing) { + final existingInfo = existing != null + ? '${existing.runtimeType} (help: "${existing.help}")' + : 'non-descriptor option'; + throw ArgumentError( + 'Conflicting option descriptor registered for "$name"!\n' + 'Existing: $existingInfo\n' + 'New: $runtimeType (help: "$help")', + ); + } +} + +/// A descriptor for single-value string options. +class StringOptionDescriptor extends OptionDescriptor { + const StringOptionDescriptor({ + required super.name, + required super.help, + super.abbr, + super.valueHelp, + super.defaultsTo, + this.aliases = const [], + super.allowed, + super.allowedHelp, + super.scope, + super.hide, + super.verboseOnly, + }); + + /// Alternative names for this option. + final List aliases; + + @override + void addTo(ArgParser parser, {bool verboseHelp = false, bool? hideOverride}) { + if (_isAlreadyRegistered(parser)) { + return; + } + + parser.addOption( + name, + abbr: abbr, + aliases: aliases, + help: help, + valueHelp: valueHelp, + defaultsTo: defaultsTo, + allowed: allowed, + allowedHelp: allowedHelp, + hide: _computeEffectiveHide(verboseHelp: verboseHelp, hideOverride: hideOverride), + ); + _recordRegistration(parser); + } + + @override + String? getValue(ArgResults? results, {ArgResults? globalResults}) { + final ArgResults? target = _resolveTargetResults(results, globalResults); + if (target != null && target.options.contains(name)) { + return target[name] as String?; + } + return defaultsTo; + } +} + +/// A descriptor for single-value string options that have a non-null default value. +class DefaultedStringOptionDescriptor extends OptionDescriptor { + const DefaultedStringOptionDescriptor({ + required super.name, + required super.help, + required String super.defaultsTo, + super.abbr, + super.valueHelp, + this.aliases = const [], + super.allowed, + super.allowedHelp, + super.scope, + super.hide, + super.verboseOnly, + }); + + /// Alternative names for this option. + final List aliases; + + @override + void addTo(ArgParser parser, {bool verboseHelp = false, bool? hideOverride}) { + if (_isAlreadyRegistered(parser)) { + return; + } + + parser.addOption( + name, + abbr: abbr, + aliases: aliases, + help: help, + valueHelp: valueHelp, + defaultsTo: defaultsTo, + allowed: allowed, + allowedHelp: allowedHelp, + hide: _computeEffectiveHide(verboseHelp: verboseHelp, hideOverride: hideOverride), + ); + _recordRegistration(parser); + } + + @override + String getValue(ArgResults? results, {ArgResults? globalResults}) { + final ArgResults? target = _resolveTargetResults(results, globalResults); + if (target != null && target.options.contains(name)) { + return (target[name] as String?) ?? defaultsTo!; + } + return defaultsTo!; + } +} + +/// A private base class providing shared integer parsing and registration infrastructure +/// for [IntOptionDescriptor] and [DefaultedIntOptionDescriptor]. +abstract class _IntOptionDescriptorBase extends OptionDescriptor { + const _IntOptionDescriptorBase({ + required super.name, + required super.help, + super.abbr, + super.valueHelp, + super.defaultsTo, + this.aliases = const [], + super.allowed, + super.allowedHelp, + super.scope, + super.hide, + super.verboseOnly, + }); + + /// Alternative names for this option. + final List aliases; + + @override + void addTo(ArgParser parser, {bool verboseHelp = false, bool? hideOverride}) { + if (_isAlreadyRegistered(parser)) { + return; + } + + parser.addOption( + name, + abbr: abbr, + aliases: aliases, + help: help, + valueHelp: valueHelp, + defaultsTo: defaultsTo?.toString(), + allowed: allowed, + allowedHelp: allowedHelp, + hide: _computeEffectiveHide(verboseHelp: verboseHelp, hideOverride: hideOverride), + ); + _recordRegistration(parser); + } + + /// Resolves and parses the integer option value from [results] or [globalResults], + /// throwing a [FormatException] if the provided string is not a valid integer. + int? _parseValue(ArgResults? results, {ArgResults? globalResults}) { + final ArgResults? target = _resolveTargetResults(results, globalResults); + if (target != null && target.options.contains(name)) { + final raw = target[name] as String?; + if (raw == null) { + return defaultsTo as int?; + } + final int? parsed = int.tryParse(raw); + if (parsed == null) { + throw FormatException('Invalid integer value "$raw" for "--$name".'); + } + return parsed; + } + return defaultsTo as int?; + } +} + +/// A descriptor for single-value integer options. +class IntOptionDescriptor extends _IntOptionDescriptorBase { + const IntOptionDescriptor({ + required super.name, + required super.help, + super.abbr, + super.valueHelp, + super.defaultsTo, + super.aliases, + super.allowed, + super.allowedHelp, + super.scope, + super.hide, + super.verboseOnly, + }); + + @override + int? getValue(ArgResults? results, {ArgResults? globalResults}) => + _parseValue(results, globalResults: globalResults); +} + +/// A descriptor for single-value integer options that have a non-null default value. +class DefaultedIntOptionDescriptor extends _IntOptionDescriptorBase { + const DefaultedIntOptionDescriptor({ + required super.name, + required super.help, + required int super.defaultsTo, + super.abbr, + super.valueHelp, + super.aliases, + super.allowed, + super.allowedHelp, + super.scope, + super.hide, + super.verboseOnly, + }); + + @override + int getValue(ArgResults? results, {ArgResults? globalResults}) => + _parseValue(results, globalResults: globalResults)!; +} + +/// A descriptor for boolean flags with a concrete default value. +class FlagOptionDescriptor extends OptionDescriptor { + const FlagOptionDescriptor({ + required super.name, + required super.help, + super.abbr, + super.defaultsTo = false, + this.negatable = true, + super.scope, + super.hide, + super.verboseOnly, + }); + + /// Whether the flag can be negated with `--no-`. + final bool negatable; + + @override + void addTo(ArgParser parser, {bool verboseHelp = false, bool? hideOverride}) { + if (_isAlreadyRegistered(parser)) { + return; + } + parser.addFlag( + name, + abbr: abbr, + help: help, + defaultsTo: defaultsTo ?? false, + negatable: negatable, + hide: _computeEffectiveHide(verboseHelp: verboseHelp, hideOverride: hideOverride), + ); + _recordRegistration(parser); + } + + @override + bool getValue(ArgResults? results, {ArgResults? globalResults}) { + final ArgResults? target = _resolveTargetResults(results, globalResults); + if (target != null && target.options.contains(name)) { + return (target[name] as bool?) ?? defaultsTo ?? false; + } + return defaultsTo ?? false; + } +} + +/// A descriptor for tri-state boolean flags without a default value. +/// +/// When omitted from the command line, [getValue] returns `null`. +class NullableFlagOptionDescriptor extends OptionDescriptor { + const NullableFlagOptionDescriptor({ + required super.name, + required super.help, + super.abbr, + this.negatable = true, + super.scope, + super.hide, + super.verboseOnly, + }) : super(defaultsTo: null); + + /// Whether the flag can be negated with `--no-`. + final bool negatable; + + @override + void addTo(ArgParser parser, {bool verboseHelp = false, bool? hideOverride}) { + if (_isAlreadyRegistered(parser)) { + return; + } + parser.addFlag( + name, + abbr: abbr, + help: help, + defaultsTo: null, + negatable: negatable, + hide: _computeEffectiveHide(verboseHelp: verboseHelp, hideOverride: hideOverride), + ); + _recordRegistration(parser); + } + + @override + bool? getValue(ArgResults? results, {ArgResults? globalResults}) { + final ArgResults? target = _resolveTargetResults(results, globalResults); + if (target != null && target.options.contains(name)) { + return target[name] as bool?; + } + return defaultsTo; + } +} + +/// A descriptor for multi-value options (passed multiple times or comma-separated). +class MultiOptionDescriptor extends OptionDescriptor> { + const MultiOptionDescriptor({ + required super.name, + required super.help, + super.abbr, + super.valueHelp, + this.splitCommas = true, + this.aliases = const [], + super.defaultsTo = const [], + super.allowed, + super.allowedHelp, + super.scope, + super.hide, + super.verboseOnly, + }); + + /// Whether values containing commas are split into multiple entries. + final bool splitCommas; + + /// Alternative names for this option. + final List aliases; + + @override + void addTo(ArgParser parser, {bool verboseHelp = false, bool? hideOverride}) { + if (_isAlreadyRegistered(parser)) { + return; + } + parser.addMultiOption( + name, + abbr: abbr, + aliases: aliases, + help: help, + valueHelp: valueHelp, + defaultsTo: defaultsTo, + splitCommas: splitCommas, + allowed: allowed, + allowedHelp: allowedHelp, + hide: _computeEffectiveHide(verboseHelp: verboseHelp, hideOverride: hideOverride), + ); + _recordRegistration(parser); + } + + @override + List getValue(ArgResults? results, {ArgResults? globalResults}) { + final ArgResults? target = _resolveTargetResults(results, globalResults); + if (target != null && target.options.contains(name)) { + return (target[name] as List?)?.cast() ?? const []; + } + return defaultsTo ?? const []; + } +} + +/// A descriptor for enum-based options that automatically validates allowed values +/// and parses CLI string arguments directly into typed enum values [T]. +/// A private base class providing shared enum parsing and registration infrastructure +/// for [EnumOptionDescriptor] and [DefaultedEnumOptionDescriptor]. +abstract class _EnumOptionDescriptorBase extends OptionDescriptor { + const _EnumOptionDescriptorBase({ + required super.name, + required super.help, + required this.values, + this.nameMapper, + this.valueParser, + super.abbr, + super.valueHelp, + super.defaultsTo, + this.aliases = const [], + super.allowedHelp, + super.scope, + super.hide, + super.verboseOnly, + }); + + /// The list of enum values allowed for this option. + final List values; + + /// Optional function to map an enum value to its CLI string representation. + /// Defaults to `value.cliName` if [T] is a [CliEnum], otherwise `value.name`. + final String Function(T value)? nameMapper; + + /// Optional function to parse a string into an enum value. + /// Defaults to matching against [nameMapper], `cliName`, or `value.name`. + final T? Function(String name)? valueParser; + + /// Alternative names for this option. + final List aliases; + + String _formatName(T value) { + if (nameMapper != null) { + return nameMapper!(value); + } + if (value is CliEnum) { + return (value as CliEnum).cliName; + } + return value.name; + } + + @override + List get allowed => values.map(_formatName).toList(); + + @override + void addTo(ArgParser parser, {bool verboseHelp = false, bool? hideOverride}) { + if (_isAlreadyRegistered(parser)) { + return; + } + + final String? defaultString = defaultsTo != null ? _formatName(defaultsTo! as T) : null; + + Map? effectiveAllowedHelp = allowedHelp; + if (effectiveAllowedHelp == null && values.isNotEmpty && values.first is CliEnum) { + effectiveAllowedHelp = { + for (final T val in values) _formatName(val): (val as CliEnum).helpText, + }; + } + + parser.addOption( + name, + abbr: abbr, + aliases: aliases, + help: help, + valueHelp: valueHelp, + defaultsTo: defaultString, + allowed: allowed, + allowedHelp: effectiveAllowedHelp, + hide: _computeEffectiveHide(verboseHelp: verboseHelp, hideOverride: hideOverride), + ); + _recordRegistration(parser); + } +} + +/// A descriptor for enum-based options that automatically validates allowed values +/// and parses CLI string arguments directly into typed enum values [T]. +class EnumOptionDescriptor extends _EnumOptionDescriptorBase { + const EnumOptionDescriptor({ + required super.name, + required super.help, + required super.values, + super.nameMapper, + super.valueParser, + super.abbr, + super.valueHelp, + super.defaultsTo, + super.aliases, + super.allowedHelp, + super.scope, + super.hide, + super.verboseOnly, + }); + + @override + T? getValue(ArgResults? results, {ArgResults? globalResults}) { + final ArgResults? target = _resolveTargetResults(results, globalResults); + if (target != null && target.options.contains(name)) { + final raw = target[name] as String?; + if (raw == null) { + return defaultsTo; + } + if (valueParser != null) { + return valueParser!(raw) ?? defaultsTo; + } + for (final T val in values) { + if (_formatName(val) == raw || val.name == raw) { + return val; + } + } + } + return defaultsTo; + } +} + +/// A descriptor for enum-based options that have a non-null default value. +class DefaultedEnumOptionDescriptor extends _EnumOptionDescriptorBase { + const DefaultedEnumOptionDescriptor({ + required super.name, + required super.help, + required super.values, + required T super.defaultsTo, + super.nameMapper, + super.valueParser, + super.abbr, + super.valueHelp, + super.aliases, + super.allowedHelp, + super.scope, + super.hide, + super.verboseOnly, + }); + + @override + T getValue(ArgResults? results, {ArgResults? globalResults}) { + final ArgResults? target = _resolveTargetResults(results, globalResults); + if (target != null && target.options.contains(name)) { + final raw = target[name] as String?; + if (raw == null) { + return defaultsTo!; + } + if (valueParser != null) { + return valueParser!(raw) ?? defaultsTo!; + } + for (final T val in values) { + if (_formatName(val) == raw || val.name == raw) { + return val; + } + } + } + return defaultsTo!; + } +} + +/// Extension on [ArgParser] for registering [OptionDescriptor] instances directly. +extension ArgParserDescriptorExtension on ArgParser { + /// Registers [descriptor] with this parser. + void addDescriptor( + OptionDescriptor descriptor, { + bool verboseHelp = false, + bool? hideOverride, + }) { + descriptor.addTo(this, verboseHelp: verboseHelp, hideOverride: hideOverride); + } + + /// Registers multiple [descriptors] with this parser. + void addDescriptors( + Iterable> descriptors, { + bool verboseHelp = false, + bool? hideOverride, + }) { + for (final descriptor in descriptors) { + descriptor.addTo(this, verboseHelp: verboseHelp, hideOverride: hideOverride); + } + } +} diff --git a/packages/flutter_tools/lib/src/runner/options/safe_arg_results.dart b/packages/flutter_tools/lib/src/runner/options/safe_arg_results.dart new file mode 100644 index 00000000..febf85cd --- /dev/null +++ b/packages/flutter_tools/lib/src/runner/options/safe_arg_results.dart @@ -0,0 +1,30 @@ +// Copyright 2014 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 '../../base/common.dart'; +import '../flutter_command.dart'; + +/// Type-safe argument extraction extension for [FlutterCommand]. +extension SafeArgResults on FlutterCommand { + /// Returns the resolved value for [descriptor], falling back to its default value. + T getValue(OptionDescriptor descriptor) { + try { + return descriptor.getValue(argResults, globalResults: globalResults); + } on FormatException catch (e) { + throwToolExit(e.message); + } + } + + /// Returns whether [descriptor] was explicitly provided on the command line. + bool wasProvided(OptionDescriptor descriptor) => + descriptor.wasProvided(argResults, globalResults: globalResults); + + /// Checks if this option was explicitly parsed (alias for [wasProvided]). + bool wasParsed(OptionDescriptor descriptor) => + descriptor.wasProvided(argResults, globalResults: globalResults); + + /// Returns whether [descriptor] is registered on this command (or globally). + bool hasOption(OptionDescriptor descriptor) => + descriptor.isRegistered(argResults, globalResults: globalResults); +} diff --git a/packages/flutter_tools/lib/src/runner/target_devices.dart b/packages/flutter_tools/lib/src/runner/target_devices.dart index 235a3413..5b007c19 100644 --- a/packages/flutter_tools/lib/src/runner/target_devices.dart +++ b/packages/flutter_tools/lib/src/runner/target_devices.dart @@ -6,10 +6,11 @@ import 'package:meta/meta.dart'; import '../base/common.dart'; import '../base/logger.dart'; -import '../base/platform.dart'; import '../base/terminal.dart'; +import '../base/user_messages.dart'; +import '../context/tool_context.dart'; import '../device.dart'; -import '../globals.dart' as globals; +import '../doctor.dart'; import '../ios/devices.dart'; const _checkingForWirelessDevicesMessage = 'Checking for wireless devices...'; @@ -24,6 +25,8 @@ const _wirelesslyConnectedDevicesMessage = 'Wirelessly connected devices:'; String _chooseDeviceOptionMessage(int option, String name, String deviceId) => '[$option]: $name ($deviceId)'; +String _skipPromptWithDeviceIdMessage(String deviceId) => + 'To skip this prompt in the future, pass the device id to the "-d" flag, e.g. "-d $deviceId".'; String _foundMultipleSpecifiedDevicesMessage(String deviceId) => 'Found multiple devices with name or id matching $deviceId:'; String _foundSpecifiedDevicesMessage(int count, String deviceId) => @@ -46,36 +49,43 @@ String flutterSpecifiedDeviceUnpaired(String deviceName) => /// a flutter application on. class TargetDevices { factory TargetDevices({ - required Platform platform, required DeviceManager deviceManager, - required Logger logger, + required Doctor doctor, + required ToolContext toolContext, DeviceConnectionInterface? deviceConnectionInterface, }) { - if (platform.isMacOS) { + if (toolContext.platform.isMacOS) { return TargetDevicesWithExtendedWirelessDeviceDiscovery( deviceManager: deviceManager, - logger: logger, + doctor: doctor, + toolContext: toolContext, deviceConnectionInterface: deviceConnectionInterface, ); } return TargetDevices._private( deviceManager: deviceManager, - logger: logger, + doctor: doctor, + toolContext: toolContext, deviceConnectionInterface: deviceConnectionInterface, ); } TargetDevices._private({ - required DeviceManager deviceManager, - required Logger logger, - required this.deviceConnectionInterface, - }) : _deviceManager = deviceManager, - _logger = logger; + required this._deviceManager, + required this._doctor, + required this._toolContext, + this.deviceConnectionInterface, + }); final DeviceManager _deviceManager; - final Logger _logger; + final Doctor _doctor; + final ToolContext _toolContext; final DeviceConnectionInterface? deviceConnectionInterface; + Logger get _logger => _toolContext.logger; + AnsiTerminal get _terminal => _toolContext.terminal; + UserMessages get _userMessages => _toolContext.userMessages; + bool get _includeAttachedDevices => deviceConnectionInterface == null || deviceConnectionInterface == DeviceConnectionInterface.attached; @@ -153,8 +163,8 @@ class TargetDevices { Duration? deviceDiscoveryTimeout, bool includeDevicesUnsupportedByProject = false, }) async { - if (!globals.doctor!.canLaunchAnything) { - _logger.printError(globals.userMessages.flutterNoDevelopmentDevice); + if (!_doctor.canLaunchAnything) { + _logger.printError(_userMessages.flutterNoDevelopmentDevice); return null; } @@ -215,7 +225,7 @@ class TargetDevices { _logger.printStatus( _deviceManager.hasSpecifiedAllDevices ? _noDevicesFoundMessage - : globals.userMessages.flutterNoSupportedDevices, + : _userMessages.flutterNoSupportedDevices, ); await _printUnsupportedDevice(unsupportedDevices); return null; @@ -243,7 +253,7 @@ class TargetDevices { return [ephemeralDevice]; } - if (canPrompt && globals.terminal.stdinHasTerminal) { + if (canPrompt && _terminal.stdinHasTerminal) { return _selectFromMultipleDevices(attachedDevices, wirelessDevices); } else { return _printMultipleDevices(attachedDevices, wirelessDevices); @@ -274,7 +284,7 @@ class TargetDevices { supportFilter: DeviceDiscoverySupportFilter.excludeDevicesUnsupportedByFlutter(), ); - _logger.printStatus(globals.userMessages.flutterSpecifyDeviceWithAllOption); + _logger.printStatus(_userMessages.flutterSpecifyDeviceWithAllOption); _logger.printStatus(''); } @@ -327,20 +337,16 @@ class TargetDevices { Future _printUnsupportedDevice(List unsupportedDevices) async { if (unsupportedDevices.isNotEmpty) { - final result = StringBuffer(); - result.writeln(); - result.writeln(_foundButUnsupportedDevicesMessage); - result.writeAll( - (await Device.descriptions(unsupportedDevices)).map((String desc) => desc).toList(), - '\n', - ); - result.writeln(); - result.writeln( - globals.userMessages.flutterMissPlatformProjects( - Device.devicesPlatformTypes(unsupportedDevices), - ), + final String descriptions = (await Device.descriptions(unsupportedDevices)).join('\n'); + final String platformMessage = _userMessages.flutterMissPlatformProjects( + Device.devicesPlatformTypes(unsupportedDevices), ); - _logger.printStatus(result.toString(), newline: false); + _logger.printStatus(''' + +$_foundButUnsupportedDevicesMessage +$descriptions +$platformMessage +''', newline: false); } } @@ -350,7 +356,9 @@ class TargetDevices { if (userInput.toLowerCase() == 'q') { throwToolExit(''); } - return devices[int.parse(userInput) - 1]; + final Device chosenDevice = devices[int.parse(userInput) - 1]; + _logger.printStatus(_skipPromptWithDeviceIdMessage(chosenDevice.id)); + return chosenDevice; } void _displayDeviceOptions(List devices) { @@ -363,14 +371,10 @@ class TargetDevices { Future _readUserInput(int deviceCount) async { if (deviceCount >= 10) { - return _readDeviceChoiceLine( - terminal: globals.terminal, - logger: _logger, - deviceCount: deviceCount, - ); + return _readDeviceChoiceLine(deviceCount: deviceCount, logger: _logger, terminal: _terminal); } - globals.terminal.usesTerminalUi = true; - final String result = await globals.terminal.promptForCharInput( + _terminal.usesTerminalUi = true; + final String result = await _terminal.promptForCharInput( [for (int i = 0; i < deviceCount; i++) '${i + 1}', 'q', 'Q'], displayAcceptedCharacters: false, logger: _logger, @@ -384,7 +388,8 @@ class TargetDevices { class TargetDevicesWithExtendedWirelessDeviceDiscovery extends TargetDevices { TargetDevicesWithExtendedWirelessDeviceDiscovery({ required super.deviceManager, - required super.logger, + required super.doctor, + required super.toolContext, super.deviceConnectionInterface, }) : super._private(); @@ -393,8 +398,10 @@ class TargetDevicesWithExtendedWirelessDeviceDiscovery extends TargetDevices { @visibleForTesting bool waitForWirelessBeforeInput = false; + late final _deviceSelection = TargetDeviceSelection(_logger, terminal: _terminal); + @visibleForTesting - late final deviceSelection = TargetDeviceSelection(_logger); + TargetDeviceSelection get deviceSelection => _deviceSelection; @override void startExtendedWirelessDeviceDiscovery({Duration? deviceDiscoveryTimeout}) { @@ -473,8 +480,8 @@ class TargetDevicesWithExtendedWirelessDeviceDiscovery extends TargetDevices { bool includeDevicesUnsupportedByProject = false, }) async { try { - if (!globals.doctor!.canLaunchAnything) { - _logger.printError(globals.userMessages.flutterNoDevelopmentDevice); + if (!_doctor.canLaunchAnything) { + _logger.printError(_userMessages.flutterNoDevelopmentDevice); return null; } @@ -642,7 +649,7 @@ class TargetDevicesWithExtendedWirelessDeviceDiscovery extends TargetDevices { return [ephemeralDevice]; } - if (!canPrompt || !globals.terminal.stdinHasTerminal || !_logger.supportsColor) { + if (!canPrompt || !_terminal.stdinHasTerminal || !_logger.supportsColor) { _logger.printStatus(_checkingForWirelessDevicesMessage); final List wirelessDevices = await futureWirelessDevices; if (attachedDevices.length + wirelessDevices.length == 1) { @@ -651,7 +658,7 @@ class TargetDevicesWithExtendedWirelessDeviceDiscovery extends TargetDevices { _logger.printStatus(''); // If the terminal has stdin but does not support color/ANSI (which is // needed to clear lines), fallback to standard selection of device. - if (canPrompt && globals.terminal.stdinHasTerminal && !_logger.supportsColor) { + if (canPrompt && _terminal.stdinHasTerminal && !_logger.supportsColor) { return _handleMultipleDevices(attachedDevices, wirelessDevices, canPrompt: canPrompt); } // If terminal does not have stdin, print out device list. @@ -734,6 +741,11 @@ class TargetDevicesWithExtendedWirelessDeviceDiscovery extends TargetDevices { // Wait for user to select a device. chosenDevice = await futureChosenDevice; + // The hint is printed only after the device is chosen, so that it does + // not interfere with the lines cleared and reprinted once wireless + // devices have loaded. + _logger.printStatus(_skipPromptWithDeviceIdMessage(chosenDevice.id)); + // Update the [DeviceManager.specifiedDeviceId] so that the user will not // be prompted again. _deviceManager.specifiedDeviceId = chosenDevice.id; @@ -764,7 +776,7 @@ class TargetDevicesWithExtendedWirelessDeviceDiscovery extends TargetDevices { /// Clear [numLinesToClear] lines from terminal. Print message and list of /// wireless devices. Future _printWirelessDevices(List wirelessDevices, int numLinesToClear) async { - _logger.printStatus(globals.terminal.clearLines(numLinesToClear), newline: false); + _logger.printStatus(_terminal.clearLines(numLinesToClear), newline: false); _logger.printStatus(''); if (wirelessDevices.isEmpty) { _logger.printStatus(_noWirelessDevicesFoundMessage); @@ -777,10 +789,11 @@ class TargetDevicesWithExtendedWirelessDeviceDiscovery extends TargetDevices { @visibleForTesting class TargetDeviceSelection { - TargetDeviceSelection(this._logger); + TargetDeviceSelection(this._logger, {required this._terminal}); List devices = []; final Logger _logger; + final AnsiTerminal _terminal; int invalidAttempts = 0; /// Prompt user to select a device and wait until they select a valid device. @@ -812,9 +825,9 @@ class TargetDeviceSelection { Future readUserInput() async { if (devices.length >= 10) { return _readDeviceChoiceLine( - terminal: globals.terminal, - logger: _logger, deviceCount: devices.length, + logger: _logger, + terminal: _terminal, onInvalidInput: () { invalidAttempts++; }, @@ -822,24 +835,24 @@ class TargetDeviceSelection { } final pattern = RegExp(r'\d+$|q', caseSensitive: false); String? choice; - globals.terminal.singleCharMode = true; + _terminal.singleCharMode = true; while (choice == null || choice.length > 1 || !pattern.hasMatch(choice)) { _logger.printStatus(_chooseOneMessage, emphasis: true, newline: false); // prompt ends with ': ' _logger.printStatus(': ', emphasis: true, newline: false); - choice = (await globals.terminal.keystrokes.first).trim(); + choice = (await _terminal.keystrokes.first).trim(); _logger.printStatus(choice); invalidAttempts++; } - globals.terminal.singleCharMode = false; + _terminal.singleCharMode = false; return choice; } } Future _readDeviceChoiceLine({ - required Terminal terminal, - required Logger logger, required int deviceCount, + required Logger logger, + required Terminal terminal, void Function()? onInvalidInput, }) async { while (true) { diff --git a/packages/flutter_tools/lib/src/template.dart b/packages/flutter_tools/lib/src/template.dart index 9f83b0b7..de5b9aa3 100644 --- a/packages/flutter_tools/lib/src/template.dart +++ b/packages/flutter_tools/lib/src/template.dart @@ -106,12 +106,10 @@ class Template { List templateSources, this.imageSourceDirectories, { required FileSystem fileSystem, - required Logger logger, - required TemplateRenderer templateRenderer, + required this._logger, + required this._templateRenderer, required Set? templateManifest, }) : _fileSystem = fileSystem, - _logger = logger, - _templateRenderer = templateRenderer, _templateManifest = templateManifest ?? {} { for (final sourceDirectory in templateSources) { if (!sourceDirectory.existsSync()) { @@ -140,11 +138,33 @@ class Template { // If '.tmpl' appears anywhere within the path of this entity, it is // a candidate for rendering. This catches cases where the folder // itself is a template. + // When multiple template sources are provided, later directories take + // precedence and overwrite matching relative destination paths from + // base dependencies. _templateFilePaths[relativePath] = fileSystem.path.absolute(entity.path); } } } + /// Creates a [Template] by resolving one or more source directories. + static Future