From f85f6b62b6db2a8725cafa75360ec960d581a809 Mon Sep 17 00:00:00 2001 From: Matan Lurey Date: Tue, 15 Jul 2025 16:42:09 -0700 Subject: [PATCH 01/23] [3.35] Create `release-candidate-branch.version` (#172191) Towards https://github.com/flutter/flutter/issues/172014. --- bin/internal/release-candidate-branch.version | 1 + 1 file changed, 1 insertion(+) create mode 100644 bin/internal/release-candidate-branch.version diff --git a/bin/internal/release-candidate-branch.version b/bin/internal/release-candidate-branch.version new file mode 100644 index 0000000000000..d7548344de90e --- /dev/null +++ b/bin/internal/release-candidate-branch.version @@ -0,0 +1 @@ +flutter-3.35-candidate.0 From d86b4e9fd56486284d99440cd6018257d5f39fe4 Mon Sep 17 00:00:00 2001 From: Matan Lurey Date: Wed, 16 Jul 2025 12:49:11 -0700 Subject: [PATCH 02/23] [3.35] Update `engine.version` (+CPs Use `release-*.version`) (#172237) Cherrypick's https://github.com/flutter/flutter/pull/172236 into the 3.35 release branch _and_ tests it live by setting `engine.version` in this PR. --- bin/internal/engine.version | 1 + bin/internal/last_engine_commit.ps1 | 52 ++++++++++----------- bin/internal/last_engine_commit.sh | 26 ++++------- dev/tools/test/last_engine_commit_test.dart | 19 +++----- 4 files changed, 40 insertions(+), 58 deletions(-) create mode 100644 bin/internal/engine.version diff --git a/bin/internal/engine.version b/bin/internal/engine.version new file mode 100644 index 0000000000000..a62065c38ce0d --- /dev/null +++ b/bin/internal/engine.version @@ -0,0 +1 @@ +f85f6b62b6db2a8725cafa75360ec960d581a809 diff --git a/bin/internal/last_engine_commit.ps1 b/bin/internal/last_engine_commit.ps1 index 0ec8849fb6aca..7ecac6b5f4b98 100644 --- a/bin/internal/last_engine_commit.ps1 +++ b/bin/internal/last_engine_commit.ps1 @@ -5,7 +5,7 @@ # Based on the current repository state, writes on stdout the last commit in the # git tree that edited either `DEPS` or any file in the `engine/` sub-folder, # which is used to ensure `bin/internal/engine.version` is set correctly. - +# # ---------------------------------- NOTE ---------------------------------- # # # Please keep the logic in this file consistent with the logic in the @@ -20,52 +20,48 @@ # # -------------------------------------------------------------------------- # -$ErrorActionPreference = "Stop" +$ErrorActionPreference = "Stop" # Equivalent to 'set -e' in bash $progName = Split-Path -parent $MyInvocation.MyCommand.Definition $flutterRoot = (Get-Item $progName).parent.parent.FullName $gitToplevel = (git rev-parse --show-toplevel).Trim() -# 1. Determine when we diverged from master. -$MERGE_BASE_COMMIT = "" + +$Path1 = Join-Path $gitToplevel "bin" +$Path2 = Join-Path $Path1 "internal" +$RELEASE_CANDIDATE_VERSION_PATH = Join-Path $Path2 "release-candidate-branch.version" + +# 1. Determine the reference commit: the last commit that changed +# 'bin/internal/release-candidate-branch.version'. +# This serves as the starting point for evaluating changes on the current branch. +$REFERENCE_COMMIT = "" try { - $MERGE_BASE_COMMIT = (git merge-base HEAD master).Trim() + $REFERENCE_COMMIT = (git log -1 --pretty=format:%H -- "$RELEASE_CANDIDATE_VERSION_PATH" -ErrorAction Stop).Trim() } catch { - # If git merge-base fails (e.g., master not found, no common history), - # $MERGE_BASE_COMMIT will remain empty. + # If git log fails (e.g., file not found or no history), $REFERENCE_COMMIT will remain empty. } -# If we did not find a merge-base, fail -if ([string]::IsNullOrEmpty($MERGE_BASE_COMMIT)) { +# If we did not find this reference commit, fail. +if ([string]::IsNullOrEmpty($REFERENCE_COMMIT)) { Write-Error "Error: Could not determine a suitable engine commit." -ErrorAction Stop - Write-Error "Current branch: $(git rev-parse --abbrev-ref HEAD).Trim()" -ErrorAction Stop - Write-Error "Expected a different branch, from 'master', or a 'master' branch that exists and has history." -ErrorAction Stop + Write-Error "Current branch: $((git rev-parse --abbrev-ref HEAD).Trim())" -ErrorAction Stop + Write-Error "No file $RELEASE_CANDIDATE_VERSION_PATH found, or it has no history." -ErrorAction Stop exit 1 } -# 2. Define and search history range to search within (unique to changes on this branch). -$HISTORY_RANGE = "$MERGE_BASE_COMMIT..HEAD" +# 2. Define the history range to search within: commits reachable from HEAD +# but not from the REFERENCE_COMMIT. This focuses the search on commits +# *unique to the current branch* since that file was last changed. +$HISTORY_RANGE = "$REFERENCE_COMMIT..HEAD" $DEPS_PATH = Join-Path $gitToplevel "DEPS" $ENGINE_PATH = Join-Path $gitToplevel "engine" $ENGINE_COMMIT = (git log -1 --pretty=format:%H --ancestry-path $HISTORY_RANGE -- "$DEPS_PATH" "$ENGINE_PATH") -# 3. If no engine-related commit was found within the current branch's history, fallback to the first commit on this branch. +# 3. If no engine-related commit was found within the current branch's history, +# fallback to the REFERENCE_COMMIT itself. if ([string]::IsNullOrEmpty($ENGINE_COMMIT)) { - # Find the oldest commit on HEAD that is *not* reachable from MERGE_BASE_COMMIT. - # This is the first commit *on this branch* after it diverged from 'master'. - $ENGINE_COMMIT = (git log --pretty=format:%H --reverse --ancestry-path "$MERGE_BASE_COMMIT..HEAD" | Select-Object -First 1).Trim() - - # Final check: If even this fallback fails (which would be highly unusual if MERGE_BASE_COMMIT was found), - # then something is truly wrong. - if ([string]::IsNullOrEmpty($ENGINE_COMMIT)) { - Write-Error "Error: Unexpected state. MERGE_BASE_COMMIT was found ($MERGE_BASE_COMMIT), but no commits found on current branch after it." -ErrorAction Stop - Write-Error "Current branch: $((git rev-parse --abbrev-ref HEAD).Trim())" -ErrorAction Stop - Write-Error "History range searched for fallback: $HISTORY_RANGE" -ErrorAction Stop - Write-Error "All commits on current branch (for debug):" -ErrorAction Stop - (git log --pretty=format:%H) | Write-Error -ErrorAction Stop - exit 1 - } + $ENGINE_COMMIT = $REFERENCE_COMMIT } Write-Output $ENGINE_COMMIT diff --git a/bin/internal/last_engine_commit.sh b/bin/internal/last_engine_commit.sh index 83af980405334..49202974663d8 100755 --- a/bin/internal/last_engine_commit.sh +++ b/bin/internal/last_engine_commit.sh @@ -7,7 +7,7 @@ # git tree that edited either `DEPS` or any file in the `engine/` sub-folder, # which is used to ensure `bin/internal/engine.version` is set correctly. # - +# # ---------------------------------- NOTE ---------------------------------- # # # Please keep the logic in this file consistent with the logic in the @@ -26,37 +26,27 @@ set -e FLUTTER_ROOT="$(dirname "$(dirname "$(dirname "${BASH_SOURCE[0]}")")")" -# 1. Determine when we diverged from master, and prevent set -e from exiting. -MERGE_BASE_COMMIT="$(git merge-base HEAD master || echo "")" +# 1. Determine when the release branch was started, and prevent set -e from exiting. +RELEASE_CANDIDATE_VERSION_PATH="$(git rev-parse --show-toplevel)/bin/internal/release-candidate-branch.version" +REFERENCE_COMMIT="$(git log -1 --pretty=format:%H -- "$RELEASE_CANDIDATE_VERSION_PATH")" # If we did not find a merge-base, fail -if [[ -z "$MERGE_BASE_COMMIT" ]]; then +if [[ -z "$REFERENCE_COMMIT" ]]; then echo >&2 "Error: Could not determine a suitable engine commit." echo >&2 "Current branch: $(git rev-parse --abbrev-ref HEAD)" - echo >&2 "Expected a different branch, from master" + echo >&2 "No file $RELEASE_CANDIDATE_VERSION_PATH found" exit 1 fi # 2. Define and search history range to searhc within (unique to changes on this branch). -HISTORY_RANGE="$MERGE_BASE_COMMIT..HEAD" +HISTORY_RANGE="$REFERENCE_COMMIT..HEAD" ENGINE_COMMIT="$(git log -1 --pretty=format:%H --ancestry-path "$HISTORY_RANGE" -- "$(git rev-parse --show-toplevel)/DEPS" "$(git rev-parse --show-toplevel)/engine")" # 3. If no engine-related commit was found within the current branch's history, fallback to the first commit on this branch. if [[ -z "$ENGINE_COMMIT" ]]; then # Find the oldest commit on HEAD that is *not* reachable from MERGE_BASE_COMMIT. # This is the first commit *on this branch* after it diverged from 'master'. - ENGINE_COMMIT="$(git log --pretty=format:%H --reverse --ancestry-path "$MERGE_BASE_COMMIT"..HEAD | head -n 1)" - - # Final check: If even this fallback fails (which would be highly unusual if MERGE_BASE_COMMIT was found), - # then something is truly wrong. - if [[ -z "$ENGINE_COMMIT" ]]; then - echo >&2 "Error: Unexpected state. MERGE_BASE_COMMIT was found ($MERGE_BASE_COMMIT), but no commits found on current branch after it." - echo >&2 "Current branch: $(git rev-parse --abbrev-ref HEAD)" - echo >&2 "History range searched for fallback: $HISTORY_RANGE" - echo >&2 "All commits on current branch (for debug):" - git log --pretty=format:%H - exit 1 - fi + ENGINE_COMMIT="$REFERENCE_COMMIT" fi echo "$ENGINE_COMMIT" diff --git a/dev/tools/test/last_engine_commit_test.dart b/dev/tools/test/last_engine_commit_test.dart index 00060e899cf84..0a110c7b06043 100644 --- a/dev/tools/test/last_engine_commit_test.dart +++ b/dev/tools/test/last_engine_commit_test.dart @@ -163,12 +163,8 @@ void main() { run('git', ['commit', '-m', 'Wrote ${files.length} files']); } - void changeBranch({required String newBranchName}) { - run('git', ['checkout', '-b', newBranchName]); - } - test('returns the last engine commit', () { - changeBranch(newBranchName: 'flutter-1.2.3-candidate.0'); + writeCommit(['bin/internal/release-candidate-branch.version']); writeCommit(['DEPS', 'engine/README.md']); final String lastEngine = getLastEngineCommit(); @@ -179,7 +175,7 @@ void main() { }); test('considers DEPS an engine change', () { - changeBranch(newBranchName: 'flutter-1.2.3-candidate.0'); + writeCommit(['bin/internal/release-candidate-branch.version']); writeCommit(['DEPS', 'engine/README.md']); final String lastEngineA = getLastEngineCommit(); @@ -196,9 +192,8 @@ void main() { // Make an engine change *before* the branch. writeCommit(['engine/README.md']); final String engineCommitPreBranch = getLastCommit(); - changeBranch(newBranchName: 'flutter-1.2.3-candidate.0'); - // Make a non-engine change, but no engine changes, after branching. + // Write the branch file. writeCommit(['bin/internal/release-candidate-branch.version']); final String initialBranchCommit = getLastCommit(); @@ -213,10 +208,10 @@ void main() { initialBranchCommit, reason: 'The git history for this simulation looks like this:\n' - 'master (intial commit) | $initialStartingCommit\n' - 'master (touches engine) | $engineCommitPreBranch\n' - 'flutter-1.2.3-candidate.0 | $initialBranchCommit\n' - 'flutter-1.2.3-candidate.0 | $latestCommitIgnore\n' + 'master | $initialStartingCommit\n' + 'master | $engineCommitPreBranch\n' + 'release | $initialBranchCommit\n' + 'release | $latestCommitIgnore\n' '\n' 'We expected our script to select HEAD~2, $initialBranchCommit, but ' 'instead it selected $lastCommitToEngine, which is incorrect. See ' From 1c9c20e7c3dd48c66f400a24d48ea806b4ab312a Mon Sep 17 00:00:00 2001 From: flutteractionsbot <154381524+flutteractionsbot@users.noreply.github.com> Date: Fri, 18 Jul 2025 09:37:15 -0700 Subject: [PATCH 03/23] [CP-beta]Remove emoji from ci.yaml, because we still live with CP1252 for some silly reason (#172263) This pull request is created by [automatic cherry pick workflow](https://github.com/flutter/flutter/blob/main/docs/releases/Flutter-Cherrypick-Process.md#automatically-creates-a-cherry-pick-request) Please fill in the form below, and a flutter domain expert will evaluate this cherry pick request. ### Issue Link: What is the link to the issue this cherry-pick is addressing? https://github.com/flutter/flutter/issues/172257 ### Changelog Description: Explain this cherry pick in one line that is accessible to most Flutter developers. See [best practices](https://github.com/flutter/flutter/blob/main/docs/releases/Hotfix-Documentation-Best-Practices.md) for examples N/A ### Impact Description: What is the impact (ex. visual jank on Samsung phones, app crash, cannot ship an iOS app)? Does it impact development (ex. flutter doctor crashes when Android Studio is installed), or the shipping production app (the app crashes on launch) Cannot release (Windows builder fails) ### Workaround: Is there a workaround for this issue? No ### Risk: What is the risk level of this cherry-pick? ### Test Coverage: Are you confident that your fix is well-tested by automated tests? ### Validation Steps: What are the steps to validate that this fix works? Try publishing another release --- engine/src/flutter/.ci.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/engine/src/flutter/.ci.yaml b/engine/src/flutter/.ci.yaml index 9e530b0c57c77..84843307f18b3 100644 --- a/engine/src/flutter/.ci.yaml +++ b/engine/src/flutter/.ci.yaml @@ -555,11 +555,11 @@ targets: properties: config_name: windows_unopt - # _____________________⚠️__________________________ + # _________________[WARNING]_______________________ # # This is a size experiment for reducing engine binary sizes. # It is highly volatile and will not be supported if you use it. - # _____________________⚠️__________________________ + # _________________[WARNING]_______________________ # - name: Linux linux_android_aot_engine_size_exp recipe: engine_v2/engine_v2 From c0f2a1dd60414de7bef59318dc2554a6bb75d4ad Mon Sep 17 00:00:00 2001 From: Matan Lurey Date: Mon, 21 Jul 2025 10:13:44 -0700 Subject: [PATCH 04/23] [3.35] Update `engine.version` (#172473) to use, correctly, https://github.com/flutter/flutter/commit/1c9c20e7c3dd48c66f400a24d48ea806b4ab312a. --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index a62065c38ce0d..7db0d59faa1f4 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -f85f6b62b6db2a8725cafa75360ec960d581a809 +1c9c20e7c3dd48c66f400a24d48ea806b4ab312a From da1b28974bd2c725709ef58878525e56092fd138 Mon Sep 17 00:00:00 2001 From: flutteractionsbot <154381524+flutteractionsbot@users.noreply.github.com> Date: Tue, 22 Jul 2025 08:09:15 -0700 Subject: [PATCH 05/23] [CP-beta] Add a warning on usage of `dartPluginClass: 'none'`. (#172498) This pull request is created by [automatic cherry pick workflow](https://github.com/flutter/flutter/blob/main/docs/releases/Flutter-Cherrypick-Process.md#automatically-creates-a-cherry-pick-request) Please fill in the form below, and a flutter domain expert will evaluate this cherry pick request. ### Issue Link: What is the link to the issue this cherry-pick is addressing? https://github.com/flutter/flutter/issues/57497 ### Changelog Description: Explain this cherry pick in one line that is accessible to most Flutter developers. See [best practices](https://github.com/flutter/flutter/blob/main/docs/releases/Hotfix-Documentation-Best-Practices.md) for examples N/A (Beta) ### Impact Description: What is the impact (ex. visual jank on Samsung phones, app crash, cannot ship an iOS app)? Does it impact development (ex. flutter doctor crashes when Android Studio is installed), or the shipping production app (the app crashes on launch) Provides a CLI-issued warning to plugins using a workaround we wanted to remove in 2020. Once this is CP'd in 3.35, the `master` branch (post-3.35) can remove the workaround/tech debt. ### Workaround: Is there a workaround for this issue? N/A ### Risk: What is the risk level of this cherry-pick? ### Test Coverage: Are you confident that your fix is well-tested by automated tests? ### Validation Steps: What are the steps to validate that this fix works? N/A --- .../lib/src/flutter_plugins.dart | 14 +++- .../lib/src/platform_plugins.dart | 38 ++++++++-- .../targets/dart_plugin_registrant_test.dart | 75 +++++++++++++++++++ 3 files changed, 121 insertions(+), 6 deletions(-) diff --git a/packages/flutter_tools/lib/src/flutter_plugins.dart b/packages/flutter_tools/lib/src/flutter_plugins.dart index b8f932f697b6e..5778c6140c28f 100644 --- a/packages/flutter_tools/lib/src/flutter_plugins.dart +++ b/packages/flutter_tools/lib/src/flutter_plugins.dart @@ -1659,7 +1659,19 @@ bool _hasPluginInlineImpl( /// Determine if the plugin provides an inline Dart implementation. bool _hasPluginInlineDartImpl(Plugin plugin, String platformKey) { final DartPluginClassAndFilePair? platformInfo = plugin.pluginDartClassPlatforms[platformKey]; - return platformInfo != null && platformInfo.dartClass != 'none'; + if (platformInfo == null) { + return false; + } + if (platformInfo.dartClass == 'none') { + // TODO(matanlurey): Remove as part of https://github.com/flutter/flutter/issues/57497. + globals.printWarning( + 'Use of `dartPluginClass: none` (${plugin.name}) is deprecated, and will ' + 'be removed in the next stable version. See ' + 'https://github.com/flutter/flutter/issues/57497 for details.', + ); + return false; + } + return true; } /// Get the resolved [Plugin] `resolution` from the [candidates] serving as diff --git a/packages/flutter_tools/lib/src/platform_plugins.dart b/packages/flutter_tools/lib/src/platform_plugins.dart index 8bc2502b9abae..ecba272dcf6cd 100644 --- a/packages/flutter_tools/lib/src/platform_plugins.dart +++ b/packages/flutter_tools/lib/src/platform_plugins.dart @@ -6,6 +6,7 @@ import 'package:yaml/yaml.dart'; import 'base/common.dart'; import 'base/file_system.dart'; +import 'globals.dart' as globals; /// Constant for 'pluginClass' key in plugin maps. const kPluginClass = 'pluginClass'; @@ -367,8 +368,18 @@ class MacOSPlugin extends PluginPlatform implements NativeOrDartPlugin, DarwinPl ); } - // Treat 'none' as not present. See https://github.com/flutter/flutter/issues/57497. - final String? pluginClass = yaml[kPluginClass] == 'none' ? null : yaml[kPluginClass] as String?; + final String? pluginClass; + if (yaml[kPluginClass] == 'none') { + // TODO(matanlurey): Remove as part of https://github.com/flutter/flutter/issues/57497. + globals.printWarning( + 'Use of `dartPluginClass: none` ($name) is deprecated, and will be ' + 'removed in the next stable version. See ' + 'https://github.com/flutter/flutter/issues/57497 for details.', + ); + pluginClass = null; + } else { + pluginClass = yaml[kPluginClass] as String?; + } return MacOSPlugin( name: name, @@ -446,9 +457,14 @@ class WindowsPlugin extends PluginPlatform implements NativeOrDartPlugin, Varian factory WindowsPlugin.fromYaml(String name, YamlMap yaml) { assert(validate(yaml)); - // Treat 'none' as not present. See https://github.com/flutter/flutter/issues/57497. var pluginClass = yaml[kPluginClass] as String?; if (pluginClass == 'none') { + // TODO(matanlurey): Remove as part of https://github.com/flutter/flutter/issues/57497. + globals.printWarning( + 'Use of `dartPluginClass: none` ($name) is deprecated, and will be ' + 'removed in the next stable version. See ' + 'https://github.com/flutter/flutter/issues/57497 for details.', + ); pluginClass = null; } final variants = {}; @@ -564,10 +580,22 @@ class LinuxPlugin extends PluginPlatform implements NativeOrDartPlugin { ); } + final String? pluginClass; + if (yaml[kPluginClass] == 'none') { + // TODO(matanlurey): Remove as part of https://github.com/flutter/flutter/issues/57497. + globals.printWarning( + 'Use of `dartPluginClass: none` ($name) is deprecated, and will be ' + 'removed in the next stable version. See ' + 'https://github.com/flutter/flutter/issues/57497 for details.', + ); + pluginClass = null; + } else { + pluginClass = yaml[kPluginClass] as String?; + } + return LinuxPlugin( name: name, - // Treat 'none' as not present. See https://github.com/flutter/flutter/issues/57497. - pluginClass: yaml[kPluginClass] == 'none' ? null : yaml[kPluginClass] as String?, + pluginClass: pluginClass, dartPluginClass: dartPluginClass, dartFileName: dartFileName, ffiPlugin: yaml[kFfiPlugin] as bool? ?? false, diff --git a/packages/flutter_tools/test/general.shard/build_system/targets/dart_plugin_registrant_test.dart b/packages/flutter_tools/test/general.shard/build_system/targets/dart_plugin_registrant_test.dart index 7f01a24e21a91..1ee66cad8c705 100644 --- a/packages/flutter_tools/test/general.shard/build_system/targets/dart_plugin_registrant_test.dart +++ b/packages/flutter_tools/test/general.shard/build_system/targets/dart_plugin_registrant_test.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'package:file/memory.dart'; +import 'package:file_testing/file_testing.dart'; import 'package:flutter_tools/src/artifacts.dart'; import 'package:flutter_tools/src/base/file_system.dart'; import 'package:flutter_tools/src/base/logger.dart'; @@ -54,12 +55,33 @@ environment: flutter: ">=1.20.0" '''; +/// Returns a `pubspec.yaml` where `$platform` uses `dartPluginClass: 'none'`. +String samplePluginPubspecWithDartPluginClassNone({required String platform}) => + ''' +name: path_provider_$platform +description: $platform implementation of the path_provider plugin +// version: 2.0.1 +// homepage: https://github.com/flutter/plugins/tree/main/packages/path_provider/path_provider_$platform +flutter: + plugin: + implements: path_provider + platforms: + $platform: + dartPluginClass: none + pluginClass: none +environment: + sdk: ^3.7.0-0 + flutter: ">=1.20.0" +'''; + void main() { group('Dart plugin registrant', () { late FileSystem fileSystem; + late BufferLogger logger; setUp(() { fileSystem = MemoryFileSystem.test(); + logger = BufferLogger.test(); }); testWithoutContext('skipped based on environment.generateDartPluginRegistry', () async { @@ -158,6 +180,59 @@ name: path_provider_example }, ); + for (final platform in ['linux', 'macos', 'windows']) { + testUsingContext( + '$platform treats dartPluginClass: "none" as omitted', + () async { + final Directory projectDir = fileSystem.directory('project')..createSync(); + final environment = Environment.test( + fileSystem.currentDirectory, + projectDir: projectDir, + artifacts: Artifacts.test(), + fileSystem: fileSystem, + logger: BufferLogger.test(), + processManager: FakeProcessManager.any(), + defines: { + kTargetFile: projectDir.childDirectory('lib').childFile('main.dart').absolute.path, + }, + generateDartPluginRegistry: true, + ); + + writePackageConfigFiles( + directory: projectDir, + mainLibName: 'path_provider_example', + packages: {'path_provider_$platform': '/path_provider_$platform'}, + languageVersions: {'path_provider_example': '2.12'}, + ); + + projectDir.childFile('pubspec.yaml').writeAsStringSync(_kSamplePubspecFile); + + projectDir.childDirectory('lib').childFile('main.dart').createSync(recursive: true); + + environment.fileSystem.currentDirectory + .childDirectory('path_provider_$platform') + .childFile('pubspec.yaml') + ..createSync(recursive: true) + ..writeAsStringSync(samplePluginPubspecWithDartPluginClassNone(platform: platform)); + + final FlutterProject testProject = FlutterProject.fromDirectoryTest(projectDir); + await DartPluginRegistrantTarget.test(testProject).build(environment); + + final File generatedMain = projectDir + .childDirectory('.dart_tool') + .childDirectory('flutter_build') + .childFile('dart_plugin_registrant.dart'); + expect(generatedMain, isNot(exists)); + expect(logger.warningText, contains('Use of `dartPluginClass: none`')); + }, + overrides: { + Logger: () => logger, + ProcessManager: () => FakeProcessManager.any(), + Pub: ThrowingPub.new, + }, + ); + } + testUsingContext( 'regenerates dart_plugin_registrant.dart', () async { From 789c3e343156c5942796b0ea59c50096f138396b Mon Sep 17 00:00:00 2001 From: flutteractionsbot <154381524+flutteractionsbot@users.noreply.github.com> Date: Fri, 25 Jul 2025 11:51:53 -0700 Subject: [PATCH 06/23] [CP-beta]Revert #160653 Fix view removal process for AutofillContextAction.cancel (#172675) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This pull request is created by [automatic cherry pick workflow](https://github.com/flutter/flutter/blob/main/docs/releases/Flutter-Cherrypick-Process.md#automatically-creates-a-cherry-pick-request) Please fill in the form below, and a flutter domain expert will evaluate this cherry pick request. ### Issue Link: What is the link to the issue this cherry-pick is addressing? #172250 ### Changelog Description: Explain this cherry pick in one line that is accessible to most Flutter developers. See [best practices](https://github.com/flutter/flutter/blob/main/docs/releases/Hotfix-Documentation-Best-Practices.md) for examples Fixes a bug where `TextInput.hide` call incorrect clears the text in the active text field. ### Impact Description: What is the impact (ex. visual jank on Samsung phones, app crash, cannot ship an iOS app)? Does it impact development (ex. flutter doctor crashes when Android Studio is installed), or the shipping production app (the app crashes on launch) If an app calls `TextInput.hide` to hide the software keyboard, the user input in the current active text field will also be erased. It impacts the production app. The framework itself doesn't seem to call `TextInput.hide` in a way that would affect the user. ### Workaround: Is there a workaround for this issue? Yes, in theory, developers can store and restore the `TextEditingValue`, when they need to call `TextInput.hide`, to undo the clear. However I suspect the reverted PR may also [break voiceover](https://github.com/flutter/flutter/issues/145681#issuecomment-3110985123). ### Risk: What is the risk level of this cherry-pick? This is a revert of #160653. The reverted PR was merged in February, and the previous implementation was [introduced in 2021](https://github.com/flutter/engine/pull/23776). ### Test Coverage: Are you confident that your fix is well-tested by automated tests? - [] No This change is a revert so the added test will also get reverted. ### Validation Steps: What are the steps to validate that this fix works? To verify the fix on master (it has already landed on master), run this app: ```dart import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; void main() { runApp(const MyApp()); } class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Demo', debugShowCheckedModeBanner: false, theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple), useMaterial3: true, ), home: const MyHomePage(), ); } } class MyHomePage extends StatelessWidget { const MyHomePage({super.key}); @override Widget build(BuildContext context) { return Scaffold( body: Center( child: SizedBox( child: ListView( children: [ TextButton( child: Text('hide keyboard'), onPressed: () => SystemChannels.textInput.invokeListMethod("TextInput.hide"), ), TextField( controller: TextEditingController(text: '️a' * 20), maxLines: 1, ), ], ), ), ), ); } } ``` focus the text field and then click the hide keyboard button. The text "aaaaaa..." should remain after the button is clicked. --- .../Source/FlutterTextInputPlugin.mm | 7 ++----- .../Source/FlutterTextInputPluginTest.mm | 21 ------------------- 2 files changed, 2 insertions(+), 26 deletions(-) diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm index 68d1aa205689e..1aaaa311df7e6 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPlugin.mm @@ -2827,11 +2827,8 @@ - (void)hideTextInput { [self removeEnableFlutterTextInputViewAccessibilityTimer]; _activeView.accessibilityEnabled = NO; [_activeView resignFirstResponder]; - // Removes the focus from the `_activeView` (UIView) - // when the user stops typing (keyboard is hidden). - // For more details, refer to the discussion at: - // https://github.com/flutter/engine/pull/57209#discussion_r1905942577 - [self cleanUpViewHierarchy:YES clearText:YES delayRemoval:NO]; + [_activeView removeFromSuperview]; + [_inputHider removeFromSuperview]; } - (void)triggerAutofillSave:(BOOL)saveEntries { diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm index 8245e6e657a2e..a0c631213620b 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm @@ -2812,27 +2812,6 @@ - (void)testInitialActiveViewCantAccessTextInputDelegate { XCTAssertNil(textInputPlugin.activeView.textInputDelegate); } -- (void)testAutoFillDoesNotTriggerOnHideButTriggersOnCommit { - // Regression test for https://github.com/flutter/flutter/issues/145681. - NSMutableDictionary* configuration = self.mutableTemplateCopy; - [configuration setValue:@{ - @"uniqueIdentifier" : @"field1", - @"hints" : @[ UITextContentTypePassword ], - @"editingValue" : @{@"text" : @""} - } - forKey:@"autofill"]; - [configuration setValue:@[ [configuration copy] ] forKey:@"fields"]; - - [self setClientId:123 configuration:configuration]; - XCTAssertEqual(self.viewsVisibleToAutofill.count, 1ul); - - [self setTextInputHide]; - // Before the fix in https://github.com/flutter/flutter/pull/160653, it was 0ul. - XCTAssertEqual(self.viewsVisibleToAutofill.count, 1ul); - - [self commitAutofillContextAndVerify]; -} - #pragma mark - Accessibility - Tests - (void)testUITextInputAccessibilityNotHiddenWhenShowed { From 4a371c9d95d77bbf3344ff80052142580e827408 Mon Sep 17 00:00:00 2001 From: flutteractionsbot <154381524+flutteractionsbot@users.noreply.github.com> Date: Fri, 25 Jul 2025 12:09:23 -0700 Subject: [PATCH 07/23] [CP-beta]Fix: Ensure Text widget locale is included in semantics language tag (#172711) This pull request is created by [automatic cherry pick workflow](https://github.com/flutter/flutter/blob/main/docs/releases/Flutter-Cherrypick-Process.md#automatically-creates-a-cherry-pick-request) Please fill in the form below, and a flutter domain expert will evaluate this cherry pick request. ### Issue Link: What is the link to the issue this cherry-pick is addressing? https://github.com/flutter/flutter/issues/162324 ### Changelog Description: Explain this cherry pick in one line that is accessible to most Flutter developers. See [best practices](https://github.com/flutter/flutter/blob/main/docs/releases/Hotfix-Documentation-Best-Practices.md) for examples This PR fixes a bug where the locale property of a Text widget was not being correctly passed to the accessibility layer, resulting in screen readers not knowing the language of the text. ### Impact Description: What is the impact (ex. visual jank on Samsung phones, app crash, cannot ship an iOS app)? Does it impact development (ex. flutter doctor crashes when Android Studio is installed), or the shipping production app (the app crashes on launch) This bug critically impacted accessibility, causing screen readers to mispronounce text in foreign languages and creating a confusing, inaccessible experience for users with visual impairments. The fix ensures the locale property on the Text widget is no longer ignored, correctly passing the language information to the underlying accessibility services so the text is read intelligibly. ### Workaround: Is there a workaround for this issue? No. ### Risk: What is the risk level of this cherry-pick? ### Test Coverage: Are you confident that your fix is well-tested by automated tests? ### Validation Steps: What are the steps to validate that this fix works? Unit tests verify this change works as expected. --- packages/flutter/lib/src/widgets/text.dart | 2 + .../test/widgets/text_semantics_test.dart | 50 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/packages/flutter/lib/src/widgets/text.dart b/packages/flutter/lib/src/widgets/text.dart index fe4c37f69d96b..031a9820bf920 100644 --- a/packages/flutter/lib/src/widgets/text.dart +++ b/packages/flutter/lib/src/widgets/text.dart @@ -735,6 +735,7 @@ class Text extends StatelessWidget { text: TextSpan( style: effectiveTextStyle, text: data, + locale: locale, children: textSpan != null ? [textSpan!] : null, ), ), @@ -762,6 +763,7 @@ class Text extends StatelessWidget { text: TextSpan( style: effectiveTextStyle, text: data, + locale: locale, children: textSpan != null ? [textSpan!] : null, ), ); diff --git a/packages/flutter/test/widgets/text_semantics_test.dart b/packages/flutter/test/widgets/text_semantics_test.dart index 3232d69fd5c00..967c994002f38 100644 --- a/packages/flutter/test/widgets/text_semantics_test.dart +++ b/packages/flutter/test/widgets/text_semantics_test.dart @@ -173,4 +173,54 @@ void main() { expect(labelToNodeId['has been created.'], ''); expect(labelToNodeId.length, 3); }); + + testWidgets('GIVEN a Text widget with a locale ' + 'WHEN semantics are built ' + 'THEN the SemanticsNode contains the correct language tag', (WidgetTester tester) async { + const Locale locale = Locale('de', 'DE'); + + await tester.pumpWidget( + const Directionality( + textDirection: TextDirection.ltr, + child: Text('Flutter 2050', locale: locale), + ), + ); + + final SemanticsNode node = tester.getSemantics(find.byType(Directionality)); + final LocaleStringAttribute localeStringAttribute = + node.attributedLabel.attributes[0] as LocaleStringAttribute; + + expect(node.label, 'Flutter 2050'); + expect(localeStringAttribute.locale.toLanguageTag(), 'de-DE'); + }); + + testWidgets('GIVEN a Text with a locale is within a SelectionContainer ' + 'WHEN semantics are built ' + 'THEN the SemanticsNode contains the correct language tag', (WidgetTester tester) async { + const Locale locale = Locale('de', 'DE'); + const String text = 'Flutter 2050'; + await tester.pumpWidget( + const MaterialApp( + home: SelectionArea(child: Text(text, locale: locale)), + ), + ); + await tester.pumpAndSettle(); + + final SemanticsNode root = tester.binding.pipelineOwner.semanticsOwner!.rootSemanticsNode!; + final List queue = [root]; + SemanticsNode? targetNode; + while (queue.isNotEmpty) { + final SemanticsNode node = queue.removeAt(0); + if (node.label == text) { + targetNode = node; + break; + } + queue.addAll(node.debugListChildrenInOrder(DebugSemanticsDumpOrder.traversalOrder)); + } + final LocaleStringAttribute localeStringAttribute = + targetNode!.attributedLabel.attributes[0] as LocaleStringAttribute; + + expect(targetNode.label, text); + expect(localeStringAttribute.locale.toLanguageTag(), 'de-DE'); + }); } From 31cf7a8ce27f99f8b1ca840d787783da1ba1e75a Mon Sep 17 00:00:00 2001 From: flutteractionsbot <154381524+flutteractionsbot@users.noreply.github.com> Date: Mon, 28 Jul 2025 06:41:10 -0700 Subject: [PATCH 08/23] [CP-beta]Update warnGradleVersion to `8.7.0` (#172787) This pull request is created by [automatic cherry pick workflow](https://github.com/flutter/flutter/blob/main/docs/releases/Flutter-Cherrypick-Process.md#automatically-creates-a-cherry-pick-request) Please fill in the form below, and a flutter domain expert will evaluate this cherry pick request. ### Issue Link: What is the link to the issue this cherry-pick is addressing? https://github.com/flutter/flutter/issues/172789 ### Changelog Description: Explain this cherry pick in one line that is accessible to most Flutter developers. See [best practices](https://github.com/flutter/flutter/blob/main/docs/releases/Hotfix-Documentation-Best-Practices.md) for examples https://github.com/flutter/flutter/issues/172789 https://github.com/flutter/flutter/issues/172789 Using a lower version of Gradle results in a warning message to bump to a minimum of version `8.7.2`, but the warning should bump to a minimum of version `8.7.0` Reids edit: [flutter/172789](https://github.com/flutter/flutter/issues/172789) When building for Android, Change warning for minimum gradle version from 8.7.2 to 8.7.0. ### Impact Description: What is the impact (ex. visual jank on Samsung phones, app crash, cannot ship an iOS app)? Does it impact development (ex. flutter doctor crashes when Android Studio is installed), or the shipping production app (the app crashes on launch) When on a lower version of Gradle, Flutter tooling would log a suggestion to bump to a minimum of `8.7.0` instead of `8.7.2` ### Workaround: Is there a workaround for this issue? Yes, the user would bump to a higher valid version of Gradle. ### Risk: What is the risk level of this cherry-pick? ### Test Coverage: Are you confident that your fix is well-tested by automated tests? ### Validation Steps: What are the steps to validate that this fix works? Use a low version of gradle and see that the Flutter tool recommends using gradle `8.7.0` --- .../gradle/src/main/kotlin/DependencyVersionChecker.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/flutter_tools/gradle/src/main/kotlin/DependencyVersionChecker.kt b/packages/flutter_tools/gradle/src/main/kotlin/DependencyVersionChecker.kt index ce5642ef76cbb..592315a40639a 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/DependencyVersionChecker.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/DependencyVersionChecker.kt @@ -90,7 +90,7 @@ object DependencyVersionChecker { // flutter.dev/go/android-dependency-versions for more. // 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(8, 7, 2) + @VisibleForTesting internal val warnGradleVersion: Version = Version(8, 7, 0) @VisibleForTesting internal val errorGradleVersion: Version = Version(8, 3, 0) From 3284810aab2255740a8a90c5b2b3d9ec6c0d1941 Mon Sep 17 00:00:00 2001 From: flutteractionsbot <154381524+flutteractionsbot@users.noreply.github.com> Date: Tue, 29 Jul 2025 09:51:02 -0700 Subject: [PATCH 09/23] [CP-beta] Emit a warning on `--[no-]disable-dds`, preferring `--no-dds` (#172790) This pull request is created by [automatic cherry pick workflow](https://github.com/flutter/flutter/blob/main/docs/releases/Flutter-Cherrypick-Process.md#automatically-creates-a-cherry-pick-request) Please fill in the form below, and a flutter domain expert will evaluate this cherry pick request. ### Issue Link: What is the link to the issue this cherry-pick is addressing? https://github.com/flutter/flutter/issues/150279 ### Changelog Description: Explain this cherry pick in one line that is accessible to most Flutter developers. See [best practices](https://github.com/flutter/flutter/blob/main/docs/releases/Hotfix-Documentation-Best-Practices.md) for examples Emits a warning if `--[no-]disable-dds` is used, users should prefer `--[no-]dds`. ### Impact Description: What is the impact (ex. visual jank on Samsung phones, app crash, cannot ship an iOS app)? Does it impact development (ex. flutter doctor crashes when Android Studio is installed), or the shipping production app (the app crashes on launch) Gives a full stable release of warnings before removing a deprecated feature. ### Workaround: Is there a workaround for this issue? N/A ### Risk: What is the risk level of this cherry-pick? ### Test Coverage: Are you confident that your fix is well-tested by automated tests? ### Validation Steps: What are the steps to validate that this fix works? N/A --- .../lib/src/runner/flutter_command.dart | 20 ++++++++----------- .../runner/flutter_command_test.dart | 11 ++++++++-- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/packages/flutter_tools/lib/src/runner/flutter_command.dart b/packages/flutter_tools/lib/src/runner/flutter_command.dart index 7360f7ab14895..38853cca68e3f 100644 --- a/packages/flutter_tools/lib/src/runner/flutter_command.dart +++ b/packages/flutter_tools/lib/src/runner/flutter_command.dart @@ -605,18 +605,14 @@ abstract class FlutterCommand extends Command { ); } ddsEnabled = !boolArg('disable-dds'); - // TODO(ianh): enable the following code once google3 is migrated away from --disable-dds (and add test to flutter_command_test.dart) - // ignore: dead_code, literal_only_boolean_expressions - if (false) { - if (ddsEnabled) { - globals.printWarning( - '${globals.logger.terminal.warningMark} The "--no-disable-dds" argument is deprecated and redundant, and should be omitted.', - ); - } else { - globals.printWarning( - '${globals.logger.terminal.warningMark} The "--disable-dds" argument is deprecated. Use "--no-dds" instead.', - ); - } + if (ddsEnabled) { + globals.printWarning( + '${globals.logger.terminal.warningMark} The "--no-disable-dds" argument is deprecated and redundant, and should be omitted.', + ); + } else { + globals.printWarning( + '${globals.logger.terminal.warningMark} The "--disable-dds" argument is deprecated. Use "--no-dds" instead.', + ); } } else { ddsEnabled = boolArg('dds'); diff --git a/packages/flutter_tools/test/general.shard/runner/flutter_command_test.dart b/packages/flutter_tools/test/general.shard/runner/flutter_command_test.dart index f12419eb84d5c..0f2b479c1ae6e 100644 --- a/packages/flutter_tools/test/general.shard/runner/flutter_command_test.dart +++ b/packages/flutter_tools/test/general.shard/runner/flutter_command_test.dart @@ -46,7 +46,7 @@ void main() { late MemoryFileSystem fileSystem; late Platform platform; late FileSystemUtils fileSystemUtils; - late Logger logger; + late BufferLogger logger; late FakeProcessManager processManager; late PreRunValidator preRunValidator; @@ -727,15 +727,17 @@ void main() { ); testUsingContext( - 'dds options --disable-dds', + 'dds options --disable-dds works, but is deprecated', () async { final ddsCommand = FakeDdsCommand(); final CommandRunner runner = createTestCommandRunner(ddsCommand); await runner.run(['test', '--disable-dds']); expect(ddsCommand.enableDds, isFalse); + expect(logger.warningText, contains('"--disable-dds" argument is deprecated')); }, overrides: { FileSystem: () => fileSystem, + Logger: () => logger, ProcessManager: () => processManager, }, ); @@ -747,9 +749,14 @@ void main() { final CommandRunner runner = createTestCommandRunner(ddsCommand); await runner.run(['test', '--no-disable-dds']); expect(ddsCommand.enableDds, isTrue); + expect( + logger.warningText, + contains('"--no-disable-dds" argument is deprecated and redundant'), + ); }, overrides: { FileSystem: () => fileSystem, + Logger: () => logger, ProcessManager: () => processManager, }, ); From def9ff97c0c85909a71187e801bb046f86442c6c Mon Sep 17 00:00:00 2001 From: flutteractionsbot <154381524+flutteractionsbot@users.noreply.github.com> Date: Wed, 30 Jul 2025 12:20:00 -0700 Subject: [PATCH 10/23] [CP-beta][ios]update provisioning profile for 2025-2026 cert for chromium bots (#172972) This pull request is created by [automatic cherry pick workflow](https://github.com/flutter/flutter/blob/main/docs/releases/Flutter-Cherrypick-Process.md#automatically-creates-a-cherry-pick-request) Please fill in the form below, and a flutter domain expert will evaluate this cherry pick request. ### Issue Link: What is the link to the issue this cherry-pick is addressing? https://github.com/flutter/flutter/issues/168427 ### Changelog Description: Explain this cherry pick in one line that is accessible to most Flutter developers. See [best practices](https://github.com/flutter/flutter/blob/main/docs/releases/Hotfix-Documentation-Best-Practices.md) for examples Updates the provisioning profile that Flutter's CI uses. ### Impact Description: What is the impact (ex. visual jank on Samsung phones, app crash, cannot ship an iOS app)? Does it impact development (ex. flutter doctor crashes when Android Studio is installed), or the shipping production app (the app crashes on launch) This only impacts the release team. Without this patch, codesigning on try and prod chromium bots will fail. ### Workaround: Is there a workaround for this issue? No ### Risk: What is the risk level of this cherry-pick? ### Test Coverage: Are you confident that your fix is well-tested by automated tests? ### Validation Steps: What are the steps to validate that this fix works? Run a test that requires codesigning, such as "Mac_x64 tool_host_cross_arch_tests" --- .ci.yaml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.ci.yaml b/.ci.yaml index c57f0f015bfe5..f64818c597ac9 100644 --- a/.ci.yaml +++ b/.ci.yaml @@ -155,7 +155,7 @@ platform_properties: ] dependencies: >- [ - {"dependency": "apple_signing", "version": "version:to_2025"} + {"dependency": "apple_signing", "version": "version:to_2026"} ] os: Mac-14|Mac-15 device_type: none @@ -172,7 +172,7 @@ platform_properties: ] dependencies: >- [ - {"dependency": "apple_signing", "version": "version:to_2025"} + {"dependency": "apple_signing", "version": "version:to_2026"} ] os: Mac-14|Mac-15 device_type: none @@ -190,7 +190,7 @@ platform_properties: ] dependencies: >- [ - {"dependency": "apple_signing", "version": "version:to_2025"} + {"dependency": "apple_signing", "version": "version:to_2026"} ] device_type: none mac_model: "Macmini8,1" @@ -210,7 +210,7 @@ platform_properties: ] dependencies: >- [ - {"dependency": "apple_signing", "version": "version:to_2025"} + {"dependency": "apple_signing", "version": "version:to_2026"} ] os: Mac-14|Mac-15 device_type: none @@ -229,7 +229,7 @@ platform_properties: dependencies: >- [ {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, - {"dependency": "apple_signing", "version": "version:to_2025"} + {"dependency": "apple_signing", "version": "version:to_2026"} ] os: Mac-14|Mac-15 device_type: none @@ -271,7 +271,7 @@ platform_properties: dependencies: >- [ {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, - {"dependency": "apple_signing", "version": "version:to_2025"} + {"dependency": "apple_signing", "version": "version:to_2026"} ] os: Mac-14|Mac-15 device_os: iOS-17|iOS-18 @@ -289,7 +289,7 @@ platform_properties: dependencies: >- [ {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, - {"dependency": "apple_signing", "version": "version:to_2025"} + {"dependency": "apple_signing", "version": "version:to_2026"} ] os: Mac-14|Mac-15 cpu: x86 From f32d84f9134142ccaa775bd61ea658f6e47be4c8 Mon Sep 17 00:00:00 2001 From: Jackson Gardner Date: Wed, 30 Jul 2025 12:38:14 -0700 Subject: [PATCH 11/23] Update engine version for 3.35-0.2.pre (#172987) --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index 7db0d59faa1f4..a99e6a7f10a14 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -1c9c20e7c3dd48c66f400a24d48ea806b4ab312a +789c3e343156c5942796b0ea59c50096f138396b From 52cc75c62467e72200ab5cd54cee48b8313f6d86 Mon Sep 17 00:00:00 2001 From: flutteractionsbot <154381524+flutteractionsbot@users.noreply.github.com> Date: Thu, 31 Jul 2025 13:23:08 -0700 Subject: [PATCH 12/23] [CP-beta][web] Text editing test accepts both behaviors in Firefox (#173053) This pull request is created by [automatic cherry pick workflow](https://github.com/flutter/flutter/blob/main/docs/releases/Flutter-Cherrypick-Process.md#automatically-creates-a-cherry-pick-request) ### Issue Link: https://github.com/flutter/flutter/issues/172713 ### Changelog Description: Fix web engine unit tests to work on multiple versions of Firefox. ### Impact Description: Without this fix, some unit tests fail fairly regularly on Firefox. ### Workaround: This does not affect end-users, so no workaround necessary. ### Risk: Low ### Test Coverage: Are you confident that your fix is well-tested by automated tests? Yes ### Validation Steps: Run CI. --- .../lib/web_ui/test/engine/text_editing_test.dart | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/engine/src/flutter/lib/web_ui/test/engine/text_editing_test.dart b/engine/src/flutter/lib/web_ui/test/engine/text_editing_test.dart index a7815aac5feab..2fe122aa2c1a1 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/text_editing_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/text_editing_test.dart @@ -649,7 +649,7 @@ Future testMain() async { }); test( - 'keeps focus within window/iframe when the focus moves within the flutter view in Chrome but not Safari', + 'keeps focus within window/iframe when the focus moves within the flutter view in Chrome and Firefox but not Safari', () async { final PlatformMessagesSpy spy = PlatformMessagesSpy(); spy.setUp(); @@ -694,7 +694,14 @@ Future testMain() async { // call .focus() the browser doesn't move focus to the target // element. This only happens in the test harness. When testing // manually, Firefox happily moves focus to the input element. - expect(domDocument.activeElement, flutterView.dom.rootElement); + // + // We've seen cases in LUCI where Firefox behaves like Chrome (sets focus on the input + // element). But when running locally, we are seeing the wrong behavior explained in the + // comment above. To work around this, the test will accept both behaviors for now. + expect( + domDocument.activeElement, + anyOf(textEditing.strategy.domElement, flutterView.dom.rootElement), + ); } else { expect(domDocument.activeElement, textEditing.strategy.domElement); } From 26b84dd770917c2f8d959f3530d7a83be080bca3 Mon Sep 17 00:00:00 2001 From: Jackson Gardner Date: Fri, 1 Aug 2025 10:10:56 -0700 Subject: [PATCH 13/23] Update engine.version again for 3.35-0.2 (#173116) The most recent change to fix CI on the beta branch actually touches engine unit tests, so we need to bump the engine.version again. --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index a99e6a7f10a14..d0da161faf563 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -789c3e343156c5942796b0ea59c50096f138396b +52cc75c62467e72200ab5cd54cee48b8313f6d86 From 659d9553df45256ed2aa388aae7ed5a1a4f51bae Mon Sep 17 00:00:00 2001 From: flutteractionsbot <154381524+flutteractionsbot@users.noreply.github.com> Date: Wed, 6 Aug 2025 12:44:18 -0700 Subject: [PATCH 14/23] [CP-beta][web] ClickDebouncer workaround for iOS Safari click behavior (#173072) This pull request is created by [automatic cherry pick workflow](https://github.com/flutter/flutter/blob/main/docs/releases/Flutter-Cherrypick-Process.md#automatically-creates-a-cherry-pick-request) ### Issue Link: What is the link to the issue this cherry-pick is addressing? https://github.com/flutter/flutter/issues/172180 ### Changelog Description: Fix a bug that causes the web engine to double report taps on dropdown menu items on iOS Safari. ### Impact Description: It breaks Flutter Web apps that use dropdown menus with semantics enabled on iOS Safari. ### Workaround: Is there a workaround for this issue? No. ### Risk: What is the risk level of this cherry-pick? ### Test Coverage: Are you confident that your fix is well-tested by automated tests? ### Validation Steps: What are the steps to validate that this fix works? Follow repro steps in https://github.com/flutter/flutter/issues/172180 --- .../lib/src/engine/pointer_binding.dart | 90 +++++++++++++------ .../test/engine/pointer_binding_test.dart | 74 +++++++++++++++ 2 files changed, 136 insertions(+), 28 deletions(-) diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/pointer_binding.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/pointer_binding.dart index 931e87c9fe96e..62342cc13b371 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/pointer_binding.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/pointer_binding.dart @@ -167,7 +167,7 @@ class PointerBinding { typedef QueuedEvent = ({DomEvent event, Duration timeStamp, List data}); @visibleForTesting -typedef DebounceState = ({DomElement target, Timer timer, List queue}); +typedef DebounceState = ({DomEventTarget target, Timer timer, List queue}); /// Disambiguates taps and clicks that are produced both by the framework from /// `pointerdown`/`pointerup` events and those detected as DOM "click" events by @@ -221,7 +221,8 @@ class ClickDebouncer { /// This value is normally false, and it flips to true when the first /// pointerdown is observed that lands on a tappable semantics node, denoted /// by the presence of the `flt-tappable` attribute. - bool get isDebouncing => _state != null; + bool get isDebouncing => _isDebouncing; + bool _isDebouncing = false; /// Processes a pointer event. /// @@ -245,7 +246,7 @@ class ClickDebouncer { if (isDebouncing) { _debounce(event, data); } else if (event.type == 'pointerdown') { - _startDebouncing(event, data); + _maybeStartDebouncing(event, data); } else { if (event.type == 'pointerup') { // Record the last pointerup event even if not debouncing. This is @@ -313,33 +314,31 @@ class ClickDebouncer { reset(); } - void _startDebouncing(DomEvent event, List data) { - assert(_state == null, 'Cannot start debouncing. Already debouncing.'); + /// Starts debouncing pointer events if the [event] is a `pointerdown` on a + /// tappable element. + /// + /// To work around an issue in iOS Safari, the debouncing does not start + /// immediately, but at the end of the event loop. + /// + /// See also: + /// + /// * [_doStartDebouncing], which actually starts the debouncing. + void _maybeStartDebouncing(DomEvent event, List data) { + assert(!isDebouncing, 'Cannot start debouncing. Already debouncing.'); assert(event.type == 'pointerdown', 'Click debouncing must begin with a pointerdown'); final DomEventTarget? target = event.target; if (target.isA() && (target! as DomElement).hasAttribute('flt-tappable')) { - _state = ( - target: target as DomElement, - // The 200ms duration was chosen empirically by testing tapping, mouse - // clicking, trackpad tapping and clicking, as well as the following - // screen readers: TalkBack on Android, VoiceOver on macOS, Narrator/ - // NVDA/JAWS on Windows. 200ms seemed to hit the sweet spot by - // satisfying the following: - // * It was short enough that delaying the `pointerdown` still allowed - // drag gestures to begin reasonably soon (e.g. scrolling). - // * It was long enough to register taps and clicks. - // * It was successful at detecting taps generated by all tested - // screen readers. - timer: Timer(const Duration(milliseconds: 200), _onTimerExpired), - queue: [ - ( - event: event, - timeStamp: _BaseAdapter._eventTimeStampToDuration(event.timeStamp!), - data: data, - ), - ], - ); + // In some cases, iOS Safari tracks timers that are initiated from within a `pointerdown` + // event, and waits until those timers go off before sending the `click` event. + // + // This iOS Safari behavior breaks the `ClickDebouncer` because it creates a 200ms timer. To + // work around it, the `ClickDebouncer` should start debouncing after the end of the event + // loop. + // + // See: https://github.com/flutter/flutter/issues/172180 + _isDebouncing = true; + Timer.run(() => _doStartDebouncing(event, data)); } else { // The event landed on an non-tappable target. Assume this won't lead to // double clicks and forward the event to the framework. @@ -347,9 +346,42 @@ class ClickDebouncer { } } + /// The core logic for starting to debounce pointer events. + /// + /// This method is called asynchronously from [_maybeStartDebouncing]. + void _doStartDebouncing(DomEvent event, List data) { + // It's possible that debouncing was cancelled between the pointerdown event and the execution + // of this method. + if (!isDebouncing) { + return; + } + + _state = ( + target: event.target!, + // The 200ms duration was chosen empirically by testing tapping, mouse + // clicking, trackpad tapping and clicking, as well as the following + // screen readers: TalkBack on Android, VoiceOver on macOS, Narrator/ + // NVDA/JAWS on Windows. 200ms seemed to hit the sweet spot by + // satisfying the following: + // * It was short enough that delaying the `pointerdown` still allowed + // drag gestures to begin reasonably soon (e.g. scrolling). + // * It was long enough to register taps and clicks. + // * It was successful at detecting taps generated by all tested + // screen readers. + timer: Timer(const Duration(milliseconds: 200), _onTimerExpired), + queue: [ + ( + event: event, + timeStamp: _BaseAdapter._eventTimeStampToDuration(event.timeStamp!), + data: data, + ), + ], + ); + } + void _debounce(DomEvent event, List data) { assert( - _state != null, + isDebouncing, 'Cannot debounce event. Debouncing state not established by _startDebouncing.', ); @@ -394,7 +426,7 @@ class ClickDebouncer { } void _flush() { - assert(_state != null); + assert(isDebouncing); final DebounceState state = _state!; state.timer.cancel(); @@ -409,6 +441,7 @@ class ClickDebouncer { _sendToFramework(null, aggregateData); _state = null; + _isDebouncing = false; } void _sendToFramework(DomEvent? event, List data) { @@ -428,6 +461,7 @@ class ClickDebouncer { void reset() { _state?.timer.cancel(); _state = null; + _isDebouncing = false; _lastSentPointerUpTimeStamp = null; } } diff --git a/engine/src/flutter/lib/web_ui/test/engine/pointer_binding_test.dart b/engine/src/flutter/lib/web_ui/test/engine/pointer_binding_test.dart index 1790ccdc1b427..09aca386c1b7e 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/pointer_binding_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/pointer_binding_test.dart @@ -2580,6 +2580,10 @@ void _testClickDebouncer({required PointerBinding Function() getBinding}) { late _PointerEventContext context; late PointerBinding binding; + Future nextEventLoop() { + return Future.delayed(Duration.zero); + } + void testWithSemantics( String description, Future Function() body, { @@ -2635,6 +2639,7 @@ void _testClickDebouncer({required PointerBinding Function() getBinding}) { view.dom.semanticsHost.appendChild(testElement); testElement.dispatchEvent(context.primaryDown()); + await nextEventLoop(); testElement.dispatchEvent(context.primaryUp()); expect(PointerBinding.clickDebouncer.isDebouncing, false); @@ -2646,6 +2651,71 @@ void _testClickDebouncer({required PointerBinding Function() getBinding}) { expect(semanticsActions, isEmpty); }); + testWithSemantics('Does not start debouncing if reset before scheduled execution', () async { + expect(EnginePlatformDispatcher.instance.semanticsEnabled, isTrue); + expect(PointerBinding.clickDebouncer.isDebouncing, isFalse); + expect(PointerBinding.clickDebouncer.debugState, isNull); + + final DomElement testElement = createDomElement('flt-semantics'); + testElement.setAttribute('flt-tappable', ''); + view.dom.semanticsHost.appendChild(testElement); + + // 1. Trigger _maybeStartDebouncing, which sets _isDebouncing = true and schedules _doStartDebouncing. + testElement.dispatchEvent(context.primaryDown()); + + // At this point, _isDebouncing is true, but _doStartDebouncing (which sets _state and creates the Timer) + // has not yet executed because it was scheduled with Timer.run(). + expect(PointerBinding.clickDebouncer.isDebouncing, isTrue); + expect(PointerBinding.clickDebouncer.debugState, isNull); // _state is still null + + // 2. Simulate a scenario where reset() is called before _doStartDebouncing gets a chance to run. + // This could happen due to a hot restart or other lifecycle events. + PointerBinding.clickDebouncer.reset(); + + // After reset(), _isDebouncing should be false and _state should still be null. + expect(PointerBinding.clickDebouncer.isDebouncing, isFalse); + expect(PointerBinding.clickDebouncer.debugState, isNull); + + // 3. Allow the scheduled _doStartDebouncing to run. With the fix, it should now check + // `!isDebouncing` and return early. + await nextEventLoop(); + + // Verify that _doStartDebouncing did not proceed to set _state or create a Timer. + expect(PointerBinding.clickDebouncer.isDebouncing, isFalse); + expect(PointerBinding.clickDebouncer.debugState, isNull); + + // Ensure no events were sent to the framework as debouncing was effectively cancelled. + expect(pointerPackets, isEmpty); + expect(semanticsActions, isEmpty); + }); + + testWithSemantics('Starts debouncing after event loop', () async { + expect(EnginePlatformDispatcher.instance.semanticsEnabled, isTrue); + expect(PointerBinding.clickDebouncer.isDebouncing, isFalse); + + final DomElement testElement = createDomElement('flt-semantics'); + testElement.setAttribute('flt-tappable', ''); + view.dom.semanticsHost.appendChild(testElement); + + testElement.dispatchEvent(context.primaryDown()); + // ClickDebouncer does not start debouncing right away. + expect(PointerBinding.clickDebouncer.isDebouncing, isTrue); + expect(PointerBinding.clickDebouncer.debugState, isNull); + // Instead, it waits until the end of the event loop. + await nextEventLoop(); + expect(PointerBinding.clickDebouncer.isDebouncing, isTrue); + expect(PointerBinding.clickDebouncer.debugState, isNotNull); + + final DomEvent click = createDomMouseEvent('click', { + 'clientX': testElement.getBoundingClientRect().x, + 'clientY': testElement.getBoundingClientRect().y, + }); + + PointerBinding.clickDebouncer.onClick(click, view.viewId, 42, true); + expect(pointerPackets, isEmpty); + expect(semanticsActions, [(type: ui.SemanticsAction.tap, nodeId: 42)]); + }); + testWithSemantics('Accumulates pointer events starting from pointerdown', () async { expect(EnginePlatformDispatcher.instance.semanticsEnabled, true); expect(PointerBinding.clickDebouncer.isDebouncing, false); @@ -2661,6 +2731,7 @@ void _testClickDebouncer({required PointerBinding Function() getBinding}) { true, ); + await nextEventLoop(); testElement.dispatchEvent(context.primaryUp()); expect( reason: 'Should still be debouncing after pointerup', @@ -2709,6 +2780,7 @@ void _testClickDebouncer({required PointerBinding Function() getBinding}) { true, ); + await nextEventLoop(); final DomElement newTarget = createDomElement('flt-semantics'); newTarget.setAttribute('flt-tappable', ''); view.dom.semanticsHost.appendChild(newTarget); @@ -2759,6 +2831,7 @@ void _testClickDebouncer({required PointerBinding Function() getBinding}) { testElement.dispatchEvent(context.primaryDown()); expect(PointerBinding.clickDebouncer.isDebouncing, true); + await nextEventLoop(); final DomEvent click = createDomMouseEvent('click', { 'clientX': testElement.getBoundingClientRect().x, 'clientY': testElement.getBoundingClientRect().y, @@ -2778,6 +2851,7 @@ void _testClickDebouncer({required PointerBinding Function() getBinding}) { testElement.dispatchEvent(context.primaryDown()); expect(PointerBinding.clickDebouncer.isDebouncing, true); + await nextEventLoop(); final DomEvent click = createDomMouseEvent('click', { 'clientX': testElement.getBoundingClientRect().x, 'clientY': testElement.getBoundingClientRect().y, From 01534568db3fba0ecd17442e3cf1c4ba1181b7de Mon Sep 17 00:00:00 2001 From: flutteractionsbot <154381524+flutteractionsbot@users.noreply.github.com> Date: Wed, 6 Aug 2025 12:45:58 -0700 Subject: [PATCH 15/23] [CP-beta]Suppress deprecated iOS windows API in integration_test (#173304) This pull request is created by [automatic cherry pick workflow](https://github.com/flutter/flutter/blob/main/docs/releases/Flutter-Cherrypick-Process.md#automatically-creates-a-cherry-pick-request) Please fill in the form below, and a flutter domain expert will evaluate this cherry pick request. ### Issue Link: What is the link to the issue this cherry-pick is addressing? https://github.com/flutter/flutter/issues/154365#issuecomment-3156593687 ### Changelog Description: Explain this cherry pick in one line that is accessible to most Flutter developers. See [best practices](https://github.com/flutter/flutter/blob/main/docs/releases/Hotfix-Documentation-Best-Practices.md) for examples Suppress an iOS deprecation warning to unblock Xcode 26 testing in CI ### Impact Description: What is the impact (ex. visual jank on Samsung phones, app crash, cannot ship an iOS app)? Does it impact development (ex. flutter doctor crashes when Android Studio is installed), or the shipping production app (the app crashes on launch) Blocks Xcode 26 from being tested for the pacakges repo in CI https://github.com/flutter/flutter/issues/170437 ### Workaround: Is there a workaround for this issue? No. Packages runs the Xcode analyzer against the Flutter master and stable branches, so this deprecation suppression must go to stable ASAP. ### Risk: What is the risk level of this cherry-pick? ### Test Coverage: Are you confident that your fix is well-tested by automated tests? Tested here: https://github.com/flutter/flutter/blob/de33a3b2ab94844b091e35cae1d30b853369d308/packages/integration_test/example/integration_test/matches_golden_test.dart#L43-L45 ### Validation Steps: What are the steps to validate that this fix works? Current CI analysis will pass with or without this suppression. However, we will no longer see failures like this in packages while upgrading to Xcode 26, or increasing the minimum target version. ``` integration_test/Sources/integration_test/IntegrationTestPlugin.m:76:55: error: 'windows' is deprecated: first deprecated in iOS 15.0 - Use UIWindowScene.windows on a relevant window scene instead [-Werror,-Wdeprecated-declarations] UIWindow *window = [UIApplication.sharedApplication.windows ``` https://logs.chromium.org/logs/flutter/buildbucket/cr-buildbucket/8738245141115055857/+/u/Run_package_tests/xcode_analyze_deprecation/stdout From https://github.com/flutter/packages/pull/7542 --- .../Sources/integration_test/IntegrationTestPlugin.m | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/integration_test/ios/integration_test/Sources/integration_test/IntegrationTestPlugin.m b/packages/integration_test/ios/integration_test/Sources/integration_test/IntegrationTestPlugin.m index 8ce71b580ded3..78313b848ef34 100644 --- a/packages/integration_test/ios/integration_test/Sources/integration_test/IntegrationTestPlugin.m +++ b/packages/integration_test/ios/integration_test/Sources/integration_test/IntegrationTestPlugin.m @@ -79,7 +79,12 @@ - (void)handleMethodCall:(FlutterMethodCall *)call result:(FlutterResult)result - (UIImage *)capturePngScreenshot { // Get all windows in the app +#pragma clang diagnostic push +#pragma clang diagnostic ignored "-Wdeprecated-declarations" + // TODO(jmagman) Use scenes instead of deprecated windows. See + // https://github.com/flutter/flutter/issues/154365 NSArray *windows = [UIApplication sharedApplication].windows; +#pragma clang diagnostic pop // Find the overall bounding rect for all windows CGRect screenBounds = [UIScreen mainScreen].bounds; From 91227618fdbf0b3abe4e2a7932e59d42d74b5a64 Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Thu, 7 Aug 2025 11:57:03 -0700 Subject: [PATCH 16/23] Update engine version for 3.35-.03 (#173371) Picks up two beta cherry picks: https://github.com/flutter/flutter/commits/flutter-3.35-candidate.0/ https://github.com/flutter/flutter/pull/173304 https://github.com/flutter/flutter/pull/173072 --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index d0da161faf563..60308569b8419 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -52cc75c62467e72200ab5cd54cee48b8313f6d86 +659d9553df45256ed2aa388aae7ed5a1a4f51bae From 40aecd76fc8b3ea1b2bacc4338882d1d1f44117a Mon Sep 17 00:00:00 2001 From: flutteractionsbot <154381524+flutteractionsbot@users.noreply.github.com> Date: Thu, 7 Aug 2025 16:16:47 -0700 Subject: [PATCH 17/23] [CP-beta][ios26]Do not report error for Info.plist key not found (#173438) This pull request is created by [automatic cherry pick workflow](https://github.com/flutter/flutter/blob/main/docs/releases/Flutter-Cherrypick-Process.md#automatically-creates-a-cherry-pick-request) Please fill in the form below, and a flutter domain expert will evaluate this cherry pick request. ### Issue Link: What is the link to the issue this cherry-pick is addressing? https://github.com/flutter/flutter/issues/172627 ### Changelog Description: Explain this cherry pick in one line that is accessible to most Flutter developers. See [best practices](https://github.com/flutter/flutter/blob/main/docs/releases/Hotfix-Documentation-Best-Practices.md) for examples Prevents a non-fatal error from causing Xcode compilation failures on macOS 26. ### Impact Description: What is the impact (ex. visual jank on Samsung phones, app crash, cannot ship an iOS app)? Does it impact development (ex. flutter doctor crashes when Android Studio is installed), or the shipping production app (the app crashes on launch) Building iOS flutter apps may crash with Xcode 26. ### Workaround: Is there a workaround for this issue? Add NSBonjourServices and NSLocalNetworkUsageDescription settings to your iOS app's Info.plist. ### Risk: What is the risk level of this cherry-pick? ### Test Coverage: Are you confident that your fix is well-tested by automated tests? ### Validation Steps: What are the steps to validate that this fix works? 1. `flutter create` a new app on macOS 26 beta 2. Install Xcode 26 beta 5 3. `flutter run` --- packages/flutter_tools/bin/xcode_backend.dart | 14 +- .../general.shard/xcode_backend_test.dart | 130 ++++++++++++++++-- .../integration.shard/xcode_backend_test.dart | 3 + 3 files changed, 136 insertions(+), 11 deletions(-) diff --git a/packages/flutter_tools/bin/xcode_backend.dart b/packages/flutter_tools/bin/xcode_backend.dart index 6be047f7dd385..857fa90de2f24 100644 --- a/packages/flutter_tools/bin/xcode_backend.dart +++ b/packages/flutter_tools/bin/xcode_backend.dart @@ -115,14 +115,17 @@ class Context { if (verbose) { print('♦ $bin ${args.join(' ')}'); } - final ProcessResult result = Process.runSync(bin, args, workingDirectory: workingDirectory); + final ProcessResult result = runSyncProcess(bin, args, workingDirectory: workingDirectory); if (verbose) { print((result.stdout as String).trim()); } final String resultStderr = result.stderr.toString().trim(); if (resultStderr.isNotEmpty) { final errorOutput = StringBuffer(); - if (result.exitCode != 0) { + // If allowFail, do not fail Xcode build. An example is on macOS 26, + // plutil reports NSBonjourServices key not found via stderr (rather than + // stdout on older macOS), and it should not cause compile failure. + if (!allowFail && result.exitCode != 0) { // "error:" prefix makes this show up as an Xcode compilation error. errorOutput.write('error: '); } @@ -144,6 +147,13 @@ class Context { return result; } + // TODO(hellohuanlin): Instead of using inheritance to stub the function in + // the subclass, we should favor composition by injecting the dependencies. + // See: https://github.com/flutter/flutter/issues/173133 + ProcessResult runSyncProcess(String bin, List args, {String? workingDirectory}) { + return Process.runSync(bin, args, workingDirectory: workingDirectory); + } + /// Log message to stderr. void echoError(String message) { stderr.writeln(message); diff --git a/packages/flutter_tools/test/general.shard/xcode_backend_test.dart b/packages/flutter_tools/test/general.shard/xcode_backend_test.dart index d4308cf176c2e..4548261e6071d 100644 --- a/packages/flutter_tools/test/general.shard/xcode_backend_test.dart +++ b/packages/flutter_tools/test/general.shard/xcode_backend_test.dart @@ -370,8 +370,8 @@ void main() { group('test_vm_service_bonjour_service', () { test('handles when the Info.plist is missing', () { - final Directory buildDir = fileSystem.directory('/path/to/builds'); - buildDir.createSync(recursive: true); + final Directory buildDir = fileSystem.directory('/path/to/builds') + ..createSync(recursive: true); final context = TestContext( ['test_vm_service_bonjour_service'], { @@ -389,6 +389,124 @@ void main() { ), ); }); + + test('Missing NSBonjourServices key in Info.plist should not fail Xcode compilation', () { + final Directory buildDir = fileSystem.directory('/path/to/builds') + ..createSync(recursive: true); + final File infoPlist = buildDir.childFile('Info.plist')..createSync(); + final context = TestContext( + ['test_vm_service_bonjour_service'], + { + 'CONFIGURATION': 'Debug', + 'BUILT_PRODUCTS_DIR': buildDir.path, + 'INFOPLIST_PATH': 'Info.plist', + }, + commands: [ + FakeCommand( + command: [ + 'plutil', + '-extract', + 'NSBonjourServices', + 'xml1', + '-o', + '-', + infoPlist.path, + ], + exitCode: 1, + stderr: 'No value at that key path or invalid key path: NSBonjourServices', + ), + FakeCommand( + command: [ + 'plutil', + '-insert', + 'NSBonjourServices', + '-json', + '["_dartVmService._tcp"]', + infoPlist.path, + ], + ), + FakeCommand( + command: [ + 'plutil', + '-extract', + 'NSLocalNetworkUsageDescription', + 'xml1', + '-o', + '-', + infoPlist.path, + ], + ), + ], + fileSystem: fileSystem, + )..run(); + expect(context.stderr, isNot(contains('error: '))); + }); + + test( + 'Missing NSLocalNetworkUsageDescription in Info.plist should not fail Xcode compilation', + () { + final Directory buildDir = fileSystem.directory('/path/to/builds') + ..createSync(recursive: true); + final File infoPlist = buildDir.childFile('Info.plist')..createSync(); + final context = TestContext( + ['test_vm_service_bonjour_service'], + { + 'CONFIGURATION': 'Debug', + 'BUILT_PRODUCTS_DIR': buildDir.path, + 'INFOPLIST_PATH': 'Info.plist', + }, + commands: [ + FakeCommand( + command: [ + 'plutil', + '-extract', + 'NSBonjourServices', + 'xml1', + '-o', + '-', + infoPlist.path, + ], + ), + FakeCommand( + command: [ + 'plutil', + '-insert', + 'NSBonjourServices.0', + '-string', + '_dartVmService._tcp', + infoPlist.path, + ], + ), + FakeCommand( + command: [ + 'plutil', + '-extract', + 'NSLocalNetworkUsageDescription', + 'xml1', + '-o', + '-', + infoPlist.path, + ], + exitCode: 1, + stderr: + 'No value at that key path or invalid key path: NSLocalNetworkUsageDescription', + ), + FakeCommand( + command: [ + 'plutil', + '-insert', + 'NSLocalNetworkUsageDescription', + '-string', + 'Allow Flutter tools on your computer to connect and debug your application. This prompt will not appear on release builds.', + infoPlist.path, + ], + ), + ], + fileSystem: fileSystem, + )..run(); + expect(context.stderr, isNot(contains('error: '))); + }, + ); }); for (final platform in platforms) { @@ -1054,13 +1172,7 @@ class TestContext extends Context { } @override - ProcessResult runSync( - String bin, - List args, { - bool verbose = false, - bool allowFail = false, - String? workingDirectory, - }) { + ProcessResult runSyncProcess(String bin, List args, {String? workingDirectory}) { return processManager.runSync( [bin, ...args], workingDirectory: workingDirectory, diff --git a/packages/flutter_tools/test/integration.shard/xcode_backend_test.dart b/packages/flutter_tools/test/integration.shard/xcode_backend_test.dart index 01b3f78e6869b..55afb30b11f59 100644 --- a/packages/flutter_tools/test/integration.shard/xcode_backend_test.dart +++ b/packages/flutter_tools/test/integration.shard/xcode_backend_test.dart @@ -150,6 +150,7 @@ void main() { expect(actualInfoPlist, contains('dartVmService')); expect(actualInfoPlist, contains('NSLocalNetworkUsageDescription')); + expect(result.stderr, isNot(startsWith('error:'))); expect(result, const ProcessResultMatcher()); }); } @@ -196,6 +197,8 @@ void main() { '''); + + expect(result.stderr, isNot(startsWith('error:'))); expect(result, const ProcessResultMatcher()); }, ); From a10c95095d17467b8108dde3c6beb8e39d64941b Mon Sep 17 00:00:00 2001 From: flutteractionsbot <154381524+flutteractionsbot@users.noreply.github.com> Date: Thu, 7 Aug 2025 16:30:21 -0700 Subject: [PATCH 18/23] [CP-beta]Prepare for iOS debugging with lldb and devicectl (#173439) This pull request is created by [automatic cherry pick workflow](https://github.com/flutter/flutter/blob/main/docs/releases/Flutter-Cherrypick-Process.md#automatically-creates-a-cherry-pick-request) Please fill in the form below, and a flutter domain expert will evaluate this cherry pick request. ### Issue Link: Part 1 of https://github.com/flutter/flutter/issues/144218 ### Changelog Description: Explain this cherry pick in one line that is accessible to most Flutter developers. See [best practices](https://github.com/flutter/flutter/blob/main/docs/releases/Hotfix-Documentation-Best-Practices.md) for examples Preliminary work for a future fix to allow Xcode 26 to `flutter run` twice in a row. ### Impact Description: What is the impact (ex. visual jank on Samsung phones, app crash, cannot ship an iOS app)? Does it impact development (ex. flutter doctor crashes when Android Studio is installed), or the shipping production app (the app crashes on launch) Flutter developers running Xcode 26 can `flutter run` to a tethered iOS device once. However subsequent `flutter run` attempts are likely to fail. This is the first of several PRs to work around that issue. ### Workaround: Is there a workaround for this issue? Quitting and reopening Xcode. ### Risk: What is the risk level of this cherry-pick? ### Test Coverage: Are you confident that your fix is well-tested by automated tests? ### Validation Steps: What are the steps to validate that this fix works? N/A. This PR adds the classes and functions needed for a future commit, which will turn the new feature on. --- .../lib/src/ios/core_devices.dart | 399 ++++ .../flutter_tools/lib/src/ios/devices.dart | 6 + packages/flutter_tools/lib/src/ios/lldb.dart | 336 +++ .../lib/src/ios/xcode_debug.dart | 18 + .../flutter_tools/lib/src/macos/xcdevice.dart | 7 + .../flutter_tools/lib/src/xcode_project.dart | 17 + .../general.shard/ios/core_devices_test.dart | 1850 +++++++++++++++-- .../test/general.shard/ios/devices_test.dart | 28 + .../ios/ios_device_install_test.dart | 3 + .../ios/ios_device_project_test.dart | 3 + .../ios_device_start_nonprebuilt_test.dart | 4 + .../ios/ios_device_start_prebuilt_test.dart | 3 + .../test/general.shard/ios/lldb_test.dart | 502 +++++ .../general.shard/xcode_project_test.dart | 63 + 14 files changed, 3104 insertions(+), 135 deletions(-) create mode 100644 packages/flutter_tools/lib/src/ios/lldb.dart create mode 100644 packages/flutter_tools/test/general.shard/ios/lldb_test.dart diff --git a/packages/flutter_tools/lib/src/ios/core_devices.dart b/packages/flutter_tools/lib/src/ios/core_devices.dart index 28820446439fb..418f0559a5951 100644 --- a/packages/flutter_tools/lib/src/ios/core_devices.dart +++ b/packages/flutter_tools/lib/src/ios/core_devices.dart @@ -10,9 +10,225 @@ import '../base/file_system.dart'; import '../base/io.dart'; import '../base/logger.dart'; import '../base/process.dart'; +import '../base/template.dart'; import '../convert.dart'; import '../device.dart'; import '../macos/xcode.dart'; +import '../project.dart'; +import 'application_package.dart'; +import 'lldb.dart'; +import 'xcode_debug.dart'; + +/// Provides methods for launching and debugging apps on physical iOS CoreDevices. +/// +/// CoreDevice is a device connectivity stack introduced in Xcode 15. Devices +/// with iOS 17 or greater are CoreDevices. +/// +/// This class handles launching apps with different methods: +/// - [launchAppWithoutDebugger]: Uses `devicectl` to install and launch the app without a debugger. +/// - [launchAppWithLLDBDebugger]: Uses `devicectl` to install and launch the app, then attaches an LLDB debugger. +/// - [launchAppWithXcodeDebugger]: Uses Xcode automation to install, launch, and debug the app. +class IOSCoreDeviceLauncher { + IOSCoreDeviceLauncher({ + required IOSCoreDeviceControl coreDeviceControl, + required Logger logger, + required XcodeDebug xcodeDebug, + required FileSystem fileSystem, + required ProcessUtils processUtils, + @visibleForTesting LLDB? lldb, + }) : _coreDeviceControl = coreDeviceControl, + _logger = logger, + _xcodeDebug = xcodeDebug, + _fileSystem = fileSystem, + _lldb = lldb ?? LLDB(logger: logger, processUtils: processUtils); + + final IOSCoreDeviceControl _coreDeviceControl; + final Logger _logger; + final XcodeDebug _xcodeDebug; + final FileSystem _fileSystem; + final LLDB _lldb; + + /// Install and launch the app on the device with `devicectl` ([_coreDeviceControl]) + /// and do not attach a debugger. This is generally only used for release mode. + Future launchAppWithoutDebugger({ + required String deviceId, + required String bundlePath, + required String bundleId, + required List launchArguments, + }) async { + // Install app to device + final bool installSuccess = await _coreDeviceControl.installApp( + deviceId: deviceId, + bundlePath: bundlePath, + ); + if (!installSuccess) { + return installSuccess; + } + + // Launch app to device + final IOSCoreDeviceLaunchResult? launchResult = await _coreDeviceControl.launchAppInternal( + deviceId: deviceId, + bundleId: bundleId, + launchArguments: launchArguments, + ); + + if (launchResult == null || launchResult.outcome != 'success') { + return false; + } + + return true; + } + + /// Install and launch the app on the device with `devicectl` ([_coreDeviceControl]) + /// and then attach a LLDB debugger ([_lldb]). + /// + /// Requires Xcode 16+. + Future launchAppWithLLDBDebugger({ + required String deviceId, + required String bundlePath, + required String bundleId, + required List launchArguments, + }) async { + // Install app to device + final bool installSuccess = await _coreDeviceControl.installApp( + deviceId: deviceId, + bundlePath: bundlePath, + ); + if (!installSuccess) { + return installSuccess; + } + + // Launch app on device, but start it stopped so it will wait until the debugger is attached before starting. + final IOSCoreDeviceLaunchResult? launchResult = await _coreDeviceControl.launchAppInternal( + deviceId: deviceId, + bundleId: bundleId, + launchArguments: launchArguments, + startStopped: true, + ); + + if (launchResult == null || launchResult.outcome != 'success') { + return false; + } + + final IOSCoreDeviceRunningProcess? launchedProcess = launchResult.process; + final int? processId = launchedProcess?.processIdentifier; + if (launchedProcess == null || processId == null) { + return false; + } + + // Start LLDB and attach to the device process. + final bool attachStatus = await _lldb.attachAndStart(deviceId, processId); + + // If it fails to attach with lldb, kill the launched process so it doesn't stay hanging. + if (!attachStatus) { + await stopApp(deviceId: deviceId, processId: processId); + return false; + } + return attachStatus; + } + + /// Install and launch the app on the device through Xcode using Mac Automation ([_xcodeDebug]). + Future launchAppWithXcodeDebugger({ + required String deviceId, + required DebuggingOptions debuggingOptions, + required IOSApp package, + required List launchArguments, + required TemplateRenderer templateRenderer, + String? mainPath, + @visibleForTesting Duration? discoveryTimeout, + }) async { + XcodeDebugProject? debugProject; + + if (package is PrebuiltIOSApp) { + debugProject = await _xcodeDebug.createXcodeProjectWithCustomBundle( + package.deviceBundlePath, + templateRenderer: templateRenderer, + verboseLogging: _logger.isVerbose, + ); + } else if (package is BuildableIOSApp) { + final IosProject project = package.project; + final Directory bundle = _fileSystem.directory(package.deviceBundlePath); + final Directory? xcodeWorkspace = project.xcodeWorkspace; + if (xcodeWorkspace == null) { + _logger.printTrace('Unable to get Xcode workspace.'); + return false; + } + final String? scheme = await project.schemeForBuildInfo( + debuggingOptions.buildInfo, + logger: _logger, + ); + if (scheme == null) { + return false; + } + _xcodeDebug.ensureXcodeDebuggerLaunchAction(project.xcodeProjectSchemeFile(scheme: scheme)); + + // Before installing/launching/debugging with Xcode, update the build + // settings to use a custom configuration build directory so Xcode + // knows where to find the app bundle to launch. + await _xcodeDebug.updateConfigurationBuildDir( + project: project.parent, + buildInfo: debuggingOptions.buildInfo, + configurationBuildDir: bundle.parent.absolute.path, + ); + + debugProject = XcodeDebugProject( + scheme: scheme, + xcodeProject: project.xcodeProject, + xcodeWorkspace: xcodeWorkspace, + hostAppProjectName: project.hostAppProjectName, + expectedConfigurationBuildDir: bundle.parent.absolute.path, + verboseLogging: _logger.isVerbose, + ); + } else { + // This should not happen. Currently, only PrebuiltIOSApp and + // BuildableIOSApp extend from IOSApp. + _logger.printTrace('IOSApp type ${package.runtimeType} is not recognized.'); + return false; + } + + // Core Devices (iOS 17 devices) are debugged through Xcode so don't + // include these flags, which are used to check if the app was launched + // via Flutter CLI and `ios-deploy`. + launchArguments.removeWhere( + (String arg) => arg == '--enable-checked-mode' || arg == '--verify-entry-points', + ); + + final bool debugSuccess = await _xcodeDebug.debugApp( + project: debugProject, + deviceId: deviceId, + launchArguments: launchArguments, + ); + + return debugSuccess; + } + + /// Stop the app depending on how it was launched. + /// + /// Returns `false` if the stop process fails or if there is no process to stop. + Future stopApp({required String deviceId, int? processId}) async { + if (_xcodeDebug.debugStarted) { + return _xcodeDebug.exit(); + } + + int? processToStop; + if (_lldb.isRunning) { + processToStop = _lldb.appProcessId; + // Exit the lldb process so it doesn't process any kill signals before + // the app is killed by devicectl. + _lldb.exit(); + } else { + processToStop = processId; + } + + if (processToStop == null) { + return false; + } + + // Killing the lldb process may not kill the app process. Kill it with + // devicectl to ensure it stops. + return _coreDeviceControl.terminateProcess(deviceId: deviceId, processId: processToStop); + } +} /// A wrapper around the `devicectl` command line tool. /// @@ -367,6 +583,122 @@ class IOSCoreDeviceControl { tempDirectory.deleteSync(recursive: true); } } + + /// Launches the app on the device. + /// + /// If [startStopped] is true, the app will be launched and paused, waiting + /// for a debugger to attach. + // TODO(vashworth): Rename this method to launchApp and replace old version. + // See https://github.com/flutter/flutter/issues/173416. + @visibleForTesting + Future launchAppInternal({ + required String deviceId, + required String bundleId, + List launchArguments = const [], + bool startStopped = false, + }) async { + if (!_xcode.isDevicectlInstalled) { + _logger.printTrace('devicectl is not installed.'); + return null; + } + + final Directory tempDirectory = _fileSystem.systemTempDirectory.createTempSync('core_devices.'); + final File output = tempDirectory.childFile('launch_results.json'); + output.createSync(); + + final command = [ + ..._xcode.xcrunCommand(), + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + deviceId, + if (startStopped) '--start-stopped', + bundleId, + if (launchArguments.isNotEmpty) ...launchArguments, + '--json-output', + output.path, + ]; + + try { + await _processUtils.run(command, throwOnError: true); + final String stringOutput = output.readAsStringSync(); + + try { + final result = IOSCoreDeviceLaunchResult.fromJson( + json.decode(stringOutput) as Map, + ); + if (result.outcome == null) { + _logger.printTrace('devicectl returned unexpected JSON response: $stringOutput'); + return null; + } + return result; + } on FormatException { + // We failed to parse the devicectl output, or it returned junk. + _logger.printTrace('devicectl returned non-JSON response: $stringOutput'); + return null; + } + } on ProcessException catch (err) { + _logger.printTrace('Error executing devicectl: $err'); + return null; + } finally { + tempDirectory.deleteSync(recursive: true); + } + } + + /// Terminate the [processId] on the device using `devicectl`. + Future terminateProcess({required String deviceId, required int processId}) async { + if (!_xcode.isDevicectlInstalled) { + _logger.printTrace('devicectl is not installed.'); + return false; + } + + final Directory tempDirectory = _fileSystem.systemTempDirectory.createTempSync('core_devices.'); + final File output = tempDirectory.childFile('terminate_results.json'); + output.createSync(); + + final command = [ + ..._xcode.xcrunCommand(), + 'devicectl', + 'device', + 'process', + 'terminate', + '--device', + deviceId, + '--pid', + processId.toString(), + '--kill', + '--json-output', + output.path, + ]; + + try { + await _processUtils.run(command, throwOnError: true); + final String stringOutput = output.readAsStringSync(); + + try { + final Object? decodeResult = (json.decode(stringOutput) as Map)['info']; + if (decodeResult is Map && decodeResult['outcome'] == 'success') { + return true; + } + _logger.printTrace('devicectl returned unexpected JSON response: $stringOutput'); + return false; + } on FormatException { + // We failed to parse the devicectl output, or it returned junk. + _logger.printTrace('devicectl returned non-JSON response: $stringOutput'); + return false; + } on TypeError { + _logger.printTrace('devicectl returned unexpected JSON response: $stringOutput'); + return false; + } + } on ProcessException catch (err) { + _logger.printTrace('Error executing devicectl: $err'); + return false; + } finally { + tempDirectory.deleteSync(recursive: true); + } + } } class IOSCoreDevice { @@ -844,3 +1176,70 @@ class IOSCoreDeviceInstalledApp { final String? url; final String? version; } + +class IOSCoreDeviceLaunchResult { + IOSCoreDeviceLaunchResult._({required this.outcome, required this.process}); + + /// Parse JSON from `devicectl device process launch --device --json-output`. + /// + /// Example: + /// { + /// "info" : { + /// ... + /// "outcome" : "success", + /// }, + /// "result" : { + /// ... + /// "process" : { + /// ... + /// "executable" : "file:////private/var/containers/Bundle/Application/D12EFD3B-4567-890E-B1F2-23456DAA789A/Runner.app/Runner", + /// "processIdentifier" : 14306 + /// } + /// } + /// } + factory IOSCoreDeviceLaunchResult.fromJson(Map data) { + String? outcome; + IOSCoreDeviceRunningProcess? process; + final Object? info = data['info']; + if (info is Map) { + outcome = info['outcome'] as String?; + } + + final Object? result = data['result']; + if (result is Map) { + final Object? processObject = result['process']; + if (processObject is Map) { + process = IOSCoreDeviceRunningProcess.fromJson(processObject); + } + } + + return IOSCoreDeviceLaunchResult._(outcome: outcome, process: process); + } + + final String? outcome; + final IOSCoreDeviceRunningProcess? process; +} + +class IOSCoreDeviceRunningProcess { + IOSCoreDeviceRunningProcess._({required this.executable, required this.processIdentifier}); + + //// Parse `process` section of JSON from `devicectl device process launch --device --json-output`. + /// + /// Example: + /// "process" : { + /// ... + /// "executable" : "file:////private/var/containers/Bundle/Application/D12EFD3B-4567-890E-B1F2-23456DAA789A/Runner.app/Runner", + /// "processIdentifier" : 14306 + /// } + factory IOSCoreDeviceRunningProcess.fromJson(Map data) { + return IOSCoreDeviceRunningProcess._( + executable: data['executable']?.toString(), + processIdentifier: data['processIdentifier'] is int? + ? data['processIdentifier'] as int? + : null, + ); + } + + final String? executable; + final int? processIdentifier; +} diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart index 2ed752bab5abb..98b4e87d6e645 100644 --- a/packages/flutter_tools/lib/src/ios/devices.dart +++ b/packages/flutter_tools/lib/src/ios/devices.dart @@ -280,6 +280,7 @@ class IOSDevice extends Device { required IOSDeploy iosDeploy, required IMobileDevice iMobileDevice, required IOSCoreDeviceControl coreDeviceControl, + required IOSCoreDeviceLauncher coreDeviceLauncher, required XcodeDebug xcodeDebug, required IProxy iProxy, required super.logger, @@ -287,6 +288,7 @@ class IOSDevice extends Device { _iosDeploy = iosDeploy, _iMobileDevice = iMobileDevice, _coreDeviceControl = coreDeviceControl, + _coreDeviceLauncher = coreDeviceLauncher, _xcodeDebug = xcodeDebug, _iproxy = iProxy, _fileSystem = fileSystem, @@ -306,6 +308,10 @@ class IOSDevice extends Device { final Platform _platform; final IMobileDevice _iMobileDevice; final IOSCoreDeviceControl _coreDeviceControl; + + // TODO(vashworth): See https://github.com/flutter/flutter/issues/173416. + // ignore: unused_field + final IOSCoreDeviceLauncher _coreDeviceLauncher; final XcodeDebug _xcodeDebug; final IProxy _iproxy; diff --git a/packages/flutter_tools/lib/src/ios/lldb.dart b/packages/flutter_tools/lib/src/ios/lldb.dart new file mode 100644 index 0000000000000..76912f3d9a1c9 --- /dev/null +++ b/packages/flutter_tools/lib/src/ios/lldb.dart @@ -0,0 +1,336 @@ +// 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 '../xcode_project.dart'; +library; + +import 'dart:async'; + +import '../base/io.dart'; +import '../base/logger.dart'; +import '../base/process.dart'; +import '../convert.dart'; + +/// LLDB is the default debugger in Xcode on macOS. Once the application has +/// launched on a physical iOS device, you can attach to it using LLDB. +/// +/// See `xcrun devicectl device process launch --help` for more information. +class LLDB { + LLDB({required Logger logger, required ProcessUtils processUtils}) + : _logger = logger, + _processUtils = processUtils; + + final Logger _logger; + final ProcessUtils _processUtils; + + _LLDBProcess? _lldbProcess; + + /// Whether or not a LLDB process is running. + bool get isRunning => _lldbProcess != null; + + /// The process id of the application running on the iOS device. + int? get appProcessId => _lldbProcess?.appProcessId; + + _LLDBLogPatternCompleter? _logCompleter; + + /// Pattern of lldb log when the process is stopped. + /// + /// Example: (lldb) Process 6152 stopped + static final _lldbProcessStopped = RegExp(r'Process \d* stopped'); + + /// Pattern of lldb log when the process is resuming. + /// + /// Example: (lldb) Process 6152 resuming + static final _lldbProcessResuming = RegExp(r'Process \d+ resuming'); + + /// Pattern of lldb log when the breakpoint is added. + /// + /// Example: Breakpoint 1: no locations (pending). + static final _breakpointPattern = RegExp(r'Breakpoint (\d+)*:'); + + /// Breakpoint script required for JIT on iOS. + /// + /// This should match the "handle_new_rx_page" function in [IosProject._lldbPythonHelperTemplate]. + static const _pythonScript = ''' +"""Intercept NOTIFY_DEBUGGER_ABOUT_RX_PAGES and touch the pages.""" +base = frame.register["x0"].GetValueAsAddress() +page_len = frame.register["x1"].GetValueAsUnsigned() + +# Note: NOTIFY_DEBUGGER_ABOUT_RX_PAGES will check contents of the +# first page to see if handled it correctly. This makes diagnosing +# misconfiguration (e.g. missing breakpoint) easier. +data = bytearray(page_len) +data[0:8] = b'IHELPED!' + +error = lldb.SBError() +frame.GetThread().GetProcess().WriteMemory(base, data, error) +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]. + /// This will start a debugserver on the device, which is required for JIT. + Future attachAndStart(String deviceId, int appProcessId) 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 bool start = await _startLLDB(appProcessId); + if (!start) { + return false; + } + await _selectDevice(deviceId); + await _setBreakpoint(); + await _attachToAppProcess(appProcessId); + await _resumeProcess(); + } on _LLDBError catch (e) { + _logger.printError('lldb failed with error: ${e.message}'); + exit(); + return false; + } finally { + timer?.cancel(); + } + return true; + } + + /// Starts LLDB process and leave it running. + /// + /// Streams `stdout` and `stderr`. When receiving a log from `stdout`, check + /// if it matches the pattern [_logCompleter] is waiting for. If a log is sent + /// to `stderr`, complete with an error and stop the process. + Future _startLLDB(int appProcessId) async { + if (_lldbProcess != null) { + _logger.printError( + 'An LLDB process is already running. It must be stopped before starting a new one.', + ); + return false; + } + try { + _lldbProcess = _LLDBProcess( + process: await _processUtils.start(['lldb']), + appProcessId: appProcessId, + logger: _logger, + ); + + final StreamSubscription stdoutSubscription = _lldbProcess!.stdout + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((String line) { + _logger.printTrace('[lldb]: $line'); + _logCompleter?.checkForMatch(line); + }); + + final StreamSubscription stderrSubscription = _lldbProcess!.stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((String line) { + _logger.printError('[lldb]: $line'); + _monitorError(line); + }); + + unawaited( + _lldbProcess!.exitCode + .then((int status) async { + _logger.printTrace('lldb exited with code $status'); + await stdoutSubscription.cancel(); + await stderrSubscription.cancel(); + }) + .whenComplete(() async { + _lldbProcess = null; + }), + ); + } on ProcessException catch (exception) { + _logger.printError('Process exception running lldb:\n$exception'); + return false; + } + return true; + } + + /// Kill [_lldbProcess] if available and set it to null. + bool exit() { + final bool success = (_lldbProcess == null) || _lldbProcess!.kill(); + _lldbProcess = null; + _logCompleter = null; + return success; + } + + /// Selects a device for LLDB to interact with. + Future _selectDevice(String deviceId) async { + await _lldbProcess?.stdinWriteln('device select $deviceId'); + } + + /// 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); + + await _lldbProcess?.stdinWriteln('device process attach --pid $appProcessId'); + await futureLog; + } + + /// 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); + + 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); + if (breakpointId == null) { + throw _LLDBError('LLDB failed to get breakpoint from log: $log'); + } + + // Once it has the breakpoint id, set the python script. + // For more information, see: lldb > help break command add + await _lldbProcess?.stdinWriteln('breakpoint command add --script-type python $breakpointId'); + await _lldbProcess?.stdinWriteln(_pythonScript); + await _lldbProcess?.stdinWriteln('DONE'); + } + + /// Resume the stopped process. + Future _resumeProcess() async { + final Future futureLog = _startWaitingForLog( + _lldbProcessResuming, + ).then((value) => value, onError: _handleAsyncError); + + await _lldbProcess?.stdinWriteln('process continue'); + await futureLog; + } + + /// Creates a completer and returns its future. Methods that utilize this should + /// start waiting for the log before writing to stdin to avoid race conditions. + /// + /// When the [_lldbProcess]'s `stdout` receives a log that matches the [pattern], + /// the future will complete. + Future _startWaitingForLog(RegExp pattern) async { + if (_lldbProcess == null) { + throw _LLDBError('LLDB is not running.'); + } + _logCompleter = _LLDBLogPatternCompleter(pattern); + return _logCompleter!.future; + } + + Future _handleAsyncError(Object error) async { + if (error is _LLDBError) { + throw error; + } + throw _LLDBError('Unexpected error when waiting for lldb.'); + } + + /// Checks if [error] is a fatal error and stops the process if so. + void _monitorError(String error) { + // The LLDB process does not stop when it receives these errors but is no + // longer debugging the application. When one of these errors is received, + // stop the LLDB process. + final fatalErrors = [ + "error: 'device' is not a valid command.", + "no device selected: use 'device select ' to select a device.", + 'The specified device was not found.', + 'Timeout while connecting to remote device.', + 'Internal logic error: Connection was invalidated', + ]; + + if (fatalErrors.contains(error)) { + _logCompleter?.completeError(_LLDBError(error)); + exit(); + } + } +} + +class _LLDBError implements Exception { + _LLDBError(this.message); + + final String message; +} + +/// A completer that waits for a log line to match a pattern. +class _LLDBLogPatternCompleter { + _LLDBLogPatternCompleter(this._pattern); + + final RegExp _pattern; + final _completer = Completer(); + + Future get future => _completer.future; + + void checkForMatch(String line) { + if (_completer.isCompleted) { + return; + } + if (_pattern.hasMatch(line)) { + _completer.complete(line); + } + } + + void completeError(Object error, [StackTrace? stackTrace]) { + if (!_completer.isCompleted) { + _completer.completeError(error, stackTrace); + } + } +} + +/// A container class for associating a [Process] that is 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; + + final Process _lldbProcess; + final int appProcessId; + + final Logger _logger; + + Stream> get stdout => _lldbProcess.stdout; + + Stream> get stderr => _lldbProcess.stderr; + + Future get exitCode => _lldbProcess.exitCode; + + Future? _stdinWriteFuture; + + bool kill() { + return _lldbProcess.kill(); + } + + /// Writes [line] to [_lldbProcess]'s `stdin` and catches exceptions + /// (see https://github.com/flutter/flutter/pull/139784). + Future stdinWriteln(String line, {void Function(Object, StackTrace)? onError}) async { + Future writeln() { + return ProcessUtils.writelnToStdinGuarded( + stdin: _lldbProcess.stdin, + line: line, + onError: + onError ?? + (Object error, _) { + _logger.printTrace('Could not write "$line" to stdin: $error'); + }, + ); + } + + _stdinWriteFuture = _stdinWriteFuture?.then((_) => writeln()) ?? writeln(); + return _stdinWriteFuture; + } +} diff --git a/packages/flutter_tools/lib/src/ios/xcode_debug.dart b/packages/flutter_tools/lib/src/ios/xcode_debug.dart index 2103bcf51a645..aec8d559e4712 100644 --- a/packages/flutter_tools/lib/src/ios/xcode_debug.dart +++ b/packages/flutter_tools/lib/src/ios/xcode_debug.dart @@ -16,9 +16,12 @@ import '../base/io.dart'; import '../base/logger.dart'; import '../base/process.dart'; import '../base/template.dart'; +import '../build_info.dart'; import '../convert.dart'; import '../macos/xcode.dart'; +import '../project.dart'; import '../template.dart'; +import 'xcode_build_settings.dart'; /// A class to handle interacting with Xcode via OSA (Open Scripting Architecture) /// Scripting to debug Flutter applications. @@ -433,6 +436,21 @@ and ensure "Debug executable" is checked in the "Info" tab. _logger.printError('Failed to parse ${schemeFile.path}: $exception'); } } + + /// Update CONFIGURATION_BUILD_DIR in the [project]'s Xcode build settings. + Future updateConfigurationBuildDir({ + required FlutterProject project, + required BuildInfo buildInfo, + String? mainPath, + required String configurationBuildDir, + }) async { + await updateGeneratedXcodeProperties( + project: project, + buildInfo: buildInfo, + targetOverride: mainPath, + configurationBuildDir: configurationBuildDir, + ); + } } @visibleForTesting diff --git a/packages/flutter_tools/lib/src/macos/xcdevice.dart b/packages/flutter_tools/lib/src/macos/xcdevice.dart index 66ca4b9bceeb1..687784cb37f19 100644 --- a/packages/flutter_tools/lib/src/macos/xcdevice.dart +++ b/packages/flutter_tools/lib/src/macos/xcdevice.dart @@ -605,6 +605,13 @@ class XCDevice { iosDeploy: _iosDeploy, iMobileDevice: _iMobileDevice, coreDeviceControl: _coreDeviceControl, + coreDeviceLauncher: IOSCoreDeviceLauncher( + coreDeviceControl: _coreDeviceControl, + logger: _logger, + xcodeDebug: _xcodeDebug, + fileSystem: globals.fs, + processUtils: _processUtils, + ), xcodeDebug: _xcodeDebug, platform: globals.platform, devModeEnabled: devModeEnabled, diff --git a/packages/flutter_tools/lib/src/xcode_project.dart b/packages/flutter_tools/lib/src/xcode_project.dart index d7f2f6d09672d..afa85e5f48f67 100644 --- a/packages/flutter_tools/lib/src/xcode_project.dart +++ b/packages/flutter_tools/lib/src/xcode_project.dart @@ -7,6 +7,7 @@ library; import 'base/error_handling_io.dart'; import 'base/file_system.dart'; +import 'base/logger.dart'; import 'base/template.dart'; import 'base/utils.dart'; import 'base/version.dart'; @@ -205,6 +206,22 @@ abstract class XcodeBasedProject extends FlutterProjectPlatform { XcodeProjectInfo? _projectInfo; + /// Get the scheme using the Xcode's project [XcodeProjectInfo.schemes] and + /// the [BuildInfo.flavor]. + Future schemeForBuildInfo(BuildInfo buildInfo, {Logger? logger}) async { + final XcodeProjectInfo? info = await projectInfo(); + if (info == null) { + logger?.printError('Xcode project info not found.'); + return null; + } + + final String? scheme = info.schemeFor(buildInfo); + if (scheme == null) { + info.reportFlavorNotFoundAndExit(); + } + return scheme; + } + /// The build settings for the host app of this project, as a detached map. /// /// Returns null, if Xcode tooling is unavailable. diff --git a/packages/flutter_tools/test/general.shard/ios/core_devices_test.dart b/packages/flutter_tools/test/general.shard/ios/core_devices_test.dart index 18fb9174ea544..eedfa39685475 100644 --- a/packages/flutter_tools/test/general.shard/ios/core_devices_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/core_devices_test.dart @@ -9,10 +9,18 @@ import 'package:flutter_tools/src/base/file_system.dart'; import 'package:flutter_tools/src/base/io.dart'; import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/base/platform.dart'; +import 'package:flutter_tools/src/base/process.dart'; +import 'package:flutter_tools/src/base/template.dart'; import 'package:flutter_tools/src/base/version.dart'; +import 'package:flutter_tools/src/build_info.dart'; +import 'package:flutter_tools/src/device.dart'; +import 'package:flutter_tools/src/ios/application_package.dart'; import 'package:flutter_tools/src/ios/core_devices.dart'; +import 'package:flutter_tools/src/ios/lldb.dart'; +import 'package:flutter_tools/src/ios/xcode_debug.dart'; import 'package:flutter_tools/src/ios/xcodeproj.dart'; import 'package:flutter_tools/src/macos/xcode.dart'; +import 'package:flutter_tools/src/project.dart'; import 'package:test/fake.dart'; import '../../src/common.dart'; @@ -51,6 +59,584 @@ void main() { fileSystem = MemoryFileSystem.test(); }); + group('IOSCoreDeviceLauncher', () { + group('launchAppWithoutDebugger', () { + testWithoutContext('succeeds', () async { + final fakeCoreDeviceControl = FakeIOSCoreDeviceControl( + launchResult: IOSCoreDeviceLaunchResult.fromJson(const { + 'info': {'outcome': 'success'}, + }), + ); + + final processManager = FakeProcessManager.any(); + final logger = BufferLogger.test(); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + final fakeLLDB = FakeLLDB(); + final launcher = IOSCoreDeviceLauncher( + coreDeviceControl: fakeCoreDeviceControl, + logger: logger, + xcodeDebug: FakeXcodeDebug(), + fileSystem: MemoryFileSystem.test(), + processUtils: processUtils, + lldb: fakeLLDB, + ); + + final bool result = await launcher.launchAppWithoutDebugger( + deviceId: 'device-id', + bundlePath: 'bundle-path', + bundleId: 'bundle-id', + launchArguments: [], + ); + + expect(result, isTrue); + expect(fakeLLDB.attemptedToAttach, isFalse); + }); + + testWithoutContext('fails on install', () async { + final fakeCoreDeviceControl = FakeIOSCoreDeviceControl(installSuccess: false); + + final processManager = FakeProcessManager.any(); + final logger = BufferLogger.test(); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + final launcher = IOSCoreDeviceLauncher( + coreDeviceControl: fakeCoreDeviceControl, + logger: logger, + xcodeDebug: FakeXcodeDebug(), + fileSystem: MemoryFileSystem.test(), + processUtils: processUtils, + ); + + final bool result = await launcher.launchAppWithoutDebugger( + deviceId: 'device-id', + bundlePath: 'bundle-path', + bundleId: 'bundle-id', + launchArguments: [], + ); + + expect(result, isFalse); + }); + + testWithoutContext('fails on launch', () async { + final fakeCoreDeviceControl = FakeIOSCoreDeviceControl( + launchResult: IOSCoreDeviceLaunchResult.fromJson(const { + 'info': {'outcome': 'failed'}, + }), + ); + + final processManager = FakeProcessManager.any(); + final logger = BufferLogger.test(); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + final launcher = IOSCoreDeviceLauncher( + coreDeviceControl: fakeCoreDeviceControl, + logger: logger, + xcodeDebug: FakeXcodeDebug(), + fileSystem: MemoryFileSystem.test(), + processUtils: processUtils, + ); + + final bool result = await launcher.launchAppWithoutDebugger( + deviceId: 'device-id', + bundlePath: 'bundle-path', + bundleId: 'bundle-id', + launchArguments: [], + ); + + expect(result, isFalse); + }); + + testWithoutContext('fails on null launch result', () async { + final fakeCoreDeviceControl = FakeIOSCoreDeviceControl(); + + final processManager = FakeProcessManager.any(); + final logger = BufferLogger.test(); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + final launcher = IOSCoreDeviceLauncher( + coreDeviceControl: fakeCoreDeviceControl, + logger: logger, + xcodeDebug: FakeXcodeDebug(), + fileSystem: MemoryFileSystem.test(), + processUtils: processUtils, + ); + + final bool result = await launcher.launchAppWithoutDebugger( + deviceId: 'device-id', + bundlePath: 'bundle-path', + bundleId: 'bundle-id', + launchArguments: [], + ); + + expect(result, isFalse); + }); + }); + + group('launchAppWithLLDBDebugger', () { + testWithoutContext('succeeds', () async { + final fakeCoreDeviceControl = FakeIOSCoreDeviceControl( + launchResult: IOSCoreDeviceLaunchResult.fromJson(const { + 'info': {'outcome': 'success'}, + 'result': { + 'process': {'processIdentifier': 123}, + }, + }), + ); + + final processManager = FakeProcessManager.any(); + final logger = BufferLogger.test(); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + final fakeLLDB = FakeLLDB(); + final launcher = IOSCoreDeviceLauncher( + coreDeviceControl: fakeCoreDeviceControl, + logger: logger, + xcodeDebug: FakeXcodeDebug(), + fileSystem: MemoryFileSystem.test(), + processUtils: processUtils, + lldb: fakeLLDB, + ); + + final bool result = await launcher.launchAppWithLLDBDebugger( + deviceId: 'device-id', + bundlePath: 'bundle-path', + bundleId: 'bundle-id', + launchArguments: [], + ); + + expect(result, isTrue); + expect(fakeLLDB.attemptedToAttach, isTrue); + }); + + testWithoutContext('fails on install', () async { + final fakeCoreDeviceControl = FakeIOSCoreDeviceControl(installSuccess: false); + + final processManager = FakeProcessManager.any(); + final logger = BufferLogger.test(); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + final fakeLLDB = FakeLLDB(); + final launcher = IOSCoreDeviceLauncher( + coreDeviceControl: fakeCoreDeviceControl, + logger: logger, + xcodeDebug: FakeXcodeDebug(), + fileSystem: MemoryFileSystem.test(), + processUtils: processUtils, + lldb: fakeLLDB, + ); + + final bool result = await launcher.launchAppWithLLDBDebugger( + deviceId: 'device-id', + bundlePath: 'bundle-path', + bundleId: 'bundle-id', + launchArguments: [], + ); + + expect(result, isFalse); + expect(fakeLLDB.attemptedToAttach, isFalse); + }); + + testWithoutContext('fails on launch', () async { + final fakeCoreDeviceControl = FakeIOSCoreDeviceControl( + launchResult: IOSCoreDeviceLaunchResult.fromJson(const { + 'info': {'outcome': 'failed'}, + 'result': { + 'process': {'processIdentifier': 123}, + }, + }), + ); + + final processManager = FakeProcessManager.any(); + final logger = BufferLogger.test(); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + final fakeLLDB = FakeLLDB(); + final launcher = IOSCoreDeviceLauncher( + coreDeviceControl: fakeCoreDeviceControl, + logger: logger, + xcodeDebug: FakeXcodeDebug(), + fileSystem: MemoryFileSystem.test(), + processUtils: processUtils, + lldb: fakeLLDB, + ); + + final bool result = await launcher.launchAppWithLLDBDebugger( + deviceId: 'device-id', + bundlePath: 'bundle-path', + bundleId: 'bundle-id', + launchArguments: [], + ); + + expect(result, isFalse); + expect(fakeLLDB.attemptedToAttach, isFalse); + }); + + testWithoutContext('fails on null launch result', () async { + final fakeCoreDeviceControl = FakeIOSCoreDeviceControl(); + + final processManager = FakeProcessManager.any(); + final logger = BufferLogger.test(); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + final fakeLLDB = FakeLLDB(); + final launcher = IOSCoreDeviceLauncher( + coreDeviceControl: fakeCoreDeviceControl, + logger: logger, + xcodeDebug: FakeXcodeDebug(), + fileSystem: MemoryFileSystem.test(), + processUtils: processUtils, + lldb: fakeLLDB, + ); + + final bool result = await launcher.launchAppWithLLDBDebugger( + deviceId: 'device-id', + bundlePath: 'bundle-path', + bundleId: 'bundle-id', + launchArguments: [], + ); + + expect(result, isFalse); + expect(fakeLLDB.attemptedToAttach, isFalse); + }); + + testWithoutContext('fails on null launched process', () async { + final fakeCoreDeviceControl = FakeIOSCoreDeviceControl( + launchResult: IOSCoreDeviceLaunchResult.fromJson(const { + 'info': {'outcome': 'success'}, + }), + ); + + final processManager = FakeProcessManager.any(); + final logger = BufferLogger.test(); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + final fakeLLDB = FakeLLDB(); + final launcher = IOSCoreDeviceLauncher( + coreDeviceControl: fakeCoreDeviceControl, + logger: logger, + xcodeDebug: FakeXcodeDebug(), + fileSystem: MemoryFileSystem.test(), + processUtils: processUtils, + lldb: fakeLLDB, + ); + + final bool result = await launcher.launchAppWithLLDBDebugger( + deviceId: 'device-id', + bundlePath: 'bundle-path', + bundleId: 'bundle-id', + launchArguments: [], + ); + + expect(result, isFalse); + expect(fakeLLDB.attemptedToAttach, isFalse); + }); + + testWithoutContext('fails on null launched process id', () async { + final fakeCoreDeviceControl = FakeIOSCoreDeviceControl( + launchResult: IOSCoreDeviceLaunchResult.fromJson(const { + 'info': {'outcome': 'success'}, + 'result': {'process': {}}, + }), + ); + + final processManager = FakeProcessManager.any(); + final logger = BufferLogger.test(); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + final fakeLLDB = FakeLLDB(); + final launcher = IOSCoreDeviceLauncher( + coreDeviceControl: fakeCoreDeviceControl, + logger: logger, + xcodeDebug: FakeXcodeDebug(), + fileSystem: MemoryFileSystem.test(), + processUtils: processUtils, + lldb: fakeLLDB, + ); + + final bool result = await launcher.launchAppWithLLDBDebugger( + deviceId: 'device-id', + bundlePath: 'bundle-path', + bundleId: 'bundle-id', + launchArguments: [], + ); + + expect(result, isFalse); + expect(fakeLLDB.attemptedToAttach, isFalse); + }); + + testWithoutContext('fails on lldb attach', () async { + final fakeCoreDeviceControl = FakeIOSCoreDeviceControl( + launchResult: IOSCoreDeviceLaunchResult.fromJson(const { + 'info': {'outcome': 'success'}, + 'result': { + 'process': {'processIdentifier': 123}, + }, + }), + ); + + final processManager = FakeProcessManager.any(); + final logger = BufferLogger.test(); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + final fakeLLDB = FakeLLDB(attachSuccess: false); + final launcher = IOSCoreDeviceLauncher( + coreDeviceControl: fakeCoreDeviceControl, + logger: logger, + xcodeDebug: FakeXcodeDebug(), + fileSystem: MemoryFileSystem.test(), + processUtils: processUtils, + lldb: fakeLLDB, + ); + + final bool result = await launcher.launchAppWithLLDBDebugger( + deviceId: 'device-id', + bundlePath: 'bundle-path', + bundleId: 'bundle-id', + launchArguments: [], + ); + + expect(result, isFalse); + expect(fakeLLDB.attemptedToAttach, isTrue); + expect(fakeCoreDeviceControl.terminateProcessCalled, isTrue); + }); + }); + + group('launchAppWithXcodeDebugger', () { + testWithoutContext('succeeds with PrebuiltIOSApp', () async { + final processManager = FakeProcessManager.any(); + final logger = BufferLogger.test(); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + final IOSApp package = FakePrebuiltIOSApp(); + final fileSystem = MemoryFileSystem.test(); + final fakeXcodeDebug = FakeXcodeDebug( + tempXcodeProject: fileSystem.systemTempDirectory, + expectedLaunchArguments: [], + ); + + final launcher = IOSCoreDeviceLauncher( + coreDeviceControl: FakeIOSCoreDeviceControl(), + logger: logger, + xcodeDebug: fakeXcodeDebug, + fileSystem: MemoryFileSystem.test(), + processUtils: processUtils, + lldb: FakeLLDB(), + ); + final bool result = await launcher.launchAppWithXcodeDebugger( + deviceId: 'device-id', + debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug), + package: package, + launchArguments: ['--enable-checked-mode', '--verify-entry-points'], + templateRenderer: FakeTemplateRenderer(), + ); + + expect(result, isTrue); + expect(fakeXcodeDebug.isTemporaryProject, isTrue); + expect(fakeXcodeDebug.debugStarted, isTrue); + }); + + testWithoutContext('succeeds with BuildableIOSApp', () async { + final processManager = FakeProcessManager.any(); + final logger = BufferLogger.test(); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + final fakeIosProject = FakeIosProject(); + final IOSApp package = FakeBuildableIOSApp(fakeIosProject); + final fileSystem = MemoryFileSystem.test(); + final fakeXcodeDebug = FakeXcodeDebug( + tempXcodeProject: fileSystem.systemTempDirectory, + expectedLaunchArguments: [], + ); + + final launcher = IOSCoreDeviceLauncher( + coreDeviceControl: FakeIOSCoreDeviceControl(), + logger: logger, + xcodeDebug: fakeXcodeDebug, + fileSystem: MemoryFileSystem.test(), + processUtils: processUtils, + lldb: FakeLLDB(), + ); + final bool result = await launcher.launchAppWithXcodeDebugger( + deviceId: 'device-id', + debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug), + package: package, + launchArguments: ['--enable-checked-mode', '--verify-entry-points'], + templateRenderer: FakeTemplateRenderer(), + ); + + expect(result, isTrue); + expect(fakeXcodeDebug.isTemporaryProject, isFalse); + expect(fakeXcodeDebug.debugStarted, isTrue); + }); + + testWithoutContext('fails with BuildableIOSApp if unable to find workspace', () async { + final processManager = FakeProcessManager.any(); + final logger = BufferLogger.test(); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + final fakeIosProject = FakeIosProject(missingWorkspace: true); + final IOSApp package = FakeBuildableIOSApp(fakeIosProject); + final fileSystem = MemoryFileSystem.test(); + final fakeXcodeDebug = FakeXcodeDebug( + tempXcodeProject: fileSystem.systemTempDirectory, + expectedLaunchArguments: [], + ); + + final launcher = IOSCoreDeviceLauncher( + coreDeviceControl: FakeIOSCoreDeviceControl(), + logger: logger, + xcodeDebug: fakeXcodeDebug, + fileSystem: MemoryFileSystem.test(), + processUtils: processUtils, + lldb: FakeLLDB(), + ); + final bool result = await launcher.launchAppWithXcodeDebugger( + deviceId: 'device-id', + debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug), + package: package, + launchArguments: ['--enable-checked-mode', '--verify-entry-points'], + templateRenderer: FakeTemplateRenderer(), + ); + + expect(result, isFalse); + expect(fakeXcodeDebug.isTemporaryProject, isFalse); + expect(fakeXcodeDebug.debugStarted, isFalse); + }); + + testWithoutContext('fails with BuildableIOSApp if unable to find scheme', () async { + final processManager = FakeProcessManager.any(); + final logger = BufferLogger.test(); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + final fakeIosProject = FakeIosProject(missingScheme: true); + final IOSApp package = FakeBuildableIOSApp(fakeIosProject); + final fileSystem = MemoryFileSystem.test(); + final fakeXcodeDebug = FakeXcodeDebug( + tempXcodeProject: fileSystem.systemTempDirectory, + expectedLaunchArguments: [], + ); + + final launcher = IOSCoreDeviceLauncher( + coreDeviceControl: FakeIOSCoreDeviceControl(), + logger: logger, + xcodeDebug: fakeXcodeDebug, + fileSystem: MemoryFileSystem.test(), + processUtils: processUtils, + lldb: FakeLLDB(), + ); + final bool result = await launcher.launchAppWithXcodeDebugger( + deviceId: 'device-id', + debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug), + package: package, + launchArguments: ['--enable-checked-mode', '--verify-entry-points'], + templateRenderer: FakeTemplateRenderer(), + ); + + expect(result, isFalse); + expect(fakeXcodeDebug.isTemporaryProject, isFalse); + expect(fakeXcodeDebug.debugStarted, isFalse); + }); + }); + + group('stopApp', () { + testWithoutContext('stops with xcode debug', () async { + final fakeCoreDeviceControl = FakeIOSCoreDeviceControl(); + final xcodeDebug = FakeXcodeDebug(); + xcodeDebug._debugStarted = true; + + final processManager = FakeProcessManager.any(); + final logger = BufferLogger.test(); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + final fakeLLDB = FakeLLDB(); + final launcher = IOSCoreDeviceLauncher( + coreDeviceControl: fakeCoreDeviceControl, + logger: logger, + xcodeDebug: xcodeDebug, + fileSystem: MemoryFileSystem.test(), + processUtils: processUtils, + lldb: fakeLLDB, + ); + + final bool result = await launcher.stopApp(deviceId: 'device-id'); + + expect(result, isTrue); + expect(xcodeDebug.exitCalled, isTrue); + expect(fakeCoreDeviceControl.terminateProcessCalled, isFalse); + expect(fakeLLDB.exitCalled, isFalse); + }); + + testWithoutContext('stops with lldb process', () async { + const processId = 1234; + final fakeCoreDeviceControl = FakeIOSCoreDeviceControl(); + final xcodeDebug = FakeXcodeDebug(); + final fakeLLDB = FakeLLDB(); + + fakeLLDB.setIsRunning(true, processId); + + final processManager = FakeProcessManager.any(); + final logger = BufferLogger.test(); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + + final launcher = IOSCoreDeviceLauncher( + coreDeviceControl: fakeCoreDeviceControl, + logger: logger, + xcodeDebug: xcodeDebug, + fileSystem: MemoryFileSystem.test(), + processUtils: processUtils, + lldb: fakeLLDB, + ); + + final bool result = await launcher.stopApp(deviceId: 'device-id'); + + expect(result, isTrue); + expect(xcodeDebug.exitCalled, isFalse); + expect(fakeCoreDeviceControl.terminateProcessCalled, isTrue); + expect(fakeCoreDeviceControl.processTerminated, processId); + expect(fakeLLDB.exitCalled, isTrue); + }); + + testWithoutContext('stops with processId', () async { + const processId = 1234; + final fakeCoreDeviceControl = FakeIOSCoreDeviceControl(); + final xcodeDebug = FakeXcodeDebug(); + final fakeLLDB = FakeLLDB(); + + final processManager = FakeProcessManager.any(); + final logger = BufferLogger.test(); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + + final launcher = IOSCoreDeviceLauncher( + coreDeviceControl: fakeCoreDeviceControl, + logger: logger, + xcodeDebug: xcodeDebug, + fileSystem: MemoryFileSystem.test(), + processUtils: processUtils, + lldb: fakeLLDB, + ); + + final bool result = await launcher.stopApp(deviceId: 'device-id', processId: processId); + + expect(result, isTrue); + expect(xcodeDebug.exitCalled, isFalse); + expect(fakeCoreDeviceControl.terminateProcessCalled, isTrue); + expect(fakeCoreDeviceControl.processTerminated, processId); + expect(fakeLLDB.exitCalled, isFalse); + }); + + testWithoutContext('no process to stop', () async { + final fakeCoreDeviceControl = FakeIOSCoreDeviceControl(); + final xcodeDebug = FakeXcodeDebug(); + final fakeLLDB = FakeLLDB(); + + final processManager = FakeProcessManager.any(); + final logger = BufferLogger.test(); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + + final launcher = IOSCoreDeviceLauncher( + coreDeviceControl: fakeCoreDeviceControl, + logger: logger, + xcodeDebug: xcodeDebug, + fileSystem: MemoryFileSystem.test(), + processUtils: processUtils, + lldb: fakeLLDB, + ); + + final bool result = await launcher.stopApp(deviceId: 'device-id'); + + expect(result, isFalse); + expect(xcodeDebug.exitCalled, isFalse); + expect(fakeCoreDeviceControl.terminateProcessCalled, isFalse); + expect(fakeLLDB.exitCalled, isFalse); + }); + }); + }); + group('Xcode prior to Core Device Control/Xcode 15', () { late BufferLogger logger; late FakeProcessManager fakeProcessManager; @@ -555,8 +1141,818 @@ ERROR: The file couldn’t be opened because it doesn’t exist. (NSCocoaErrorDo 'xcrun', 'devicectl', 'device', - 'uninstall', - 'app', + 'uninstall', + 'app', + '--device', + deviceId, + bundleId, + '--json-output', + tempFile.path, + ], + onRun: (_) { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + ), + ); + + final bool status = await deviceControl.uninstallApp( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('devicectl returned unexpected JSON response')); + expect(tempFile, isNot(exists)); + expect(status, false); + }); + + testWithoutContext('fails uninstall because of invalid JSON', () async { + const deviceControlOutput = ''' +invalid JSON +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('uninstall_results.json'); + fakeProcessManager.addCommand( + FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'uninstall', + 'app', + '--device', + deviceId, + bundleId, + '--json-output', + tempFile.path, + ], + onRun: (_) { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + ), + ); + + final bool status = await deviceControl.uninstallApp( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('devicectl returned non-JSON response')); + expect(tempFile, isNot(exists)); + expect(status, false); + }); + }); + + group('launch app', () { + const deviceId = 'device-id'; + const bundleId = 'com.example.flutterApp'; + + testWithoutContext('Successful launch without launch args', () async { + const deviceControlOutput = ''' +{ + "info" : { + "arguments" : [ + "devicectl", + "device", + "process", + "launch", + "--device", + "00001234-0001234A3C03401E", + "com.example.flutterApp", + "--json-output", + "/var/folders/wq/randompath/T/flutter_tools.rand0/core_devices.rand0/install_results.json" + ], + "commandType" : "devicectl.device.process.launch", + "environment" : { + + }, + "outcome" : "success", + "version" : "341" + }, + "result" : { + "deviceIdentifier" : "123456BB5-AEDE-7A22-B890-1234567890DD", + "launchOptions" : { + "activatedWhenStarted" : true, + "arguments" : [ + + ], + "environmentVariables" : { + "TERM" : "vt100" + }, + "platformSpecificOptions" : { + + }, + "startStopped" : false, + "terminateExistingInstances" : false, + "user" : { + "active" : true + } + }, + "process" : { + "auditToken" : [ + 12345, + 678 + ], + "executable" : "file:///private/var/containers/Bundle/Application/12345E6A-7F89-0C12-345E-F6A7E890CFF1/Runner.app/Runner", + "processIdentifier" : 1234 + } + } +} +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('launch_results.json'); + fakeProcessManager.addCommand( + FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + deviceId, + bundleId, + '--json-output', + tempFile.path, + ], + onRun: (_) { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + ), + ); + + final bool status = await deviceControl.launchApp(deviceId: deviceId, bundleId: bundleId); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, isEmpty); + expect(tempFile, isNot(exists)); + expect(status, true); + }); + + testWithoutContext('Successful launch with launch args', () async { + const deviceControlOutput = ''' +{ + "info" : { + "arguments" : [ + "devicectl", + "device", + "process", + "launch", + "--device", + "00001234-0001234A3C03401E", + "com.example.flutterApp", + "--arg1", + "--arg2", + "--json-output", + "/var/folders/wq/randompath/T/flutter_tools.rand0/core_devices.rand0/install_results.json" + ], + "commandType" : "devicectl.device.process.launch", + "environment" : { + + }, + "outcome" : "success", + "version" : "341" + }, + "result" : { + "deviceIdentifier" : "123456BB5-AEDE-7A22-B890-1234567890DD", + "launchOptions" : { + "activatedWhenStarted" : true, + "arguments" : [ + + ], + "environmentVariables" : { + "TERM" : "vt100" + }, + "platformSpecificOptions" : { + + }, + "startStopped" : false, + "terminateExistingInstances" : false, + "user" : { + "active" : true + } + }, + "process" : { + "auditToken" : [ + 12345, + 678 + ], + "executable" : "file:///private/var/containers/Bundle/Application/12345E6A-7F89-0C12-345E-F6A7E890CFF1/Runner.app/Runner", + "processIdentifier" : 1234 + } + } +} +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('launch_results.json'); + fakeProcessManager.addCommand( + FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + deviceId, + bundleId, + '--arg1', + '--arg2', + '--json-output', + tempFile.path, + ], + onRun: (_) { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + ), + ); + + final bool status = await deviceControl.launchApp( + deviceId: deviceId, + bundleId: bundleId, + launchArguments: ['--arg1', '--arg2'], + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, isEmpty); + expect(tempFile, isNot(exists)); + expect(status, true); + }); + + testWithoutContext('devicectl fails install with an error', () async { + const deviceControlOutput = ''' +{ + "error" : { + "code" : -10814, + "domain" : "NSOSStatusErrorDomain", + "userInfo" : { + "_LSFunction" : { + "string" : "runEvaluator" + }, + "_LSLine" : { + "int" : 1608 + } + } + }, + "info" : { + "arguments" : [ + "devicectl", + "device", + "process", + "launch", + "--device", + "00001234-0001234A3C03401E", + "com.example.flutterApp", + "--json-output", + "/var/folders/wq/randompath/T/flutter_tools.rand0/core_devices.rand0/install_results.json" + ], + "commandType" : "devicectl.device.process.launch", + "environment" : { + + }, + "outcome" : "failed", + "version" : "341" + } +} +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('launch_results.json'); + fakeProcessManager.addCommand( + FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + deviceId, + bundleId, + '--json-output', + tempFile.path, + ], + onRun: (_) { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + exitCode: 1, + stderr: ''' +ERROR: The operation couldn?t be completed. (OSStatus error -10814.) (NSOSStatusErrorDomain error -10814.) + _LSFunction = runEvaluator + _LSLine = 1608 +''', + ), + ); + + final bool status = await deviceControl.launchApp(deviceId: deviceId, bundleId: bundleId); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('ERROR: The operation couldn?t be completed.')); + expect(tempFile, isNot(exists)); + expect(status, false); + }); + + testWithoutContext('devicectl fails install without an error', () async { + const deviceControlOutput = ''' +{ + "error" : { + "code" : -10814, + "domain" : "NSOSStatusErrorDomain", + "userInfo" : { + "_LSFunction" : { + "string" : "runEvaluator" + }, + "_LSLine" : { + "int" : 1608 + } + } + }, + "info" : { + "arguments" : [ + "devicectl", + "device", + "process", + "launch", + "--device", + "00001234-0001234A3C03401E", + "com.example.flutterApp", + "--json-output", + "/var/folders/wq/randompath/T/flutter_tools.rand0/core_devices.rand0/install_results.json" + ], + "commandType" : "devicectl.device.process.launch", + "environment" : { + + }, + "outcome" : "failed", + "version" : "341" + } +} +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('launch_results.json'); + fakeProcessManager.addCommand( + FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + deviceId, + bundleId, + '--json-output', + tempFile.path, + ], + onRun: (_) { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + ), + ); + + final bool status = await deviceControl.launchApp(deviceId: deviceId, bundleId: bundleId); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(tempFile, isNot(exists)); + expect(status, false); + }); + + testWithoutContext('fails launch because of unexpected JSON', () async { + const deviceControlOutput = ''' +{ + "valid_unexpected_json": true +} +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('launch_results.json'); + fakeProcessManager.addCommand( + FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + deviceId, + bundleId, + '--json-output', + tempFile.path, + ], + onRun: (_) { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + ), + ); + + final bool status = await deviceControl.launchApp(deviceId: deviceId, bundleId: bundleId); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('devicectl returned unexpected JSON response')); + expect(tempFile, isNot(exists)); + expect(status, false); + }); + + testWithoutContext('fails launch because of invalid JSON', () async { + const deviceControlOutput = ''' +invalid JSON +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('launch_results.json'); + fakeProcessManager.addCommand( + FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + deviceId, + bundleId, + '--json-output', + tempFile.path, + ], + onRun: (_) { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + ), + ); + + final bool status = await deviceControl.launchApp(deviceId: deviceId, bundleId: bundleId); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('devicectl returned non-JSON response')); + expect(tempFile, isNot(exists)); + expect(status, false); + }); + }); + + group('launchAppInternal', () { + const deviceId = 'device-id'; + const bundleId = 'com.example.flutterApp'; + + testWithoutContext('Successful launch without launch args', () async { + const deviceControlOutput = ''' +{ + "info" : { + "arguments" : [ + "devicectl", + "device", + "process", + "launch", + "--device", + "00001234-0001234A3C03401E", + "com.example.flutterApp", + "--json-output", + "/var/folders/wq/randompath/T/flutter_tools.rand0/core_devices.rand0/install_results.json" + ], + "commandType" : "devicectl.device.process.launch", + "environment" : { + + }, + "outcome" : "success", + "version" : "341" + }, + "result" : { + "deviceIdentifier" : "123456BB5-AEDE-7A22-B890-1234567890DD", + "launchOptions" : { + "activatedWhenStarted" : true, + "arguments" : [ + + ], + "environmentVariables" : { + "TERM" : "vt100" + }, + "platformSpecificOptions" : { + + }, + "startStopped" : false, + "terminateExistingInstances" : false, + "user" : { + "active" : true + } + }, + "process" : { + "auditToken" : [ + 12345, + 678 + ], + "executable" : "file:///private/var/containers/Bundle/Application/12345E6A-7F89-0C12-345E-F6A7E890CFF1/Runner.app/Runner", + "processIdentifier" : 1234 + } + } +} +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('launch_results.json'); + fakeProcessManager.addCommand( + FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + deviceId, + bundleId, + '--json-output', + tempFile.path, + ], + onRun: (_) { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + ), + ); + + final IOSCoreDeviceLaunchResult? result = await deviceControl.launchAppInternal( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, isEmpty); + expect(tempFile, isNot(exists)); + expect(result, isNotNull); + expect(result!.outcome, 'success'); + }); + + testWithoutContext('Successful launch with launch args', () async { + const deviceControlOutput = ''' +{ + "info" : { + "arguments" : [ + "devicectl", + "device", + "process", + "launch", + "--device", + "00001234-0001234A3C03401E", + "com.example.flutterApp", + "--arg1", + "--arg2", + "--json-output", + "/var/folders/wq/randompath/T/flutter_tools.rand0/core_devices.rand0/install_results.json" + ], + "commandType" : "devicectl.device.process.launch", + "environment" : { + + }, + "outcome" : "success", + "version" : "341" + }, + "result" : { + "deviceIdentifier" : "123456BB5-AEDE-7A22-B890-1234567890DD", + "launchOptions" : { + "activatedWhenStarted" : true, + "arguments" : [ + + ], + "environmentVariables" : { + "TERM" : "vt100" + }, + "platformSpecificOptions" : { + + }, + "startStopped" : false, + "terminateExistingInstances" : false, + "user" : { + "active" : true + } + }, + "process" : { + "auditToken" : [ + 12345, + 678 + ], + "executable" : "file:///private/var/containers/Bundle/Application/12345E6A-7F89-0C12-345E-F6A7E890CFF1/Runner.app/Runner", + "processIdentifier" : 1234 + } + } +} +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('launch_results.json'); + fakeProcessManager.addCommand( + FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + deviceId, + bundleId, + '--arg1', + '--arg2', + '--json-output', + tempFile.path, + ], + onRun: (_) { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + ), + ); + + final IOSCoreDeviceLaunchResult? result = await deviceControl.launchAppInternal( + deviceId: deviceId, + bundleId: bundleId, + launchArguments: ['--arg1', '--arg2'], + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.errorText, isEmpty); + expect(tempFile, isNot(exists)); + expect(result, isNotNull); + expect(result!.outcome, 'success'); + }); + + testWithoutContext('devicectl fails install with an error', () async { + const deviceControlOutput = ''' +{ + "error" : { + "code" : -10814, + "domain" : "NSOSStatusErrorDomain", + "userInfo" : { + "_LSFunction" : { + "string" : "runEvaluator" + }, + "_LSLine" : { + "int" : 1608 + } + } + }, + "info" : { + "arguments" : [ + "devicectl", + "device", + "process", + "launch", + "--device", + "00001234-0001234A3C03401E", + "com.example.flutterApp", + "--json-output", + "/var/folders/wq/randompath/T/flutter_tools.rand0/core_devices.rand0/install_results.json" + ], + "commandType" : "devicectl.device.process.launch", + "environment" : { + + }, + "outcome" : "failed", + "version" : "341" + } +} +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('launch_results.json'); + fakeProcessManager.addCommand( + FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + deviceId, + bundleId, + '--json-output', + tempFile.path, + ], + onRun: (_) { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + exitCode: 1, + stderr: ''' +ERROR: The operation couldn?t be completed. (OSStatus error -10814.) (NSOSStatusErrorDomain error -10814.) + _LSFunction = runEvaluator + _LSLine = 1608 +''', + ), + ); + + final IOSCoreDeviceLaunchResult? result = await deviceControl.launchAppInternal( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(logger.traceText, contains('ERROR: The operation couldn?t be completed.')); + expect(tempFile, isNot(exists)); + expect(result, isNull); + }); + + testWithoutContext('devicectl fails install without an error', () async { + const deviceControlOutput = ''' +{ + "error" : { + "code" : -10814, + "domain" : "NSOSStatusErrorDomain", + "userInfo" : { + "_LSFunction" : { + "string" : "runEvaluator" + }, + "_LSLine" : { + "int" : 1608 + } + } + }, + "info" : { + "arguments" : [ + "devicectl", + "device", + "process", + "launch", + "--device", + "00001234-0001234A3C03401E", + "com.example.flutterApp", + "--json-output", + "/var/folders/wq/randompath/T/flutter_tools.rand0/core_devices.rand0/install_results.json" + ], + "commandType" : "devicectl.device.process.launch", + "environment" : { + + }, + "outcome" : "failed", + "version" : "341" + } +} +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('launch_results.json'); + fakeProcessManager.addCommand( + FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'launch', + '--device', + deviceId, + bundleId, + '--json-output', + tempFile.path, + ], + onRun: (_) { + expect(tempFile, exists); + tempFile.writeAsStringSync(deviceControlOutput); + }, + ), + ); + + final IOSCoreDeviceLaunchResult? result = await deviceControl.launchAppInternal( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(tempFile, isNot(exists)); + expect(result, isNotNull); + expect(result!.outcome, isNot('success')); + }); + + testWithoutContext('fails launch because of unexpected JSON', () async { + const deviceControlOutput = ''' +{ + "valid_unexpected_json": true +} +'''; + final File tempFile = fileSystem.systemTempDirectory + .childDirectory('core_devices.rand0') + .childFile('launch_results.json'); + fakeProcessManager.addCommand( + FakeCommand( + command: [ + 'xcrun', + 'devicectl', + 'device', + 'process', + 'launch', '--device', deviceId, bundleId, @@ -570,32 +1966,32 @@ ERROR: The file couldn’t be opened because it doesn’t exist. (NSCocoaErrorDo ), ); - final bool status = await deviceControl.uninstallApp( + final IOSCoreDeviceLaunchResult? result = await deviceControl.launchAppInternal( deviceId: deviceId, bundleId: bundleId, ); expect(fakeProcessManager, hasNoRemainingExpectations); - expect(logger.errorText, contains('devicectl returned unexpected JSON response')); + expect(logger.traceText, contains('devicectl returned unexpected JSON response')); expect(tempFile, isNot(exists)); - expect(status, false); + expect(result, isNull); }); - testWithoutContext('fails uninstall because of invalid JSON', () async { + testWithoutContext('fails launch because of invalid JSON', () async { const deviceControlOutput = ''' invalid JSON '''; final File tempFile = fileSystem.systemTempDirectory .childDirectory('core_devices.rand0') - .childFile('uninstall_results.json'); + .childFile('launch_results.json'); fakeProcessManager.addCommand( FakeCommand( command: [ 'xcrun', 'devicectl', 'device', - 'uninstall', - 'app', + 'process', + 'launch', '--device', deviceId, bundleId, @@ -609,23 +2005,23 @@ invalid JSON ), ); - final bool status = await deviceControl.uninstallApp( + final IOSCoreDeviceLaunchResult? result = await deviceControl.launchAppInternal( deviceId: deviceId, bundleId: bundleId, ); expect(fakeProcessManager, hasNoRemainingExpectations); - expect(logger.errorText, contains('devicectl returned non-JSON response')); + expect(logger.traceText, contains('devicectl returned non-JSON response')); expect(tempFile, isNot(exists)); - expect(status, false); + expect(result, isNull); }); }); - group('launch app', () { + group('terminate app', () { const deviceId = 'device-id'; - const bundleId = 'com.example.flutterApp'; + const processId = 1234; - testWithoutContext('Successful launch without launch args', () async { + testWithoutContext('Successful terminate app', () async { const deviceControlOutput = ''' { "info" : { @@ -633,53 +2029,39 @@ invalid JSON "devicectl", "device", "process", - "launch", + "terminate", "--device", "00001234-0001234A3C03401E", - "com.example.flutterApp", + "--pid", + "1234", "--json-output", - "/var/folders/wq/randompath/T/flutter_tools.rand0/core_devices.rand0/install_results.json" + "./temp.txt" ], - "commandType" : "devicectl.device.process.launch", + "commandType" : "devicectl.device.process.terminate", "environment" : { - + "TERM" : "xterm-256color" }, + "jsonVersion" : 2, "outcome" : "success", - "version" : "341" + "version" : "477.29" }, "result" : { - "deviceIdentifier" : "123456BB5-AEDE-7A22-B890-1234567890DD", - "launchOptions" : { - "activatedWhenStarted" : true, - "arguments" : [ - - ], - "environmentVariables" : { - "TERM" : "vt100" - }, - "platformSpecificOptions" : { - - }, - "startStopped" : false, - "terminateExistingInstances" : false, - "user" : { - "active" : true - } - }, + "deviceIdentifier" : "95F6A339-849B-50D6-B27A-4DB39527E070", + "deviceTimestamp" : "2025-08-07T16:13:35.220Z", "process" : { - "auditToken" : [ - 12345, - 678 - ], "executable" : "file:///private/var/containers/Bundle/Application/12345E6A-7F89-0C12-345E-F6A7E890CFF1/Runner.app/Runner", "processIdentifier" : 1234 + }, + "signal" : { + "name" : "SIGTERM", + "value" : 15 } } } '''; final File tempFile = fileSystem.systemTempDirectory .childDirectory('core_devices.rand0') - .childFile('launch_results.json'); + .childFile('terminate_results.json'); fakeProcessManager.addCommand( FakeCommand( command: [ @@ -687,10 +2069,12 @@ invalid JSON 'devicectl', 'device', 'process', - 'launch', + 'terminate', '--device', deviceId, - bundleId, + '--pid', + processId.toString(), + '--kill', '--json-output', tempFile.path, ], @@ -701,7 +2085,10 @@ invalid JSON ), ); - final bool status = await deviceControl.launchApp(deviceId: deviceId, bundleId: bundleId); + final bool status = await deviceControl.terminateProcess( + deviceId: deviceId, + processId: processId, + ); expect(fakeProcessManager, hasNoRemainingExpectations); expect(logger.errorText, isEmpty); @@ -709,63 +2096,42 @@ invalid JSON expect(status, true); }); - testWithoutContext('Successful launch with launch args', () async { + testWithoutContext('devicectl fails terminate with an error', () async { const deviceControlOutput = ''' { + "error" : { + "code" : 3, + "domain" : "NSPOSIXErrorDomain", + "userInfo" : { + + } + }, "info" : { "arguments" : [ "devicectl", "device", "process", - "launch", + "terminate", "--device", "00001234-0001234A3C03401E", - "com.example.flutterApp", - "--arg1", - "--arg2", + "--pid", + "1234", "--json-output", - "/var/folders/wq/randompath/T/flutter_tools.rand0/core_devices.rand0/install_results.json" + "./temp.txt" ], - "commandType" : "devicectl.device.process.launch", + "commandType" : "devicectl.device.process.terminate", "environment" : { - - }, - "outcome" : "success", - "version" : "341" - }, - "result" : { - "deviceIdentifier" : "123456BB5-AEDE-7A22-B890-1234567890DD", - "launchOptions" : { - "activatedWhenStarted" : true, - "arguments" : [ - - ], - "environmentVariables" : { - "TERM" : "vt100" - }, - "platformSpecificOptions" : { - - }, - "startStopped" : false, - "terminateExistingInstances" : false, - "user" : { - "active" : true - } + "TERM" : "xterm-256color" }, - "process" : { - "auditToken" : [ - 12345, - 678 - ], - "executable" : "file:///private/var/containers/Bundle/Application/12345E6A-7F89-0C12-345E-F6A7E890CFF1/Runner.app/Runner", - "processIdentifier" : 1234 - } + "jsonVersion" : 2, + "outcome" : "failed", + "version" : "477.29" } } '''; final File tempFile = fileSystem.systemTempDirectory .childDirectory('core_devices.rand0') - .childFile('launch_results.json'); + .childFile('terminate_results.json'); fakeProcessManager.addCommand( FakeCommand( command: [ @@ -773,12 +2139,12 @@ invalid JSON 'devicectl', 'device', 'process', - 'launch', + 'terminate', '--device', deviceId, - bundleId, - '--arg1', - '--arg2', + '--pid', + processId.toString(), + '--kill', '--json-output', tempFile.path, ], @@ -786,60 +2152,67 @@ invalid JSON expect(tempFile, exists); tempFile.writeAsStringSync(deviceControlOutput); }, + exitCode: 1, + stderr: ''' +ERROR: The operation couldn?t be completed. (OSStatus error -10814.) (NSOSStatusErrorDomain error -10814.) + _LSFunction = runEvaluator + _LSLine = 1608 +''', ), ); - final bool status = await deviceControl.launchApp( + final bool status = await deviceControl.terminateProcess( deviceId: deviceId, - bundleId: bundleId, - launchArguments: ['--arg1', '--arg2'], + processId: processId, ); expect(fakeProcessManager, hasNoRemainingExpectations); - expect(logger.errorText, isEmpty); + expect(logger.traceText, contains('ERROR: The operation couldn?t be completed.')); expect(tempFile, isNot(exists)); - expect(status, true); + expect(status, false); }); - testWithoutContext('devicectl fails install', () async { + testWithoutContext('devicectl fails terminate without an error', () async { const deviceControlOutput = ''' { - "error" : { - "code" : -10814, - "domain" : "NSOSStatusErrorDomain", - "userInfo" : { - "_LSFunction" : { - "string" : "runEvaluator" - }, - "_LSLine" : { - "int" : 1608 - } - } - }, "info" : { "arguments" : [ "devicectl", "device", "process", - "launch", + "terminate", "--device", "00001234-0001234A3C03401E", - "com.example.flutterApp", + "--pid", + "1234", "--json-output", - "/var/folders/wq/randompath/T/flutter_tools.rand0/core_devices.rand0/install_results.json" + "./temp.txt" ], - "commandType" : "devicectl.device.process.launch", + "commandType" : "devicectl.device.process.terminate", "environment" : { - + "TERM" : "xterm-256color" }, + "jsonVersion" : 2, "outcome" : "failed", - "version" : "341" + "version" : "477.29" + }, + "result" : { + "deviceIdentifier" : "95F6A339-849B-50D6-B27A-4DB39527E070", + "deviceTimestamp" : "2025-08-07T16:13:35.220Z", + "process" : { + "executable" : "file:///private/var/containers/Bundle/Application/12345E6A-7F89-0C12-345E-F6A7E890CFF1/Runner.app/Runner", + "processIdentifier" : 1234 + }, + "signal" : { + "name" : "SIGTERM", + "value" : 15 + } } } '''; final File tempFile = fileSystem.systemTempDirectory .childDirectory('core_devices.rand0') - .childFile('launch_results.json'); + .childFile('terminate_results.json'); fakeProcessManager.addCommand( FakeCommand( command: [ @@ -847,10 +2220,12 @@ invalid JSON 'devicectl', 'device', 'process', - 'launch', + 'terminate', '--device', deviceId, - bundleId, + '--pid', + processId.toString(), + '--kill', '--json-output', tempFile.path, ], @@ -858,19 +2233,15 @@ invalid JSON expect(tempFile, exists); tempFile.writeAsStringSync(deviceControlOutput); }, - exitCode: 1, - stderr: ''' -ERROR: The operation couldn?t be completed. (OSStatus error -10814.) (NSOSStatusErrorDomain error -10814.) - _LSFunction = runEvaluator - _LSLine = 1608 -''', ), ); - final bool status = await deviceControl.launchApp(deviceId: deviceId, bundleId: bundleId); + final bool status = await deviceControl.terminateProcess( + deviceId: deviceId, + processId: processId, + ); expect(fakeProcessManager, hasNoRemainingExpectations); - expect(logger.errorText, contains('ERROR: The operation couldn?t be completed.')); expect(tempFile, isNot(exists)); expect(status, false); }); @@ -883,7 +2254,7 @@ ERROR: The operation couldn?t be completed. (OSStatus error -10814.) (NSOSStatus '''; final File tempFile = fileSystem.systemTempDirectory .childDirectory('core_devices.rand0') - .childFile('launch_results.json'); + .childFile('terminate_results.json'); fakeProcessManager.addCommand( FakeCommand( command: [ @@ -891,10 +2262,12 @@ ERROR: The operation couldn?t be completed. (OSStatus error -10814.) (NSOSStatus 'devicectl', 'device', 'process', - 'launch', + 'terminate', '--device', deviceId, - bundleId, + '--pid', + processId.toString(), + '--kill', '--json-output', tempFile.path, ], @@ -905,10 +2278,13 @@ ERROR: The operation couldn?t be completed. (OSStatus error -10814.) (NSOSStatus ), ); - final bool status = await deviceControl.launchApp(deviceId: deviceId, bundleId: bundleId); + final bool status = await deviceControl.terminateProcess( + deviceId: deviceId, + processId: processId, + ); expect(fakeProcessManager, hasNoRemainingExpectations); - expect(logger.errorText, contains('devicectl returned unexpected JSON response')); + expect(logger.traceText, contains('devicectl returned unexpected JSON response')); expect(tempFile, isNot(exists)); expect(status, false); }); @@ -919,7 +2295,7 @@ invalid JSON '''; final File tempFile = fileSystem.systemTempDirectory .childDirectory('core_devices.rand0') - .childFile('launch_results.json'); + .childFile('terminate_results.json'); fakeProcessManager.addCommand( FakeCommand( command: [ @@ -927,10 +2303,12 @@ invalid JSON 'devicectl', 'device', 'process', - 'launch', + 'terminate', '--device', deviceId, - bundleId, + '--pid', + processId.toString(), + '--kill', '--json-output', tempFile.path, ], @@ -941,10 +2319,13 @@ invalid JSON ), ); - final bool status = await deviceControl.launchApp(deviceId: deviceId, bundleId: bundleId); + final bool status = await deviceControl.terminateProcess( + deviceId: deviceId, + processId: processId, + ); expect(fakeProcessManager, hasNoRemainingExpectations); - expect(logger.errorText, contains('devicectl returned non-JSON response')); + expect(logger.traceText, contains('devicectl returned non-JSON response')); expect(tempFile, isNot(exists)); expect(status, false); }); @@ -2183,3 +3564,202 @@ invalid JSON }); }); } + +class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl { + FakeIOSCoreDeviceControl({ + this.installSuccess = true, + this.launchResult, + this.terminateSuccess = true, + }); + + bool installSuccess; + IOSCoreDeviceLaunchResult? launchResult; + bool terminateSuccess; + int? processTerminated; + bool get terminateProcessCalled => processTerminated != null; + + @override + Future installApp({required String deviceId, required String bundlePath}) async { + return installSuccess; + } + + @override + Future launchAppInternal({ + required String deviceId, + required String bundleId, + List launchArguments = const [], + bool startStopped = false, + }) async { + return launchResult; + } + + @override + Future terminateProcess({required String deviceId, required int processId}) async { + processTerminated = processId; + return terminateSuccess; + } +} + +class FakeXcodeDebug extends Fake implements XcodeDebug { + FakeXcodeDebug({this.tempXcodeProject, this.expectedProject, this.expectedLaunchArguments}); + var exitSuccess = true; + var _debugStarted = false; + var exitCalled = false; + var isTemporaryProject = false; + Directory? tempXcodeProject; + XcodeDebugProject? expectedProject; + List? expectedLaunchArguments; + + @override + bool get debugStarted => _debugStarted; + + @override + Future createXcodeProjectWithCustomBundle( + String deviceBundlePath, { + required TemplateRenderer templateRenderer, + Directory? projectDestination, + bool verboseLogging = false, + }) async { + isTemporaryProject = true; + return XcodeDebugProject( + scheme: 'Runner', + hostAppProjectName: 'Runner', + xcodeProject: tempXcodeProject!.childDirectory('Runner.xcodeproj'), + xcodeWorkspace: tempXcodeProject!.childDirectory('Runner.xcworkspace'), + isTemporaryProject: true, + verboseLogging: verboseLogging, + ); + } + + @override + Future debugApp({ + required XcodeDebugProject project, + required String deviceId, + required List launchArguments, + }) async { + if (expectedProject != null) { + expect(expectedProject, project); + expect(expectedLaunchArguments, launchArguments); + } + _debugStarted = true; + return true; + } + + @override + Future exit({bool force = false, bool skipDelay = false}) async { + exitCalled = true; + return exitSuccess; + } + + @override + void ensureXcodeDebuggerLaunchAction(File schemeFile) {} + + @override + Future updateConfigurationBuildDir({ + required FlutterProject project, + required BuildInfo buildInfo, + String? mainPath, + required String configurationBuildDir, + }) async {} +} + +class FakeLLDB extends Fake implements LLDB { + FakeLLDB({this.attachSuccess = true}); + bool attachSuccess; + + var attemptedToAttach = false; + + var _isRunning = false; + int? _processId; + var exitCalled = false; + + @override + bool get isRunning => _isRunning; + + @override + int? get appProcessId => _processId; + + void setIsRunning(bool running, int? processId) { + _isRunning = running; + _processId = processId; + } + + @override + Future attachAndStart(String deviceId, int processId) async { + attemptedToAttach = true; + return attachSuccess; + } + + @override + bool exit() { + exitCalled = true; + return true; + } +} + +class FakePrebuiltIOSApp extends Fake implements PrebuiltIOSApp { + @override + String get deviceBundlePath => '/path/to/prebuilt/app'; +} + +class FakeBuildableIOSApp extends Fake implements BuildableIOSApp { + FakeBuildableIOSApp(this.project); + + @override + String get deviceBundlePath => '/path/to/buildable/app'; + + @override + final IosProject project; +} + +class FakeFlutterProject extends Fake implements FlutterProject { + FakeFlutterProject(this.ios); + + @override + final IosProject ios; +} + +class FakeIosProject extends Fake implements IosProject { + FakeIosProject({this.missingWorkspace = false, this.missingScheme = false}); + + late final _flutterProject = FakeFlutterProject(this); + + bool missingWorkspace; + bool missingScheme; + + @override + late FlutterProject parent = _flutterProject; + + @override + Directory? get xcodeWorkspace { + if (missingWorkspace) { + return null; + } + return MemoryFileSystem.test().directory('Runner.xcworkspace'); + } + + @override + Future schemeForBuildInfo(BuildInfo buildInfo, {Logger? logger}) async { + if (missingScheme) { + return null; + } + return 'Runner'; + } + + @override + File xcodeProjectSchemeFile({String? scheme}) { + final String schemeName = scheme ?? 'Runner'; + return xcodeProject + .childDirectory('xcshareddata') + .childDirectory('xcschemes') + .childFile('$schemeName.xcscheme'); + } + + @override + Directory get xcodeProject => MemoryFileSystem.test().directory('Runner.xcodeproj'); + + @override + String get hostAppProjectName => 'Runner'; +} + +class FakeTemplateRenderer extends Fake implements TemplateRenderer {} diff --git a/packages/flutter_tools/test/general.shard/ios/devices_test.dart b/packages/flutter_tools/test/general.shard/ios/devices_test.dart index 47b58b5655210..9b43d4c117663 100644 --- a/packages/flutter_tools/test/general.shard/ios/devices_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/devices_test.dart @@ -45,6 +45,7 @@ void main() { late IMobileDevice iMobileDevice; late FileSystem fileSystem; late IOSCoreDeviceControl coreDeviceControl; + late IOSCoreDeviceLauncher coreDeviceLauncher; late XcodeDebug xcodeDebug; setUp(() { @@ -66,6 +67,7 @@ void main() { processManager: FakeProcessManager.any(), ); coreDeviceControl = FakeIOSCoreDeviceControl(); + coreDeviceLauncher = FakeIOSCoreDeviceLauncher(); xcodeDebug = FakeXcodeDebug(); }); @@ -79,6 +81,7 @@ void main() { iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', sdkVersion: '13.3', @@ -102,6 +105,7 @@ void main() { iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.armv7, @@ -125,6 +129,7 @@ void main() { iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, @@ -147,6 +152,7 @@ void main() { iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, @@ -169,6 +175,7 @@ void main() { iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, @@ -191,6 +198,7 @@ void main() { iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, @@ -213,6 +221,7 @@ void main() { iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, @@ -237,6 +246,7 @@ void main() { iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, @@ -261,6 +271,7 @@ void main() { iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, @@ -285,6 +296,7 @@ void main() { iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, @@ -309,6 +321,7 @@ void main() { iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, @@ -333,6 +346,7 @@ void main() { iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, @@ -353,6 +367,7 @@ void main() { iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, @@ -376,6 +391,7 @@ void main() { iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', sdkVersion: '13.3 17C54', @@ -400,6 +416,7 @@ void main() { iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', sdkVersion: '13.3', @@ -431,6 +448,7 @@ void main() { iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', sdkVersion: '13.3', @@ -519,6 +537,7 @@ void main() { iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', sdkVersion: '13.3', @@ -554,6 +573,7 @@ void main() { late IMobileDevice iMobileDevice; late IOSWorkflow iosWorkflow; late IOSCoreDeviceControl coreDeviceControl; + late IOSCoreDeviceLauncher coreDeviceLauncher; late XcodeDebug xcodeDebug; late IOSDevice device1; late IOSDevice device2; @@ -579,6 +599,7 @@ void main() { logger: logger, ); coreDeviceControl = FakeIOSCoreDeviceControl(); + coreDeviceLauncher = FakeIOSCoreDeviceLauncher(); xcodeDebug = FakeXcodeDebug(); device1 = IOSDevice( @@ -590,6 +611,7 @@ void main() { iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, logger: logger, platform: macPlatform, @@ -610,6 +632,7 @@ void main() { iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, logger: logger, platform: macPlatform, @@ -882,6 +905,7 @@ void main() { late IMobileDevice iMobileDevice; late IOSWorkflow iosWorkflow; late IOSCoreDeviceControl coreDeviceControl; + late IOSCoreDeviceLauncher coreDeviceLauncher; late XcodeDebug xcodeDebug; late IOSDevice notConnected1; @@ -906,6 +930,7 @@ void main() { logger: logger, ); coreDeviceControl = FakeIOSCoreDeviceControl(); + coreDeviceLauncher = FakeIOSCoreDeviceLauncher(); xcodeDebug = FakeXcodeDebug(); notConnected1 = IOSDevice( '00000001-0000000000000000', @@ -916,6 +941,7 @@ void main() { iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, logger: logger, platform: macPlatform, @@ -1080,3 +1106,5 @@ class FakeXcodeDebug extends Fake implements XcodeDebug { } class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl {} + +class FakeIOSCoreDeviceLauncher extends Fake implements IOSCoreDeviceLauncher {} diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_install_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_install_test.dart index a802f9bf73405..539c14f009773 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_install_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_install_test.dart @@ -381,6 +381,7 @@ IOSDevice setUpIOSDevice({ cache: cache, ), coreDeviceControl: FakeIOSCoreDeviceControl(), + coreDeviceLauncher: FakeIOSCoreDeviceLauncher(), xcodeDebug: FakeXcodeDebug(), iProxy: IProxy.test(logger: logger, processManager: processManager), connectionInterface: interfaceType ?? DeviceConnectionInterface.attached, @@ -409,3 +410,5 @@ class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl { return true; } } + +class FakeIOSCoreDeviceLauncher extends Fake implements IOSCoreDeviceLauncher {} diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_project_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_project_test.dart index 6e6ef52fc6c10..5fcb5a71fdde4 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_project_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_project_test.dart @@ -109,6 +109,7 @@ IOSDevice setUpIOSDevice(FileSystem fileSystem) { ), iMobileDevice: IMobileDevice.test(processManager: processManager), coreDeviceControl: FakeIOSCoreDeviceControl(), + coreDeviceLauncher: FakeIOSCoreDeviceLauncher(), xcodeDebug: FakeXcodeDebug(), platform: platform, name: 'iPhone 1', @@ -126,3 +127,5 @@ IOSDevice setUpIOSDevice(FileSystem fileSystem) { class FakeXcodeDebug extends Fake implements XcodeDebug {} class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl {} + +class FakeIOSCoreDeviceLauncher extends Fake implements IOSCoreDeviceLauncher {} diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart index 18dcb90ec7960..a681ad09225b2 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart @@ -1174,6 +1174,7 @@ IOSDevice setUpIOSDevice({ Artifacts? artifacts, bool isCoreDevice = false, IOSCoreDeviceControl? coreDeviceControl, + IOSCoreDeviceLauncher? coreDeviceLauncher, FakeXcodeDebug? xcodeDebug, DarwinArch cpuArchitecture = DarwinArch.arm64, }) { @@ -1206,6 +1207,7 @@ IOSDevice setUpIOSDevice({ cache: cache, ), coreDeviceControl: coreDeviceControl ?? FakeIOSCoreDeviceControl(), + coreDeviceLauncher: coreDeviceLauncher ?? FakeIOSCoreDeviceLauncher(), xcodeDebug: xcodeDebug ?? FakeXcodeDebug(), cpuArchitecture: cpuArchitecture, connectionInterface: DeviceConnectionInterface.attached, @@ -1392,3 +1394,5 @@ const _validScheme = ''' '''; + +class FakeIOSCoreDeviceLauncher extends Fake implements IOSCoreDeviceLauncher {} diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart index 279b1418e1ed8..b9650b07ce0c1 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart @@ -1219,6 +1219,7 @@ IOSDevice setUpIOSDevice({ cache: cache, ), coreDeviceControl: coreDeviceControl ?? FakeIOSCoreDeviceControl(), + coreDeviceLauncher: FakeIOSCoreDeviceLauncher(), xcodeDebug: xcodeDebug ?? FakeXcodeDebug(), cpuArchitecture: DarwinArch.arm64, connectionInterface: interfaceType, @@ -1345,3 +1346,5 @@ class FakeShutDownHooks extends Fake implements ShutdownHooks { hooks.add(shutdownHook); } } + +class FakeIOSCoreDeviceLauncher extends Fake implements IOSCoreDeviceLauncher {} diff --git a/packages/flutter_tools/test/general.shard/ios/lldb_test.dart b/packages/flutter_tools/test/general.shard/ios/lldb_test.dart new file mode 100644 index 0000000000000..e94da55b1b8f4 --- /dev/null +++ b/packages/flutter_tools/test/general.shard/ios/lldb_test.dart @@ -0,0 +1,502 @@ +// 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:convert'; +import 'dart:io' as io; + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_tools/src/base/io.dart'; +import 'package:flutter_tools/src/base/logger.dart'; +import 'package:flutter_tools/src/base/process.dart'; +import 'package:flutter_tools/src/ios/lldb.dart'; +import 'package:test/fake.dart'; + +import '../../src/common.dart'; +import '../../src/fake_process_manager.dart'; + +void main() { + testWithoutContext('attachAndStart fails if lldb fails', () async { + const deviceId = '123'; + const appappProcessId = 5678; + + final processCompleter = Completer(); + final lldbCommand = FakeLLDBCommand( + command: const ['lldb'], + completer: processCompleter, + stdin: io.IOSink(StreamController>().sink), + stdout: const Stream.empty(), + stderr: const Stream.empty(), + exitCode: 1, + exception: const ProcessException('lldb', []), + ); + + final logger = BufferLogger.test(); + + final processManager = FakeLLDBProcessManager([lldbCommand]); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + final lldb = LLDB(logger: logger, processUtils: processUtils); + + final bool success = await lldb.attachAndStart(deviceId, appappProcessId); + expect(success, isFalse); + expect(lldb.isRunning, isFalse); + expect(lldb.appProcessId, isNull); + expect(processManager.hasRemainingExpectations, isFalse); + expect(logger.errorText, contains('Process exception running lldb')); + }); + + testWithoutContext('attachAndStart returns true on success', () async { + const deviceId = '123'; + const appappProcessId = 5678; + const breakpointId = 123; + + final breakPointCompleter = Completer>(); + final processAttachCompleter = Completer>(); + final processResumedCompleted = Completer>(); + + final stdoutStream = Stream>.fromFutures([ + breakPointCompleter.future, + processAttachCompleter.future, + processResumedCompleted.future, + ]); + + final stdinController = StreamController>(); + + final processCompleter = Completer(); + final lldbCommand = FakeLLDBCommand( + command: const ['lldb'], + completer: processCompleter, + stdin: io.IOSink(stdinController.sink), + stdout: stdoutStream, + stderr: const Stream.empty(), + ); + + final logger = BufferLogger.test(); + + final processManager = FakeLLDBProcessManager([lldbCommand]); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + final lldb = LLDB(logger: logger, processUtils: processUtils); + + const breakPointMatcher = r"breakpoint set --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'"; + const processAttachMatcher = 'device process attach --pid $appappProcessId'; + const processResumedMatcher = 'process continue'; + final expectedInputs = [ + 'device select $deviceId', + breakPointMatcher, + 'breakpoint command add --script-type python $breakpointId', + processAttachMatcher, + processResumedMatcher, + ]; + + stdinController.stream.transform(utf8.decoder).transform(const LineSplitter()).listen(( + String line, + ) { + expectedInputs.remove(line); + if (line == breakPointMatcher) { + breakPointCompleter.complete( + utf8.encode('Breakpoint $breakpointId: no locations (pending).\n'), + ); + } + if (line == processAttachMatcher) { + processAttachCompleter.complete( + utf8.encode(''' +Process 568 stopped +* thread #1, stop reason = signal SIGSTOP + frame #0: 0x0000000102c7b240 dyld`_dyld_start +dyld`_dyld_start: +-> 0x102c7b240 <+0>: mov x0, sp + 0x102c7b244 <+4>: and sp, x0, #0xfffffffffffffff0 + 0x102c7b248 <+8>: mov x29, #0x0 ; =0 + 0x102c7b24c <+12>: mov x30, #0x0 ; =0 +Target 0: (Runner) stopped. +'''), + ); + } + if (line == processResumedMatcher) { + processResumedCompleted.complete(utf8.encode('Process $appappProcessId resuming\n')); + } + }); + + final bool success = await lldb.attachAndStart(deviceId, appappProcessId); + expect(success, isTrue); + expect(lldb.isRunning, isTrue); + expect(lldb.appProcessId, appappProcessId); + expect(expectedInputs, isEmpty); + expect(processManager.hasRemainingExpectations, isFalse); + expect(logger.errorText, isEmpty); + }); + + testWithoutContext('attachAndStart returns false when stderr during log waiter', () async { + const deviceId = '123'; + const appappProcessId = 5678; + + final breakPointCompleter = Completer>(); + final errorCompleter = Completer>(); + + final stdoutStream = Stream>.fromFutures([breakPointCompleter.future]); + + final stderrStream = Stream>.fromFutures([errorCompleter.future]); + + final stdinController = StreamController>(); + + final processCompleter = Completer(); + final lldbCommand = FakeLLDBCommand( + command: const ['lldb'], + completer: processCompleter, + stdin: io.IOSink(stdinController.sink), + stdout: stdoutStream, + stderr: stderrStream, + ); + + final logger = BufferLogger.test(); + + final processManager = FakeLLDBProcessManager([lldbCommand]); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + final lldb = LLDB(logger: logger, processUtils: processUtils); + + const breakPointMatcher = r"breakpoint set --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'"; + final expectedInputs = ['device select $deviceId', breakPointMatcher]; + const errorText = "error: 'device' is not a valid command.\n"; + + stdinController.stream.transform(utf8.decoder).transform(const LineSplitter()).listen(( + String line, + ) { + expectedInputs.remove(line); + if (line == breakPointMatcher) { + errorCompleter.complete(utf8.encode(errorText)); + } + }); + + final bool success = await lldb.attachAndStart(deviceId, appappProcessId); + expect(success, isFalse); + expect(lldb.isRunning, isFalse); + expect(lldb.appProcessId, isNull); + expect(expectedInputs, isEmpty); + expect(processManager.hasRemainingExpectations, isFalse); + expect(logger.errorText, contains(errorText)); + }); + + testWithoutContext('attachAndStart returns false when stderr not during log waiter', () async { + const deviceId = '123'; + const appappProcessId = 5678; + + final breakPointCompleter = Completer>(); + final errorCompleter = Completer>(); + + final stdoutStream = Stream>.fromFutures([breakPointCompleter.future]); + + final stderrStream = Stream>.fromFutures([errorCompleter.future]); + + final stdinController = StreamController>(); + + final processCompleter = Completer(); + final lldbCommand = FakeLLDBCommand( + command: const ['lldb'], + completer: processCompleter, + stdin: io.IOSink(stdinController.sink), + stdout: stdoutStream, + stderr: stderrStream, + ); + + final logger = BufferLogger.test(); + + final processManager = FakeLLDBProcessManager([lldbCommand]); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + final lldb = LLDB(logger: logger, processUtils: processUtils); + final expectedInputs = ['device select $deviceId']; + const errorText = "error: 'device' is not a valid command.\n"; + + stdinController.stream.transform(utf8.decoder).transform(const LineSplitter()).listen(( + String line, + ) { + expectedInputs.remove(line); + errorCompleter.complete(utf8.encode(errorText)); + }); + + final bool success = await lldb.attachAndStart(deviceId, appappProcessId); + expect(success, isFalse); + expect(lldb.isRunning, isFalse); + expect(lldb.appProcessId, isNull); + expect(expectedInputs, isEmpty); + expect(processManager.hasRemainingExpectations, isFalse); + expect(logger.errorText, contains(errorText)); + }); + + testWithoutContext('attachAndStart prints warning if takes too long', () async { + const deviceId = '123'; + const appappProcessId = 5678; + + final stdinController = StreamController>(); + + final processCompleter = Completer(); + final lldbCommand = FakeLLDBCommand( + command: const ['lldb'], + completer: processCompleter, + stdin: io.IOSink(stdinController.sink), + stdout: const Stream.empty(), + stderr: const Stream.empty(), + ); + + final logger = BufferLogger.test(); + + final processManager = FakeLLDBProcessManager([lldbCommand]); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + final lldb = LLDB(logger: logger, processUtils: processUtils); + + final completer = Completer(); + + stdinController.stream.transform(utf8.decoder).transform(const LineSplitter()).listen(( + String line, + ) { + if (!completer.isCompleted) { + completer.complete(); + } + }); + + await FakeAsync().run((FakeAsync time) { + lldb.attachAndStart(deviceId, appappProcessId); + time.elapse(const Duration(minutes: 2)); + time.flushMicrotasks(); + return completer.future; + }); + + expect( + logger.errorText, + contains('LLDB is taking longer than expected to start debugging the app'), + ); + }); + + testWithoutContext('exit returns true and kills process', () async { + const deviceId = '123'; + const appappProcessId = 5678; + + final stdinController = StreamController>(); + + final processCompleter = Completer(); + final lldbCommand = FakeLLDBCommand( + command: const ['lldb'], + completer: processCompleter, + stdin: io.IOSink(stdinController.sink), + stdout: const Stream.empty(), + stderr: const Stream.empty(), + ); + + final logger = BufferLogger.test(); + + final processManager = FakeLLDBProcessManager([lldbCommand]); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + final lldb = LLDB(logger: logger, processUtils: processUtils); + + final lldbStarted = Completer(); + + stdinController.stream.transform(utf8.decoder).transform(const LineSplitter()).listen(( + String line, + ) { + if (!lldbStarted.isCompleted) { + lldbStarted.complete(); + } + }); + + unawaited(lldb.attachAndStart(deviceId, appappProcessId)); + + await lldbStarted.future; + final bool exitStatus = lldb.exit(); + expect(exitStatus, isTrue); + expect(lldb.isRunning, isFalse); + expect(lldb.appProcessId, isNull); + expect(processManager.hasRemainingExpectations, isFalse); + }); + + testWithoutContext('exit returns true if process not running', () { + final logger = BufferLogger.test(); + + final processManager = FakeLLDBProcessManager([]); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + final lldb = LLDB(logger: logger, processUtils: processUtils); + expect(lldb.isRunning, isFalse); + final bool exitStatus = lldb.exit(); + expect(exitStatus, isTrue); + expect(lldb.isRunning, isFalse); + expect(lldb.appProcessId, isNull); + }); +} + +class FakeLLDBProcessManager extends Fake implements ProcessManager { + FakeLLDBProcessManager(this._commands); + final List _commands; + + final fakeRunningProcesses = {}; + var _pid = 9999; + + @override + Future start( + List command, { + String? workingDirectory, + Map? environment, + bool includeParentEnvironment = true, + bool runInShell = false, + ProcessStartMode mode = ProcessStartMode.normal, + }) { + final FakeLLDBProcess process = _runCommand( + command.cast(), + workingDirectory: workingDirectory, + environment: environment, + encoding: io.systemEncoding, + mode: mode, + ); + if (process._completer != null) { + fakeRunningProcesses[process.pid] = process; + process.exitCode.whenComplete(() { + fakeRunningProcesses.remove(process.pid); + }); + } + return Future.value(process); + } + + FakeLLDBProcess _runCommand( + List command, { + String? workingDirectory, + Map? environment, + Encoding? encoding, + io.ProcessStartMode? mode, + }) { + _pid += 1; + final FakeLLDBCommand fakeCommand = findCommand( + command, + workingDirectory, + environment, + encoding, + mode, + ); + if (fakeCommand.exception != null) { + assert(fakeCommand.exception is Exception || fakeCommand.exception is Error); + throw fakeCommand.exception!; // ignore: only_throw_errors + } + return FakeLLDBProcess( + exitCode: fakeCommand.exitCode, + pid: _pid, + stderr: fakeCommand.stderr, + stdin: fakeCommand.stdin, + stdout: fakeCommand.stdout, + completer: fakeCommand.completer, + ); + } + + FakeLLDBCommand findCommand( + List command, + String? workingDirectory, + Map? environment, + Encoding? encoding, + io.ProcessStartMode? mode, + ) { + expect( + _commands, + isNotEmpty, + reason: + 'ProcessManager was told to execute $command (in $workingDirectory) ' + 'but the FakeProcessManager.list expected no more processes.', + ); + _commands.first.commandMatches(command, workingDirectory, environment, encoding, mode); + return _commands.removeAt(0); + } + + bool get hasRemainingExpectations => _commands.isNotEmpty; +} + +class FakeLLDBProcess implements io.Process { + /// Creates a fake process for use with [FakeProcessManager]. + /// + /// The process delays exit until both [duration] (if specified) has elapsed + /// and [completer] (if specified) has completed. + FakeLLDBProcess({ + int exitCode = 0, + Duration duration = Duration.zero, + this.pid = 1234, + required this.stdin, + required this.stdout, + required this.stderr, + Completer? completer, + }) : exitCode = Future.delayed(duration).then((void value) { + if (completer != null) { + return completer.future.then((void _) => exitCode); + } + return exitCode; + }), + _completer = completer; + + /// When specified, blocks process exit until completed. + final Completer? _completer; + + @override + final Future exitCode; + + @override + final int pid; + + @override + late final Stream> stderr; + + @override + final IOSink stdin; + + @override + late final Stream> stdout; + + @override + bool kill([io.ProcessSignal signal = io.ProcessSignal.sigterm]) { + // Killing a fake process has no effect. + return true; + } +} + +class FakeLLDBCommand { + const FakeLLDBCommand({ + required this.command, + this.exitCode = 0, + required this.stdin, + required this.stdout, + required this.stderr, + this.completer, + this.exception, + }); + + /// The exact commands that must be matched for this [FakeCommand] to be + /// considered correct. + final List command; + + /// The process' exit code. + final int exitCode; + + /// The output to simulate on stdout. This will be encoded as UTF-8 and + /// returned in one go. + final Stream> stdout; + + /// The output to simulate on stderr. This will be encoded as UTF-8 and + /// returned in one go. + final Stream> stderr; + + /// If provided, allows the command completion to be blocked until the future + /// resolves. + final Completer? completer; + + /// An optional stdin sink that will be exposed through the resulting + /// [FakeProcess]. + final IOSink stdin; + + /// If provided, this exception will be thrown when the fake command is run. + final Object? exception; + + void commandMatches( + List command, + String? workingDirectory, + Map? environment, + Encoding? encoding, + io.ProcessStartMode? mode, + ) { + final List matchers = this.command + .map((Pattern x) => x is String ? x : matches(x)) + .toList(); + expect(command, matchers); + } +} diff --git a/packages/flutter_tools/test/general.shard/xcode_project_test.dart b/packages/flutter_tools/test/general.shard/xcode_project_test.dart index 60e6a759acbe6..07a7c44ec677f 100644 --- a/packages/flutter_tools/test/general.shard/xcode_project_test.dart +++ b/packages/flutter_tools/test/general.shard/xcode_project_test.dart @@ -148,6 +148,69 @@ void main() { ); }); + testUsingContext( + 'schemeForBuildInfo succeeds', + () async { + final fs = MemoryFileSystem.test(); + final project = IosProject.fromFlutter(FakeFlutterProject(fileSystem: fs)); + project.xcodeProject.createSync(recursive: true); + const BuildInfo buildInfo = BuildInfo.debug; + expect(await project.schemeForBuildInfo(buildInfo), 'Runner'); + }, + overrides: {XcodeProjectInterpreter: () => FakeXcodeProjectInterpreter()}, + ); + + testUsingContext( + 'schemeForBuildInfo returns null if unable to find project', + () async { + final fs = MemoryFileSystem.test(); + final project = IosProject.fromFlutter(FakeFlutterProject(fileSystem: fs)); + const BuildInfo buildInfo = BuildInfo.debug; + expect(await project.schemeForBuildInfo(buildInfo), isNull); + }, + overrides: {XcodeProjectInterpreter: () => FakeXcodeProjectInterpreter()}, + ); + + testUsingContext( + 'schemeForBuildInfo succeeds with flavor', + () async { + final fs = MemoryFileSystem.test(); + final project = IosProject.fromFlutter(FakeFlutterProject(fileSystem: fs)); + project.xcodeProject.createSync(recursive: true); + const buildInfo = BuildInfo( + BuildMode.debug, + 'my_flavor', + treeShakeIcons: true, + packageConfigPath: '', + ); + expect(await project.schemeForBuildInfo(buildInfo), 'my_flavor'); + }, + overrides: { + XcodeProjectInterpreter: () => + FakeXcodeProjectInterpreter(schemes: ['Runner', 'my_flavor']), + }, + ); + + testUsingContext( + 'schemeForBuildInfo throws error if flavor is not found', + () async { + final fs = MemoryFileSystem.test(); + final project = IosProject.fromFlutter(FakeFlutterProject(fileSystem: fs)); + project.xcodeProject.createSync(recursive: true); + const buildInfo = BuildInfo( + BuildMode.debug, + 'invalid_flavor', + treeShakeIcons: true, + packageConfigPath: '', + ); + await expectLater(project.schemeForBuildInfo(buildInfo), throwsToolExit()); + }, + overrides: { + XcodeProjectInterpreter: () => + FakeXcodeProjectInterpreter(schemes: ['Runner', 'my_flavor']), + }, + ); + group('usesSwiftPackageManager', () { testUsingContext( 'is true when iOS project exists', From 24a5c775548b08184bc5eb7c5a05cce9f3e8f554 Mon Sep 17 00:00:00 2001 From: Victoria Ashworth <15619084+vashworth@users.noreply.github.com> Date: Fri, 8 Aug 2025 14:32:03 -0500 Subject: [PATCH 19/23] [CP beta] Use LLDB as the default debugging method for iOS 17+ and Xcode 26+ (#173443) (#173472) Please fill in the form below, and a flutter domain expert will evaluate this cherry pick request. ### Issue Link: Part 2 of https://github.com/flutter/flutter/issues/144218 ### Changelog Description: Explain this cherry pick in one line that is accessible to most Flutter developers. See [best practices](https://github.com/flutter/flutter/blob/main/docs/releases/Hotfix-Documentation-Best-Practices.md) for examples Implementation of a future fix to allow Xcode 26 to `flutter run` twice in a row. ### Impact Description: What is the impact (ex. visual jank on Samsung phones, app crash, cannot ship an iOS app)? Does it impact development (ex. flutter doctor crashes when Android Studio is installed), or the shipping production app (the app crashes on launch) Flutter developers running Xcode 26 can `flutter run` to a tethered iOS device once. However subsequent `flutter run` attempts are likely to fail. ### Workaround: Is there a workaround for this issue? Quitting and reopening Xcode. ### Risk: What is the risk level of this cherry-pick? ### Test Coverage: Are you confident that your fix is well-tested by automated tests? ### Validation Steps: What are the steps to validate that this fix works? Create a flutter project and run `flutter run` twice in a row with a physical iOS 17+ device and Xcode 26. --- .ci.yaml | 10 + TESTOWNERS | 1 + .../hot_mode_dev_cycle_ios_xcode_debug.dart | 45 ++ packages/flutter_tools/lib/src/device.dart | 9 +- packages/flutter_tools/lib/src/features.dart | 21 + .../lib/src/flutter_features.dart | 3 + .../lib/src/ios/core_devices.dart | 61 +-- .../flutter_tools/lib/src/ios/devices.dart | 301 ++++++++----- packages/flutter_tools/lib/src/ios/lldb.dart | 8 +- .../flutter_tools/lib/src/macos/xcdevice.dart | 1 + .../test/general.shard/device_test.dart | 16 - .../general.shard/flutter_validator_test.dart | 3 + .../general.shard/ios/core_devices_test.dart | 416 +----------------- .../test/general.shard/ios/devices_test.dart | 23 + .../ios/ios_device_install_test.dart | 4 + .../ios/ios_device_project_test.dart | 4 + .../ios_device_start_nonprebuilt_test.dart | 109 ++++- .../ios/ios_device_start_prebuilt_test.dart | 415 ++++++++++++++++- .../test/general.shard/ios/lldb_test.dart | 6 +- packages/flutter_tools/test/src/fakes.dart | 7 + 20 files changed, 857 insertions(+), 606 deletions(-) create mode 100644 dev/devicelab/bin/tasks/hot_mode_dev_cycle_ios_xcode_debug.dart diff --git a/.ci.yaml b/.ci.yaml index f64818c597ac9..76dda50b3a2ff 100644 --- a/.ci.yaml +++ b/.ci.yaml @@ -5503,6 +5503,16 @@ targets: ["devicelab", "hostonly", "mac"] task_name: hot_mode_dev_cycle_ios_simulator + - name: Mac_ios hot_mode_dev_cycle_ios_xcode_debug + recipe: devicelab/devicelab_drone + bringup: true + presubmit: false + timeout: 60 + properties: + tags: > + ["devicelab", "ios", "mac"] + task_name: hot_mode_dev_cycle_ios_xcode_debug + - name: Mac_ios fullscreen_textfield_perf_ios__e2e_summary recipe: devicelab/devicelab_drone presubmit: false diff --git a/TESTOWNERS b/TESTOWNERS index c3ae76bbd6c0b..57640defc0d5e 100644 --- a/TESTOWNERS +++ b/TESTOWNERS @@ -268,6 +268,7 @@ /dev/devicelab/bin/tasks/hello_world_macos__compile.dart @cbracken @flutter/desktop /dev/devicelab/bin/tasks/hello_world_win_desktop__compile.dart @yaakovschectman @flutter/desktop /dev/devicelab/bin/tasks/hot_mode_dev_cycle_ios_simulator.dart @louisehsu @flutter/tool +/dev/devicelab/bin/tasks/hot_mode_dev_cycle_ios_xcode_debug.dart @vashworth @flutter/tool /dev/devicelab/bin/tasks/hot_mode_dev_cycle_macos_target__benchmark.dart @cbracken @flutter/tool /dev/devicelab/bin/tasks/hot_mode_dev_cycle_win_target__benchmark.dart @cbracken @flutter/desktop /dev/devicelab/bin/tasks/integration_ui_test_test_macos.dart @cbracken @flutter/desktop diff --git a/dev/devicelab/bin/tasks/hot_mode_dev_cycle_ios_xcode_debug.dart b/dev/devicelab/bin/tasks/hot_mode_dev_cycle_ios_xcode_debug.dart new file mode 100644 index 0000000000000..0beaa1d5d8815 --- /dev/null +++ b/dev/devicelab/bin/tasks/hot_mode_dev_cycle_ios_xcode_debug.dart @@ -0,0 +1,45 @@ +// 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_devicelab/framework/devices.dart'; +import 'package:flutter_devicelab/framework/framework.dart'; +import 'package:flutter_devicelab/framework/task_result.dart'; +import 'package:flutter_devicelab/framework/utils.dart'; +import 'package:flutter_devicelab/tasks/hot_mode_tests.dart'; +import 'package:path/path.dart' as path; + +/// This is a test to validate that Xcode debugging still works now that LLDB is the default. +Future main() async { + await task(() async { + deviceOperatingSystem = DeviceOperatingSystem.ios; + try { + await disableLLDBDebugging(); + // This isn't actually a benchmark test, so do not use the returned `benchmarkScoreKeys` result. + await createHotModeTest()(); + return TaskResult.success(null); + } finally { + await enableLLDBDebugging(); + } + }); +} + +Future disableLLDBDebugging() async { + final int configResult = await exec(path.join(flutterDirectory.path, 'bin', 'flutter'), [ + 'config', + '--no-enable-lldb-debugging', + ]); + if (configResult != 0) { + print('Failed to disable configuration, tasks may not run.'); + } +} + +Future enableLLDBDebugging() async { + final int configResult = await exec(path.join(flutterDirectory.path, 'bin', 'flutter'), [ + 'config', + '--enable-lldb-debugging', + ], canFail: true); + if (configResult != 0) { + print('Failed to enable configuration.'); + } +} diff --git a/packages/flutter_tools/lib/src/device.dart b/packages/flutter_tools/lib/src/device.dart index a7320df0bfb9e..9a9376276474b 100644 --- a/packages/flutter_tools/lib/src/device.dart +++ b/packages/flutter_tools/lib/src/device.dart @@ -1194,7 +1194,6 @@ class DebuggingOptions { String? route, Map platformArgs, { DeviceConnectionInterface interfaceType = DeviceConnectionInterface.attached, - bool isCoreDevice = false, }) { return [ if (enableDartProfiling) '--enable-dart-profiling', @@ -1207,13 +1206,7 @@ class DebuggingOptions { if (environmentType == EnvironmentType.simulator && dartFlags.isNotEmpty) '--dart-flags=$dartFlags', if (useTestFonts) '--use-test-fonts', - // Core Devices (iOS 17 devices) are debugged through Xcode so don't - // include these flags, which are used to check if the app was launched - // via Flutter CLI and `ios-deploy`. - if (debuggingEnabled && !isCoreDevice) ...[ - '--enable-checked-mode', - '--verify-entry-points', - ], + if (debuggingEnabled) ...['--enable-checked-mode', '--verify-entry-points'], if (enableSoftwareRendering) '--enable-software-rendering', if (traceSystrace) '--trace-systrace', if (traceToFile != null) '--trace-to-file="$traceToFile"', diff --git a/packages/flutter_tools/lib/src/features.dart b/packages/flutter_tools/lib/src/features.dart index 56ad9d44aced4..8c37bba5bc118 100644 --- a/packages/flutter_tools/lib/src/features.dart +++ b/packages/flutter_tools/lib/src/features.dart @@ -64,6 +64,9 @@ abstract class FeatureFlags { /// Tracking removal: . bool get isOmitLegacyVersionFileEnabled; + /// Whether physical iOS devices are debugging with LLDB. + bool get isLLDBDebuggingEnabled; + /// Whether a particular feature is enabled for the current channel. /// /// Prefer using one of the specific getters above instead of this API. @@ -83,6 +86,7 @@ abstract class FeatureFlags { nativeAssets, swiftPackageManager, omitLegacyVersionFile, + lldbDebugging, ]; /// All current Flutter feature flags that can be configured. @@ -203,6 +207,23 @@ const omitLegacyVersionFile = Feature( stable: FeatureChannelSetting(available: true), ); +/// Enable LLDB debugging for physical iOS devices. When LLDB debugging is off, +/// Xcode debugging is used instead. +/// +/// Requires iOS 17+ and Xcode 26+. If those requirements are not met, the previous +/// default debugging method is used instead. +const lldbDebugging = Feature( + name: 'support for debugging with LLDB for physical iOS devices', + extraHelpText: + 'If LLDB debugging is off, Xcode debugging is used instead. ' + 'Only available for iOS 17 or newer devices. Requires Xcode 26 or greater.', + configSetting: 'enable-lldb-debugging', + environmentOverride: 'FLUTTER_LLDB_DEBUGGING', + master: FeatureChannelSetting(available: true, enabledByDefault: true), + beta: FeatureChannelSetting(available: true, enabledByDefault: true), + stable: FeatureChannelSetting(available: true, enabledByDefault: true), +); + /// A [Feature] is a process for conditionally enabling tool features. /// /// All settings are optional, and if not provided will generally default to diff --git a/packages/flutter_tools/lib/src/flutter_features.dart b/packages/flutter_tools/lib/src/flutter_features.dart index 23bcc16d49fa2..507e274aa8395 100644 --- a/packages/flutter_tools/lib/src/flutter_features.dart +++ b/packages/flutter_tools/lib/src/flutter_features.dart @@ -54,6 +54,9 @@ mixin FlutterFeatureFlagsIsEnabled implements FeatureFlags { @override bool get isOmitLegacyVersionFileEnabled => isEnabled(omitLegacyVersionFile); + + @override + bool get isLLDBDebuggingEnabled => isEnabled(lldbDebugging); } interface class FlutterFeatureFlags extends FeatureFlags with FlutterFeatureFlagsIsEnabled { diff --git a/packages/flutter_tools/lib/src/ios/core_devices.dart b/packages/flutter_tools/lib/src/ios/core_devices.dart index 418f0559a5951..bab9e5d27ce1b 100644 --- a/packages/flutter_tools/lib/src/ios/core_devices.dart +++ b/packages/flutter_tools/lib/src/ios/core_devices.dart @@ -66,7 +66,7 @@ class IOSCoreDeviceLauncher { } // Launch app to device - final IOSCoreDeviceLaunchResult? launchResult = await _coreDeviceControl.launchAppInternal( + final IOSCoreDeviceLaunchResult? launchResult = await _coreDeviceControl.launchApp( deviceId: deviceId, bundleId: bundleId, launchArguments: launchArguments, @@ -99,7 +99,7 @@ class IOSCoreDeviceLauncher { } // Launch app on device, but start it stopped so it will wait until the debugger is attached before starting. - final IOSCoreDeviceLaunchResult? launchResult = await _coreDeviceControl.launchAppInternal( + final IOSCoreDeviceLaunchResult? launchResult = await _coreDeviceControl.launchApp( deviceId: deviceId, bundleId: bundleId, launchArguments: launchArguments, @@ -532,66 +532,11 @@ class IOSCoreDeviceControl { } } - Future launchApp({ - required String deviceId, - required String bundleId, - List launchArguments = const [], - }) async { - if (!_xcode.isDevicectlInstalled) { - _logger.printError('devicectl is not installed.'); - return false; - } - - final Directory tempDirectory = _fileSystem.systemTempDirectory.createTempSync('core_devices.'); - final File output = tempDirectory.childFile('launch_results.json'); - output.createSync(); - - final command = [ - ..._xcode.xcrunCommand(), - 'devicectl', - 'device', - 'process', - 'launch', - '--device', - deviceId, - bundleId, - if (launchArguments.isNotEmpty) ...launchArguments, - '--json-output', - output.path, - ]; - - try { - await _processUtils.run(command, throwOnError: true); - final String stringOutput = output.readAsStringSync(); - - try { - final Object? decodeResult = (json.decode(stringOutput) as Map)['info']; - if (decodeResult is Map && decodeResult['outcome'] == 'success') { - return true; - } - _logger.printError('devicectl returned unexpected JSON response: $stringOutput'); - return false; - } on FormatException { - // We failed to parse the devicectl output, or it returned junk. - _logger.printError('devicectl returned non-JSON response: $stringOutput'); - return false; - } - } on ProcessException catch (err) { - _logger.printError('Error executing devicectl: $err'); - return false; - } finally { - tempDirectory.deleteSync(recursive: true); - } - } - /// Launches the app on the device. /// /// If [startStopped] is true, the app will be launched and paused, waiting /// for a debugger to attach. - // TODO(vashworth): Rename this method to launchApp and replace old version. - // See https://github.com/flutter/flutter/issues/173416. - @visibleForTesting - Future launchAppInternal({ + Future launchApp({ required String deviceId, required String bundleId, List launchArguments = const [], diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart index 98b4e87d6e645..a6dd75ee5a90d 100644 --- a/packages/flutter_tools/lib/src/ios/devices.dart +++ b/packages/flutter_tools/lib/src/ios/devices.dart @@ -6,6 +6,7 @@ import 'dart:async'; import 'package:meta/meta.dart'; import 'package:process/process.dart'; +import 'package:unified_analytics/unified_analytics.dart'; import 'package:vm_service/vm_service.dart' as vm_service; import '../application_package.dart'; @@ -24,6 +25,7 @@ import '../darwin/darwin.dart'; import '../device.dart'; import '../device_port_forwarder.dart'; import '../device_vm_service_discovery_for_attach.dart'; +import '../features.dart'; import '../globals.dart' as globals; import '../macos/xcdevice.dart'; import '../mdns_discovery.dart'; @@ -59,6 +61,15 @@ In the meantime, we recommend these temporary workarounds: profile mode via --release or --profile flags. ════════════════════════════════════════════════════════════════════════════════'''; +enum IOSDeploymentMethod { + iosDeployLaunch, + iosDeployLaunchAndAttach, + coreDeviceWithoutDebugger, + coreDeviceWithLLDB, + coreDeviceWithXcode, + coreDeviceWithXcodeFallback, +} + class IOSDevices extends PollingDeviceDiscovery { IOSDevices({ required Platform platform, @@ -284,6 +295,7 @@ class IOSDevice extends Device { required XcodeDebug xcodeDebug, required IProxy iProxy, required super.logger, + required Analytics analytics, }) : _sdkVersion = sdkVersion, _iosDeploy = iosDeploy, _iMobileDevice = iMobileDevice, @@ -293,6 +305,7 @@ class IOSDevice extends Device { _iproxy = iProxy, _fileSystem = fileSystem, _logger = logger, + _analytics = analytics, _platform = platform, super(category: Category.mobile, platformType: PlatformType.ios, ephemeral: true) { if (!_platform.isMacOS) { @@ -303,14 +316,12 @@ class IOSDevice extends Device { final String? _sdkVersion; final IOSDeploy _iosDeploy; + final Analytics _analytics; final FileSystem _fileSystem; final Logger _logger; final Platform _platform; final IMobileDevice _iMobileDevice; final IOSCoreDeviceControl _coreDeviceControl; - - // TODO(vashworth): See https://github.com/flutter/flutter/issues/173416. - // ignore: unused_field final IOSCoreDeviceLauncher _coreDeviceLauncher; final XcodeDebug _xcodeDebug; final IProxy _iproxy; @@ -492,7 +503,7 @@ class IOSDevice extends Device { _logger.printError('Could not build the precompiled application for the device.'); await diagnoseXcodeBuildFailure( buildResult, - analytics: globals.analytics, + analytics: _analytics, fileSystem: globals.fs, logger: globals.logger, platform: FlutterDarwinPlatform.ios, @@ -519,9 +530,10 @@ class IOSDevice extends Device { route, platformArgs, interfaceType: connectionInterface, - isCoreDevice: isCoreDevice, ); Status startAppStatus = _logger.startProgress('Installing and launching...'); + + IOSDeploymentMethod? deploymentMethod; try { ProtocolDiscovery? vmServiceDiscovery; var installationResult = 1; @@ -537,18 +549,21 @@ class IOSDevice extends Device { } if (isCoreDevice) { - installationResult = - await _startAppOnCoreDevice( - debuggingOptions: debuggingOptions, - package: package, - launchArguments: launchArguments, - mainPath: mainPath, - discoveryTimeout: discoveryTimeout, - shutdownHooks: shutdownHooks ?? globals.shutdownHooks, - ) - ? 0 - : 1; + final ( + bool result, + IOSDeploymentMethod coreDeviceDeploymentMethod, + ) = await _startAppOnCoreDevice( + debuggingOptions: debuggingOptions, + package: package, + launchArguments: launchArguments, + mainPath: mainPath, + discoveryTimeout: discoveryTimeout, + shutdownHooks: shutdownHooks ?? globals.shutdownHooks, + ); + installationResult = result ? 0 : 1; + deploymentMethod = coreDeviceDeploymentMethod; } else if (iosDeployDebugger == null) { + deploymentMethod = IOSDeploymentMethod.iosDeployLaunch; installationResult = await _iosDeploy.launchApp( deviceId: id, bundlePath: bundle.path, @@ -558,15 +573,30 @@ class IOSDevice extends Device { uninstallFirst: debuggingOptions.uninstallFirst, ); } else { + deploymentMethod = IOSDeploymentMethod.iosDeployLaunchAndAttach; installationResult = await iosDeployDebugger!.launchAndAttach() ? 0 : 1; } if (installationResult != 0) { + _analytics.send( + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: deploymentMethod.name, + result: 'launch failed', + ), + ); _printInstallError(bundle); await dispose(); return LaunchResult.failed(); } if (!debuggingOptions.debuggingEnabled) { + _analytics.send( + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: deploymentMethod.name, + result: 'release success', + ), + ); return LaunchResult.succeeded(); } @@ -587,13 +617,6 @@ class IOSDevice extends Device { _logger.printError( 'The Dart VM Service was not discovered after $defaultTimeout seconds. This is taking much longer than expected...', ); - if (isCoreDevice && debuggingOptions.debuggingEnabled) { - _logger.printError( - 'Open the Xcode window the project is opened in to ensure the app ' - 'is running. If the app is not running, try selecting "Product > Run" ' - 'to fix the problem.', - ); - } // If debugging with a wireless device and the timeout is reached, remind the // user to allow local network permissions. if (isWirelesslyConnected) { @@ -632,6 +655,13 @@ class IOSDevice extends Device { if (serviceURL == null) { await iosDeployDebugger?.stopAndDumpBacktrace(); await dispose(); + _analytics.send( + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: deploymentMethod.name, + result: 'wireless debugging failed', + ), + ); return LaunchResult.failed(); } @@ -688,13 +718,36 @@ class IOSDevice extends Device { if (localUri == null) { await iosDeployDebugger?.stopAndDumpBacktrace(); await dispose(); + _analytics.send( + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: deploymentMethod.name, + result: 'debugging failed', + ), + ); return LaunchResult.failed(); } + _analytics.send( + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: deploymentMethod.name, + result: 'debugging success', + ), + ); return LaunchResult.succeeded(vmServiceUri: localUri); } on ProcessException catch (e) { await iosDeployDebugger?.stopAndDumpBacktrace(); _logger.printError(e.message); await dispose(); + if (deploymentMethod != null) { + _analytics.send( + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: deploymentMethod.name, + result: 'process exception', + ), + ); + } return LaunchResult.failed(); } finally { startAppStatus.stop(); @@ -881,17 +934,25 @@ class IOSDevice extends Device { ); } + /// Uses either `devicectl` or Xcode automation to install, launch, and debug + /// apps on physical iOS devices. + /// /// Starting with Xcode 15 and iOS 17, `ios-deploy` stopped working due to /// the new CoreDevice connectivity stack. Previously, `ios-deploy` was used /// to install the app, launch the app, and start `debugserver`. + /// /// Xcode 15 introduced a new command line tool called `devicectl` that /// includes much of the functionality supplied by `ios-deploy`. However, - /// `devicectl` lacks the ability to start a `debugserver` and therefore `ptrace`, which are needed - /// for debug mode due to using a JIT Dart VM. + /// `devicectl` lacked the ability to start a `debugserver` and therefore `ptrace`, + /// which are needed for debug mode due to using a JIT Dart VM. + /// + /// Xcode 16 introduced a command to lldb that allows you to start a debugserver, which + /// can be used in unison with `devicectl`. /// /// Therefore, when starting an app on a CoreDevice, use `devicectl` when - /// debugging is not enabled. Otherwise, use Xcode automation. - Future _startAppOnCoreDevice({ + /// debugging is not enabled. If using Xcode 16, use `devicectl` and `lldb`. + /// Otherwise use Xcode automation. + Future<(bool, IOSDeploymentMethod)> _startAppOnCoreDevice({ required DebuggingOptions debuggingOptions, required IOSApp package, required List launchArguments, @@ -908,98 +969,140 @@ class IOSDevice extends Device { bundlePath: package.deviceBundlePath, ); if (!installSuccess) { - return installSuccess; + return (installSuccess, IOSDeploymentMethod.coreDeviceWithoutDebugger); } // Launch app to device - final bool launchSuccess = await _coreDeviceControl.launchApp( + final IOSCoreDeviceLaunchResult? launchResult = await _coreDeviceControl.launchApp( deviceId: id, bundleId: package.id, launchArguments: launchArguments, ); + final bool launchSuccess = launchResult != null && launchResult.outcome == 'success'; - return launchSuccess; - } else { - _logger.printStatus( - 'You may be prompted to give access to control Xcode. Flutter uses Xcode ' - 'to run your app. If access is not allowed, you can change this through ' - 'your Settings > Privacy & Security > Automation.', + return (launchSuccess, IOSDeploymentMethod.coreDeviceWithoutDebugger); + } + + IOSDeploymentMethod? deploymentMethod; + + // Xcode 16 introduced a way to start and attach to a debugserver through LLDB. + // However, it doesn't work reliably until Xcode 26. + // Use LLDB if Xcode version is greater than 26 and the feature is enabled. + final Version? xcodeVersion = globals.xcode?.currentVersion; + final bool lldbFeatureEnabled = featureFlags.isLLDBDebuggingEnabled; + if (xcodeVersion != null && xcodeVersion.major >= 26 && lldbFeatureEnabled) { + final bool launchSuccess = await _coreDeviceLauncher.launchAppWithLLDBDebugger( + deviceId: id, + bundlePath: package.deviceBundlePath, + bundleId: package.id, + launchArguments: launchArguments, ); - final launchTimeout = isWirelesslyConnected ? 45 : 30; - final timer = Timer(discoveryTimeout ?? Duration(seconds: launchTimeout), () { - _logger.printError( - 'Xcode is taking longer than expected to start debugging the app. ' - 'Ensure the project is opened in Xcode.', + + // If it succeeds to launch with LLDB, return, otherwise continue on to + // try launching with Xcode. + if (launchSuccess) { + return (launchSuccess, IOSDeploymentMethod.coreDeviceWithLLDB); + } else { + deploymentMethod = IOSDeploymentMethod.coreDeviceWithXcodeFallback; + _analytics.send( + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: IOSDeploymentMethod.coreDeviceWithLLDB.name, + result: 'launch failed', + ), ); - }); + } + } - XcodeDebugProject debugProject; - final FlutterProject flutterProject = FlutterProject.current(); + deploymentMethod ??= IOSDeploymentMethod.coreDeviceWithXcode; - if (package is PrebuiltIOSApp) { - debugProject = await _xcodeDebug.createXcodeProjectWithCustomBundle( - package.deviceBundlePath, - templateRenderer: globals.templateRenderer, - verboseLogging: _logger.isVerbose, - ); - } else if (package is BuildableIOSApp) { - // Before installing/launching/debugging with Xcode, update the build - // settings to use a custom configuration build directory so Xcode - // knows where to find the app bundle to launch. - final Directory bundle = _fileSystem.directory(package.deviceBundlePath); - await updateGeneratedXcodeProperties( - project: flutterProject, - buildInfo: debuggingOptions.buildInfo, - targetOverride: mainPath, - configurationBuildDir: bundle.parent.absolute.path, - ); + // If LLDB is not available or fails, fallback to using Xcode. + _logger.printStatus( + 'You may be prompted to give access to control Xcode. Flutter uses Xcode ' + 'to run your app. If access is not allowed, you can change this through ' + 'your Settings > Privacy & Security > Automation.', + ); + final launchTimeout = isWirelesslyConnected ? 45 : 30; + final timer = Timer(discoveryTimeout ?? Duration(seconds: launchTimeout), () { + _logger.printError( + 'Xcode is taking longer than expected to start debugging the app. ' + 'If the issue persists, try closing Xcode and re-running your Flutter command.', + ); + }); - final IosProject project = package.project; - final XcodeProjectInfo? projectInfo = await project.projectInfo(); - if (projectInfo == null) { - globals.printError('Xcode project not found.'); - return false; - } - if (project.xcodeWorkspace == null) { - globals.printError('Unable to get Xcode workspace.'); - return false; - } - final String? scheme = projectInfo.schemeFor(debuggingOptions.buildInfo); - if (scheme == null) { - projectInfo.reportFlavorNotFoundAndExit(); - } + XcodeDebugProject debugProject; + final FlutterProject flutterProject = FlutterProject.current(); - _xcodeDebug.ensureXcodeDebuggerLaunchAction(project.xcodeProjectSchemeFile(scheme: scheme)); + if (package is PrebuiltIOSApp) { + debugProject = await _xcodeDebug.createXcodeProjectWithCustomBundle( + package.deviceBundlePath, + templateRenderer: globals.templateRenderer, + verboseLogging: _logger.isVerbose, + ); + } else if (package is BuildableIOSApp) { + // Before installing/launching/debugging with Xcode, update the build + // settings to use a custom configuration build directory so Xcode + // knows where to find the app bundle to launch. + final Directory bundle = _fileSystem.directory(package.deviceBundlePath); + await updateGeneratedXcodeProperties( + project: flutterProject, + buildInfo: debuggingOptions.buildInfo, + targetOverride: mainPath, + configurationBuildDir: bundle.parent.absolute.path, + ); - debugProject = XcodeDebugProject( - scheme: scheme, - xcodeProject: project.xcodeProject, - xcodeWorkspace: project.xcodeWorkspace!, - hostAppProjectName: project.hostAppProjectName, - expectedConfigurationBuildDir: bundle.parent.absolute.path, - verboseLogging: _logger.isVerbose, - ); - } else { - // This should not happen. Currently, only PrebuiltIOSApp and - // BuildableIOSApp extend from IOSApp. - _logger.printError('IOSApp type ${package.runtimeType} is not recognized.'); - return false; + final IosProject project = package.project; + final XcodeProjectInfo? projectInfo = await project.projectInfo(); + if (projectInfo == null) { + globals.printError('Xcode project not found.'); + return (false, deploymentMethod); + } + if (project.xcodeWorkspace == null) { + globals.printError('Unable to get Xcode workspace.'); + return (false, deploymentMethod); + } + final String? scheme = projectInfo.schemeFor(debuggingOptions.buildInfo); + if (scheme == null) { + projectInfo.reportFlavorNotFoundAndExit(); } - final bool debugSuccess = await _xcodeDebug.debugApp( - project: debugProject, - deviceId: id, - launchArguments: launchArguments, + _xcodeDebug.ensureXcodeDebuggerLaunchAction(project.xcodeProjectSchemeFile(scheme: scheme)); + + debugProject = XcodeDebugProject( + scheme: scheme, + xcodeProject: project.xcodeProject, + xcodeWorkspace: project.xcodeWorkspace!, + hostAppProjectName: project.hostAppProjectName, + expectedConfigurationBuildDir: bundle.parent.absolute.path, + verboseLogging: _logger.isVerbose, ); - timer.cancel(); + } else { + // This should not happen. Currently, only PrebuiltIOSApp and + // BuildableIOSApp extend from IOSApp. + _logger.printError('IOSApp type ${package.runtimeType} is not recognized.'); + return (false, deploymentMethod); + } - // Kill Xcode on shutdown when running from CI - if (debuggingOptions.usingCISystem) { - shutdownHooks.addShutdownHook(() => _xcodeDebug.exit(force: true)); - } + // Core Devices (iOS 17 devices) are debugged through Xcode so don't + // include these flags, which are used to check if the app was launched + // via Flutter CLI and `ios-deploy`. + final List filteredLaunchArguments = launchArguments + .where((String arg) => arg != '--enable-checked-mode' && arg != '--verify-entry-points') + .toList(); + + final bool debugSuccess = await _xcodeDebug.debugApp( + project: debugProject, + deviceId: id, + launchArguments: filteredLaunchArguments, + ); + timer.cancel(); - return debugSuccess; + // Kill Xcode on shutdown when running from CI + if (debuggingOptions.usingCISystem) { + shutdownHooks.addShutdownHook(() => _xcodeDebug.exit(force: true)); } + + return (debugSuccess, deploymentMethod); } @override @@ -1012,7 +1115,7 @@ class IOSDevice extends Device { if (_xcodeDebug.debugStarted) { return _xcodeDebug.exit(); } - return false; + return _coreDeviceLauncher.stopApp(deviceId: id); } @override diff --git a/packages/flutter_tools/lib/src/ios/lldb.dart b/packages/flutter_tools/lib/src/ios/lldb.dart index 76912f3d9a1c9..025876ecec4f6 100644 --- a/packages/flutter_tools/lib/src/ios/lldb.dart +++ b/packages/flutter_tools/lib/src/ios/lldb.dart @@ -99,7 +99,7 @@ return False await _attachToAppProcess(appProcessId); await _resumeProcess(); } on _LLDBError catch (e) { - _logger.printError('lldb failed with error: ${e.message}'); + _logger.printTrace('lldb failed with error: ${e.message}'); exit(); return false; } finally { @@ -115,7 +115,7 @@ return False /// to `stderr`, complete with an error and stop the process. Future _startLLDB(int appProcessId) async { if (_lldbProcess != null) { - _logger.printError( + _logger.printTrace( 'An LLDB process is already running. It must be stopped before starting a new one.', ); return false; @@ -139,7 +139,7 @@ return False .transform(utf8.decoder) .transform(const LineSplitter()) .listen((String line) { - _logger.printError('[lldb]: $line'); + _logger.printTrace('[lldb]: $line'); _monitorError(line); }); @@ -155,7 +155,7 @@ return False }), ); } on ProcessException catch (exception) { - _logger.printError('Process exception running lldb:\n$exception'); + _logger.printTrace('Process exception running lldb:\n$exception'); return false; } return true; diff --git a/packages/flutter_tools/lib/src/macos/xcdevice.dart b/packages/flutter_tools/lib/src/macos/xcdevice.dart index 687784cb37f19..9534531b1eb7d 100644 --- a/packages/flutter_tools/lib/src/macos/xcdevice.dart +++ b/packages/flutter_tools/lib/src/macos/xcdevice.dart @@ -602,6 +602,7 @@ class XCDevice { iProxy: _iProxy, fileSystem: globals.fs, logger: _logger, + analytics: globals.analytics, iosDeploy: _iosDeploy, iMobileDevice: _iMobileDevice, coreDeviceControl: _coreDeviceControl, diff --git a/packages/flutter_tools/test/general.shard/device_test.dart b/packages/flutter_tools/test/general.shard/device_test.dart index 831103ee4cc2e..09f01d13d73ce 100644 --- a/packages/flutter_tools/test/general.shard/device_test.dart +++ b/packages/flutter_tools/test/general.shard/device_test.dart @@ -789,22 +789,6 @@ void main() { }, ); - testWithoutContext( - 'Get launch arguments for physical CoreDevice with debugging enabled with no launch arguments', - () { - final original = DebuggingOptions.enabled(BuildInfo.debug); - - final List launchArguments = original.getIOSLaunchArguments( - EnvironmentType.physical, - null, - {}, - isCoreDevice: true, - ); - - expect(launchArguments.join(' '), ['--enable-dart-profiling'].join(' ')); - }, - ); - testWithoutContext('Get launch arguments for physical device with iPv4 network connection', () { final original = DebuggingOptions.enabled(BuildInfo.debug); diff --git a/packages/flutter_tools/test/general.shard/flutter_validator_test.dart b/packages/flutter_tools/test/general.shard/flutter_validator_test.dart index 31e8c21681680..fb0235f5130cb 100644 --- a/packages/flutter_tools/test/general.shard/flutter_validator_test.dart +++ b/packages/flutter_tools/test/general.shard/flutter_validator_test.dart @@ -815,6 +815,9 @@ class FakeFlutterFeatures extends FeatureFlags { @override bool get isOmitLegacyVersionFileEnabled => _enabled; + @override + bool get isLLDBDebuggingEnabled => _enabled; + @override final List allFeatures; diff --git a/packages/flutter_tools/test/general.shard/ios/core_devices_test.dart b/packages/flutter_tools/test/general.shard/ios/core_devices_test.dart index eedfa39685475..80434cac57e6f 100644 --- a/packages/flutter_tools/test/general.shard/ios/core_devices_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/core_devices_test.dart @@ -681,13 +681,13 @@ void main() { }); testWithoutContext('fails to launch app', () async { - final bool status = await deviceControl.launchApp( + final IOSCoreDeviceLaunchResult? result = await deviceControl.launchApp( deviceId: 'device-id', bundleId: 'com.example.flutterApp', ); expect(fakeProcessManager, hasNoRemainingExpectations); - expect(logger.errorText, contains('devicectl is not installed.')); - expect(status, isFalse); + expect(logger.traceText, contains('devicectl is not installed.')); + expect(result, isNull); }); testWithoutContext('fails to check if app is installed', () async { @@ -1287,403 +1287,7 @@ invalid JSON ), ); - final bool status = await deviceControl.launchApp(deviceId: deviceId, bundleId: bundleId); - - expect(fakeProcessManager, hasNoRemainingExpectations); - expect(logger.errorText, isEmpty); - expect(tempFile, isNot(exists)); - expect(status, true); - }); - - testWithoutContext('Successful launch with launch args', () async { - const deviceControlOutput = ''' -{ - "info" : { - "arguments" : [ - "devicectl", - "device", - "process", - "launch", - "--device", - "00001234-0001234A3C03401E", - "com.example.flutterApp", - "--arg1", - "--arg2", - "--json-output", - "/var/folders/wq/randompath/T/flutter_tools.rand0/core_devices.rand0/install_results.json" - ], - "commandType" : "devicectl.device.process.launch", - "environment" : { - - }, - "outcome" : "success", - "version" : "341" - }, - "result" : { - "deviceIdentifier" : "123456BB5-AEDE-7A22-B890-1234567890DD", - "launchOptions" : { - "activatedWhenStarted" : true, - "arguments" : [ - - ], - "environmentVariables" : { - "TERM" : "vt100" - }, - "platformSpecificOptions" : { - - }, - "startStopped" : false, - "terminateExistingInstances" : false, - "user" : { - "active" : true - } - }, - "process" : { - "auditToken" : [ - 12345, - 678 - ], - "executable" : "file:///private/var/containers/Bundle/Application/12345E6A-7F89-0C12-345E-F6A7E890CFF1/Runner.app/Runner", - "processIdentifier" : 1234 - } - } -} -'''; - final File tempFile = fileSystem.systemTempDirectory - .childDirectory('core_devices.rand0') - .childFile('launch_results.json'); - fakeProcessManager.addCommand( - FakeCommand( - command: [ - 'xcrun', - 'devicectl', - 'device', - 'process', - 'launch', - '--device', - deviceId, - bundleId, - '--arg1', - '--arg2', - '--json-output', - tempFile.path, - ], - onRun: (_) { - expect(tempFile, exists); - tempFile.writeAsStringSync(deviceControlOutput); - }, - ), - ); - - final bool status = await deviceControl.launchApp( - deviceId: deviceId, - bundleId: bundleId, - launchArguments: ['--arg1', '--arg2'], - ); - - expect(fakeProcessManager, hasNoRemainingExpectations); - expect(logger.errorText, isEmpty); - expect(tempFile, isNot(exists)); - expect(status, true); - }); - - testWithoutContext('devicectl fails install with an error', () async { - const deviceControlOutput = ''' -{ - "error" : { - "code" : -10814, - "domain" : "NSOSStatusErrorDomain", - "userInfo" : { - "_LSFunction" : { - "string" : "runEvaluator" - }, - "_LSLine" : { - "int" : 1608 - } - } - }, - "info" : { - "arguments" : [ - "devicectl", - "device", - "process", - "launch", - "--device", - "00001234-0001234A3C03401E", - "com.example.flutterApp", - "--json-output", - "/var/folders/wq/randompath/T/flutter_tools.rand0/core_devices.rand0/install_results.json" - ], - "commandType" : "devicectl.device.process.launch", - "environment" : { - - }, - "outcome" : "failed", - "version" : "341" - } -} -'''; - final File tempFile = fileSystem.systemTempDirectory - .childDirectory('core_devices.rand0') - .childFile('launch_results.json'); - fakeProcessManager.addCommand( - FakeCommand( - command: [ - 'xcrun', - 'devicectl', - 'device', - 'process', - 'launch', - '--device', - deviceId, - bundleId, - '--json-output', - tempFile.path, - ], - onRun: (_) { - expect(tempFile, exists); - tempFile.writeAsStringSync(deviceControlOutput); - }, - exitCode: 1, - stderr: ''' -ERROR: The operation couldn?t be completed. (OSStatus error -10814.) (NSOSStatusErrorDomain error -10814.) - _LSFunction = runEvaluator - _LSLine = 1608 -''', - ), - ); - - final bool status = await deviceControl.launchApp(deviceId: deviceId, bundleId: bundleId); - - expect(fakeProcessManager, hasNoRemainingExpectations); - expect(logger.errorText, contains('ERROR: The operation couldn?t be completed.')); - expect(tempFile, isNot(exists)); - expect(status, false); - }); - - testWithoutContext('devicectl fails install without an error', () async { - const deviceControlOutput = ''' -{ - "error" : { - "code" : -10814, - "domain" : "NSOSStatusErrorDomain", - "userInfo" : { - "_LSFunction" : { - "string" : "runEvaluator" - }, - "_LSLine" : { - "int" : 1608 - } - } - }, - "info" : { - "arguments" : [ - "devicectl", - "device", - "process", - "launch", - "--device", - "00001234-0001234A3C03401E", - "com.example.flutterApp", - "--json-output", - "/var/folders/wq/randompath/T/flutter_tools.rand0/core_devices.rand0/install_results.json" - ], - "commandType" : "devicectl.device.process.launch", - "environment" : { - - }, - "outcome" : "failed", - "version" : "341" - } -} -'''; - final File tempFile = fileSystem.systemTempDirectory - .childDirectory('core_devices.rand0') - .childFile('launch_results.json'); - fakeProcessManager.addCommand( - FakeCommand( - command: [ - 'xcrun', - 'devicectl', - 'device', - 'process', - 'launch', - '--device', - deviceId, - bundleId, - '--json-output', - tempFile.path, - ], - onRun: (_) { - expect(tempFile, exists); - tempFile.writeAsStringSync(deviceControlOutput); - }, - ), - ); - - final bool status = await deviceControl.launchApp(deviceId: deviceId, bundleId: bundleId); - - expect(fakeProcessManager, hasNoRemainingExpectations); - expect(tempFile, isNot(exists)); - expect(status, false); - }); - - testWithoutContext('fails launch because of unexpected JSON', () async { - const deviceControlOutput = ''' -{ - "valid_unexpected_json": true -} -'''; - final File tempFile = fileSystem.systemTempDirectory - .childDirectory('core_devices.rand0') - .childFile('launch_results.json'); - fakeProcessManager.addCommand( - FakeCommand( - command: [ - 'xcrun', - 'devicectl', - 'device', - 'process', - 'launch', - '--device', - deviceId, - bundleId, - '--json-output', - tempFile.path, - ], - onRun: (_) { - expect(tempFile, exists); - tempFile.writeAsStringSync(deviceControlOutput); - }, - ), - ); - - final bool status = await deviceControl.launchApp(deviceId: deviceId, bundleId: bundleId); - - expect(fakeProcessManager, hasNoRemainingExpectations); - expect(logger.errorText, contains('devicectl returned unexpected JSON response')); - expect(tempFile, isNot(exists)); - expect(status, false); - }); - - testWithoutContext('fails launch because of invalid JSON', () async { - const deviceControlOutput = ''' -invalid JSON -'''; - final File tempFile = fileSystem.systemTempDirectory - .childDirectory('core_devices.rand0') - .childFile('launch_results.json'); - fakeProcessManager.addCommand( - FakeCommand( - command: [ - 'xcrun', - 'devicectl', - 'device', - 'process', - 'launch', - '--device', - deviceId, - bundleId, - '--json-output', - tempFile.path, - ], - onRun: (_) { - expect(tempFile, exists); - tempFile.writeAsStringSync(deviceControlOutput); - }, - ), - ); - - final bool status = await deviceControl.launchApp(deviceId: deviceId, bundleId: bundleId); - - expect(fakeProcessManager, hasNoRemainingExpectations); - expect(logger.errorText, contains('devicectl returned non-JSON response')); - expect(tempFile, isNot(exists)); - expect(status, false); - }); - }); - - group('launchAppInternal', () { - const deviceId = 'device-id'; - const bundleId = 'com.example.flutterApp'; - - testWithoutContext('Successful launch without launch args', () async { - const deviceControlOutput = ''' -{ - "info" : { - "arguments" : [ - "devicectl", - "device", - "process", - "launch", - "--device", - "00001234-0001234A3C03401E", - "com.example.flutterApp", - "--json-output", - "/var/folders/wq/randompath/T/flutter_tools.rand0/core_devices.rand0/install_results.json" - ], - "commandType" : "devicectl.device.process.launch", - "environment" : { - - }, - "outcome" : "success", - "version" : "341" - }, - "result" : { - "deviceIdentifier" : "123456BB5-AEDE-7A22-B890-1234567890DD", - "launchOptions" : { - "activatedWhenStarted" : true, - "arguments" : [ - - ], - "environmentVariables" : { - "TERM" : "vt100" - }, - "platformSpecificOptions" : { - - }, - "startStopped" : false, - "terminateExistingInstances" : false, - "user" : { - "active" : true - } - }, - "process" : { - "auditToken" : [ - 12345, - 678 - ], - "executable" : "file:///private/var/containers/Bundle/Application/12345E6A-7F89-0C12-345E-F6A7E890CFF1/Runner.app/Runner", - "processIdentifier" : 1234 - } - } -} -'''; - final File tempFile = fileSystem.systemTempDirectory - .childDirectory('core_devices.rand0') - .childFile('launch_results.json'); - fakeProcessManager.addCommand( - FakeCommand( - command: [ - 'xcrun', - 'devicectl', - 'device', - 'process', - 'launch', - '--device', - deviceId, - bundleId, - '--json-output', - tempFile.path, - ], - onRun: (_) { - expect(tempFile, exists); - tempFile.writeAsStringSync(deviceControlOutput); - }, - ), - ); - - final IOSCoreDeviceLaunchResult? result = await deviceControl.launchAppInternal( + final IOSCoreDeviceLaunchResult? result = await deviceControl.launchApp( deviceId: deviceId, bundleId: bundleId, ); @@ -1775,7 +1379,7 @@ invalid JSON ), ); - final IOSCoreDeviceLaunchResult? result = await deviceControl.launchAppInternal( + final IOSCoreDeviceLaunchResult? result = await deviceControl.launchApp( deviceId: deviceId, bundleId: bundleId, launchArguments: ['--arg1', '--arg2'], @@ -1854,7 +1458,7 @@ ERROR: The operation couldn?t be completed. (OSStatus error -10814.) (NSOSStatus ), ); - final IOSCoreDeviceLaunchResult? result = await deviceControl.launchAppInternal( + final IOSCoreDeviceLaunchResult? result = await deviceControl.launchApp( deviceId: deviceId, bundleId: bundleId, ); @@ -1925,7 +1529,7 @@ ERROR: The operation couldn?t be completed. (OSStatus error -10814.) (NSOSStatus ), ); - final IOSCoreDeviceLaunchResult? result = await deviceControl.launchAppInternal( + final IOSCoreDeviceLaunchResult? result = await deviceControl.launchApp( deviceId: deviceId, bundleId: bundleId, ); @@ -1966,7 +1570,7 @@ ERROR: The operation couldn?t be completed. (OSStatus error -10814.) (NSOSStatus ), ); - final IOSCoreDeviceLaunchResult? result = await deviceControl.launchAppInternal( + final IOSCoreDeviceLaunchResult? result = await deviceControl.launchApp( deviceId: deviceId, bundleId: bundleId, ); @@ -2005,7 +1609,7 @@ invalid JSON ), ); - final IOSCoreDeviceLaunchResult? result = await deviceControl.launchAppInternal( + final IOSCoreDeviceLaunchResult? result = await deviceControl.launchApp( deviceId: deviceId, bundleId: bundleId, ); @@ -3584,7 +3188,7 @@ class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl { } @override - Future launchAppInternal({ + Future launchApp({ required String deviceId, required String bundleId, List launchArguments = const [], diff --git a/packages/flutter_tools/test/general.shard/ios/devices_test.dart b/packages/flutter_tools/test/general.shard/ios/devices_test.dart index 9b43d4c117663..f09b38040af65 100644 --- a/packages/flutter_tools/test/general.shard/ios/devices_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/devices_test.dart @@ -28,6 +28,7 @@ import 'package:flutter_tools/src/ios/mac.dart'; import 'package:flutter_tools/src/ios/xcode_debug.dart'; import 'package:flutter_tools/src/macos/xcdevice.dart'; import 'package:test/fake.dart'; +import 'package:unified_analytics/unified_analytics.dart'; import '../../src/common.dart'; import '../../src/fake_process_manager.dart'; @@ -79,6 +80,7 @@ void main() { logger: logger, platform: macPlatform, iosDeploy: iosDeploy, + analytics: FakeAnalytics(), iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, coreDeviceLauncher: coreDeviceLauncher, @@ -101,6 +103,7 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, logger: logger, + analytics: FakeAnalytics(), platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, @@ -125,6 +128,7 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, logger: logger, + analytics: FakeAnalytics(), platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, @@ -148,6 +152,7 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, logger: logger, + analytics: FakeAnalytics(), platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, @@ -171,6 +176,7 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, logger: logger, + analytics: FakeAnalytics(), platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, @@ -194,6 +200,7 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, logger: logger, + analytics: FakeAnalytics(), platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, @@ -217,6 +224,7 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, logger: logger, + analytics: FakeAnalytics(), platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, @@ -242,6 +250,7 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, logger: logger, + analytics: FakeAnalytics(), platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, @@ -267,6 +276,7 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, logger: logger, + analytics: FakeAnalytics(), platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, @@ -292,6 +302,7 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, logger: logger, + analytics: FakeAnalytics(), platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, @@ -317,6 +328,7 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, logger: logger, + analytics: FakeAnalytics(), platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, @@ -342,6 +354,7 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, logger: logger, + analytics: FakeAnalytics(), platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, @@ -363,6 +376,7 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, logger: logger, + analytics: FakeAnalytics(), platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, @@ -387,6 +401,7 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, logger: logger, + analytics: FakeAnalytics(), platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, @@ -412,6 +427,7 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, logger: logger, + analytics: FakeAnalytics(), platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, @@ -444,6 +460,7 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, logger: logger, + analytics: FakeAnalytics(), platform: platform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, @@ -533,6 +550,7 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, logger: logger, + analytics: FakeAnalytics(), platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, @@ -609,6 +627,7 @@ void main() { cpuArchitecture: DarwinArch.arm64, iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), iosDeploy: iosDeploy, + analytics: FakeAnalytics(), iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, coreDeviceLauncher: coreDeviceLauncher, @@ -630,6 +649,7 @@ void main() { cpuArchitecture: DarwinArch.arm64, iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), iosDeploy: iosDeploy, + analytics: FakeAnalytics(), iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, coreDeviceLauncher: coreDeviceLauncher, @@ -939,6 +959,7 @@ void main() { cpuArchitecture: DarwinArch.arm64, iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), iosDeploy: iosDeploy, + analytics: FakeAnalytics(), iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, coreDeviceLauncher: coreDeviceLauncher, @@ -1108,3 +1129,5 @@ class FakeXcodeDebug extends Fake implements XcodeDebug { class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl {} class FakeIOSCoreDeviceLauncher extends Fake implements IOSCoreDeviceLauncher {} + +class FakeAnalytics extends Fake implements Analytics {} diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_install_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_install_test.dart index 539c14f009773..9d62ad5307eff 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_install_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_install_test.dart @@ -19,6 +19,7 @@ import 'package:flutter_tools/src/ios/iproxy.dart'; import 'package:flutter_tools/src/ios/mac.dart'; import 'package:flutter_tools/src/ios/xcode_debug.dart'; import 'package:test/fake.dart'; +import 'package:unified_analytics/unified_analytics.dart'; import '../../src/common.dart'; import '../../src/fake_process_manager.dart'; @@ -380,6 +381,7 @@ IOSDevice setUpIOSDevice({ artifacts: artifacts, cache: cache, ), + analytics: FakeAnalytics(), coreDeviceControl: FakeIOSCoreDeviceControl(), coreDeviceLauncher: FakeIOSCoreDeviceLauncher(), xcodeDebug: FakeXcodeDebug(), @@ -412,3 +414,5 @@ class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl { } class FakeIOSCoreDeviceLauncher extends Fake implements IOSCoreDeviceLauncher {} + +class FakeAnalytics extends Fake implements Analytics {} diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_project_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_project_test.dart index 5fcb5a71fdde4..d42e12c1e1b80 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_project_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_project_test.dart @@ -18,6 +18,7 @@ import 'package:flutter_tools/src/ios/mac.dart'; import 'package:flutter_tools/src/ios/xcode_debug.dart'; import 'package:flutter_tools/src/project.dart'; import 'package:test/fake.dart'; +import 'package:unified_analytics/unified_analytics.dart'; import '../../src/common.dart'; import '../../src/context.dart'; @@ -107,6 +108,7 @@ IOSDevice setUpIOSDevice(FileSystem fileSystem) { artifacts: Artifacts.test(), cache: Cache.test(processManager: processManager), ), + analytics: FakeAnalytics(), iMobileDevice: IMobileDevice.test(processManager: processManager), coreDeviceControl: FakeIOSCoreDeviceControl(), coreDeviceLauncher: FakeIOSCoreDeviceLauncher(), @@ -129,3 +131,5 @@ class FakeXcodeDebug extends Fake implements XcodeDebug {} class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl {} class FakeIOSCoreDeviceLauncher extends Fake implements IOSCoreDeviceLauncher {} + +class FakeAnalytics extends Fake implements Analytics {} diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart index a681ad09225b2..9e7a498510c1f 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart @@ -219,11 +219,13 @@ void main() { testUsingContext( 'with buildable app', () async { + final fakeExactAnalytics = FakeExactAnalytics(); final IOSDevice iosDevice = setUpIOSDevice( fileSystem: fileSystem, processManager: processManager, logger: logger, artifacts: artifacts, + analytics: fakeExactAnalytics, ); setUpIOSProject(fileSystem); final FlutterProject flutterProject = FlutterProject.fromDirectory( @@ -279,6 +281,13 @@ void main() { expect(fileSystem.directory('build/ios/iphoneos'), exists); expect(launchResult.started, true); expect(processManager, hasNoRemainingExpectations); + expect(fakeExactAnalytics.sentEvents, [ + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: IOSDeploymentMethod.iosDeployLaunch.name, + result: 'release success', + ), + ]); }, overrides: { ProcessManager: () => processManager, @@ -296,11 +305,13 @@ void main() { 'ONLY_ACTIVE_ARCH is NO if different host and target architectures', () async { // Host architecture is x64, target architecture is arm64. + final fakeExactAnalytics = FakeExactAnalytics(); final IOSDevice iosDevice = setUpIOSDevice( fileSystem: fileSystem, processManager: processManager, logger: logger, artifacts: artifacts, + analytics: fakeExactAnalytics, ); setUpIOSProject(fileSystem); final FlutterProject flutterProject = FlutterProject.fromDirectory( @@ -385,6 +396,13 @@ void main() { expect(fileSystem.directory('build/ios/iphoneos'), exists); expect(launchResult.started, true); expect(processManager, hasNoRemainingExpectations); + expect(fakeExactAnalytics.sentEvents, [ + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: IOSDeploymentMethod.iosDeployLaunch.name, + result: 'release success', + ), + ]); }, overrides: { ProcessManager: () => processManager, @@ -401,11 +419,13 @@ void main() { testUsingContext( 'with concurrent build failures', () async { + final fakeExactAnalytics = FakeExactAnalytics(); final IOSDevice iosDevice = setUpIOSDevice( fileSystem: fileSystem, processManager: processManager, logger: logger, artifacts: artifacts, + analytics: fakeExactAnalytics, ); setUpIOSProject(fileSystem); final FlutterProject flutterProject = FlutterProject.fromDirectory( @@ -464,6 +484,13 @@ void main() { ); expect(launchResult.started, true); expect(processManager, hasNoRemainingExpectations); + expect(fakeExactAnalytics.sentEvents, [ + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: IOSDeploymentMethod.iosDeployLaunch.name, + result: 'release success', + ), + ]); }), ), ); @@ -502,7 +529,10 @@ void main() { projectInfo = XcodeProjectInfo(['Runner'], ['Debug', 'Release'], [ 'Runner', ], logger); - fakeXcodeProjectInterpreter = FakeXcodeProjectInterpreter(projectInfo: projectInfo); + fakeXcodeProjectInterpreter = FakeXcodeProjectInterpreter( + projectInfo: projectInfo, + xcodeVersion: Version(15, 0, 0), + ); xcode = Xcode.test( processManager: FakeProcessManager.any(), xcodeProjectInterpreter: fakeXcodeProjectInterpreter, @@ -513,6 +543,7 @@ void main() { testUsingContext( 'succeeds when install and launch succeed', () async { + final fakeExactAnalytics = FakeExactAnalytics(); final IOSDevice iosDevice = setUpIOSDevice( fileSystem: fileSystem, processManager: FakeProcessManager.any(), @@ -520,6 +551,7 @@ void main() { artifacts: artifacts, isCoreDevice: true, coreDeviceControl: FakeIOSCoreDeviceControl(), + analytics: fakeExactAnalytics, ); setUpIOSProject(fileSystem); final FlutterProject flutterProject = FlutterProject.fromDirectory( @@ -543,6 +575,13 @@ void main() { expect(fileSystem.directory('build/ios/iphoneos'), exists); expect(launchResult.started, true); expect(processManager, hasNoRemainingExpectations); + expect(fakeExactAnalytics.sentEvents, [ + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: IOSDeploymentMethod.coreDeviceWithoutDebugger.name, + result: 'release success', + ), + ]); }, overrides: { ProcessManager: () => FakeProcessManager.any(), @@ -559,6 +598,7 @@ void main() { testUsingContext( 'fails when install fails', () async { + final fakeExactAnalytics = FakeExactAnalytics(); final IOSDevice iosDevice = setUpIOSDevice( fileSystem: fileSystem, processManager: FakeProcessManager.any(), @@ -566,6 +606,8 @@ void main() { artifacts: artifacts, isCoreDevice: true, coreDeviceControl: FakeIOSCoreDeviceControl(installSuccess: false), + coreDeviceLauncher: FakeIOSCoreDeviceLauncher(launchResult: false), + analytics: fakeExactAnalytics, ); setUpIOSProject(fileSystem); final FlutterProject flutterProject = FlutterProject.fromDirectory( @@ -589,6 +631,13 @@ void main() { expect(fileSystem.directory('build/ios/iphoneos'), exists); expect(launchResult.started, false); expect(processManager, hasNoRemainingExpectations); + expect(fakeExactAnalytics.sentEvents, [ + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: IOSDeploymentMethod.coreDeviceWithoutDebugger.name, + result: 'launch failed', + ), + ]); }, overrides: { ProcessManager: () => FakeProcessManager.any(), @@ -652,6 +701,7 @@ void main() { 'ensure arguments passed to launch', () async { final coreDeviceControl = FakeIOSCoreDeviceControl(); + final fakeExactAnalytics = FakeExactAnalytics(); final IOSDevice iosDevice = setUpIOSDevice( fileSystem: fileSystem, processManager: FakeProcessManager.any(), @@ -659,6 +709,7 @@ void main() { artifacts: artifacts, isCoreDevice: true, coreDeviceControl: coreDeviceControl, + analytics: fakeExactAnalytics, ); setUpIOSProject(fileSystem); final FlutterProject flutterProject = FlutterProject.fromDirectory( @@ -684,6 +735,13 @@ void main() { expect(processManager, hasNoRemainingExpectations); expect(coreDeviceControl.argumentsUsedForLaunch, isNotNull); expect(coreDeviceControl.argumentsUsedForLaunch, contains('--enable-dart-profiling')); + expect(fakeExactAnalytics.sentEvents, [ + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: IOSDeploymentMethod.coreDeviceWithoutDebugger.name, + result: 'release success', + ), + ]); }, overrides: { ProcessManager: () => FakeProcessManager.any(), @@ -1177,6 +1235,7 @@ IOSDevice setUpIOSDevice({ IOSCoreDeviceLauncher? coreDeviceLauncher, FakeXcodeDebug? xcodeDebug, DarwinArch cpuArchitecture = DarwinArch.arm64, + FakeExactAnalytics? analytics, }) { artifacts ??= Artifacts.test(); final cache = Cache.test( @@ -1200,6 +1259,7 @@ IOSDevice setUpIOSDevice({ artifacts: artifacts, cache: cache, ), + analytics: analytics ?? FakeExactAnalytics(), iMobileDevice: IMobileDevice( logger: logger, processManager: processManager ?? FakeProcessManager.any(), @@ -1226,7 +1286,8 @@ class FakeXcodeProjectInterpreter extends Fake implements XcodeProjectInterprete 'WRAPPER_NAME': 'My Super Awesome App.app', 'DEVELOPMENT_TEAM': '3333CCCC33', }, - }); + Version? xcodeVersion, + }) : version = xcodeVersion ?? Version(1000, 0, 0); final Map buildSettings; final XcodeProjectInfo? projectInfo; @@ -1235,7 +1296,7 @@ class FakeXcodeProjectInterpreter extends Fake implements XcodeProjectInterprete final isInstalled = true; @override - final version = Version(1000, 0, 0); + Version? version; @override String get versionText => version.toString(); @@ -1319,13 +1380,17 @@ class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl { } @override - Future launchApp({ + Future launchApp({ required String deviceId, required String bundleId, List launchArguments = const [], + bool startStopped = false, }) async { _launchArguments = launchArguments; - return launchSuccess; + final outcome = launchSuccess ? 'success' : 'failed'; + return IOSCoreDeviceLaunchResult.fromJson({ + 'info': {'outcome': outcome}, + }); } } @@ -1395,4 +1460,36 @@ const _validScheme = ''' '''; -class FakeIOSCoreDeviceLauncher extends Fake implements IOSCoreDeviceLauncher {} +class FakeIOSCoreDeviceLauncher extends Fake implements IOSCoreDeviceLauncher { + FakeIOSCoreDeviceLauncher({this.launchResult = true}); + bool launchResult; + + @override + Future launchAppWithoutDebugger({ + required String deviceId, + required String bundlePath, + required String bundleId, + required List launchArguments, + }) async { + return launchResult; + } + + @override + Future launchAppWithLLDBDebugger({ + required String deviceId, + required String bundlePath, + required String bundleId, + required List launchArguments, + }) async { + return true; + } +} + +class FakeExactAnalytics extends Fake implements Analytics { + final sentEvents = []; + + @override + void send(Event event) { + sentEvents.add(event); + } +} diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart index b9650b07ce0c1..76edb17ff86ee 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_start_prebuilt_test.dart @@ -13,6 +13,7 @@ import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/base/platform.dart'; import 'package:flutter_tools/src/base/process.dart'; import 'package:flutter_tools/src/base/template.dart'; +import 'package:flutter_tools/src/base/version.dart'; import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/cache.dart'; import 'package:flutter_tools/src/device.dart'; @@ -24,8 +25,10 @@ import 'package:flutter_tools/src/ios/ios_deploy.dart'; import 'package:flutter_tools/src/ios/iproxy.dart'; import 'package:flutter_tools/src/ios/mac.dart'; import 'package:flutter_tools/src/ios/xcode_debug.dart'; +import 'package:flutter_tools/src/macos/xcode.dart'; import 'package:flutter_tools/src/mdns_discovery.dart'; import 'package:test/fake.dart'; +import 'package:unified_analytics/unified_analytics.dart'; import '../../src/common.dart'; import '../../src/context.dart'; @@ -131,9 +134,11 @@ void main() { () async { final FileSystem fileSystem = MemoryFileSystem.test(); final processManager = FakeProcessManager.list([attachDebuggerCommand()]); + final fakeAnalytics = FakeAnalytics(); final IOSDevice device = setUpIOSDevice( processManager: processManager, fileSystem: fileSystem, + analytics: fakeAnalytics, ); final IOSApp iosApp = PrebuiltIOSApp( projectBundleId: 'app', @@ -162,6 +167,13 @@ void main() { expect(launchResult.started, true); expect(launchResult.hasVmService, true); expect(await device.stopApp(iosApp), false); + expect(fakeAnalytics.sentEvents, [ + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: IOSDeploymentMethod.iosDeployLaunchAndAttach.name, + result: 'debugging success', + ), + ]); }, ); @@ -171,6 +183,7 @@ void main() { final logger = BufferLogger.test(); final FileSystem fileSystem = MemoryFileSystem.test(); final completer = Completer(); + final fakeAnalytics = FakeAnalytics(); final processManager = FakeProcessManager.list([ attachDebuggerCommand(stdout: 'PROCESS_EXITED'), attachDebuggerCommand( @@ -183,6 +196,7 @@ void main() { processManager: processManager, fileSystem: fileSystem, logger: logger, + analytics: fakeAnalytics, ); final IOSApp iosApp = PrebuiltIOSApp( projectBundleId: 'app', @@ -214,6 +228,18 @@ void main() { expect(secondLaunchResult.started, true); expect(secondLaunchResult.hasVmService, true); expect(await device.stopApp(iosApp), true); + expect(fakeAnalytics.sentEvents, [ + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: IOSDeploymentMethod.iosDeployLaunchAndAttach.name, + result: 'launch failed', + ), + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: IOSDeploymentMethod.iosDeployLaunchAndAttach.name, + result: 'debugging success', + ), + ]); }, ); @@ -222,10 +248,12 @@ void main() { () async { final FileSystem fileSystem = MemoryFileSystem.test(); final processManager = FakeProcessManager.list([kLaunchDebugCommand]); + final fakeAnalytics = FakeAnalytics(); final IOSDevice device = setUpIOSDevice( sdkVersion: '12.4.4', processManager: processManager, fileSystem: fileSystem, + analytics: fakeAnalytics, ); final IOSApp iosApp = PrebuiltIOSApp( projectBundleId: 'app', @@ -254,6 +282,13 @@ void main() { expect(launchResult.started, true); expect(launchResult.hasVmService, true); expect(await device.stopApp(iosApp), false); + expect(fakeAnalytics.sentEvents, [ + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: IOSDeploymentMethod.iosDeployLaunch.name, + result: 'debugging success', + ), + ]); }, ); @@ -264,12 +299,14 @@ void main() { final logger = BufferLogger.test(); final stdin = CompleterIOSink(); final completer = Completer(); + final fakeAnalytics = FakeAnalytics(); final processManager = FakeProcessManager.list([ attachDebuggerCommand(stdin: stdin, completer: completer), ]); final IOSDevice device = setUpIOSDevice( processManager: processManager, fileSystem: fileSystem, + analytics: fakeAnalytics, logger: logger, ); final IOSApp iosApp = PrebuiltIOSApp( @@ -307,6 +344,13 @@ void main() { expect(utf8.decoder.convert(stdin.writes.first), contains('process interrupt')); completer.complete(); expect(processManager, hasNoRemainingExpectations); + expect(fakeAnalytics.sentEvents, [ + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: IOSDeploymentMethod.iosDeployLaunchAndAttach.name, + result: 'debugging success', + ), + ]); }, ); @@ -317,12 +361,14 @@ void main() { final logger = BufferLogger.test(); final stdin = CompleterIOSink(); final completer = Completer(); + final fakeAnalytics = FakeAnalytics(); final processManager = FakeProcessManager.list([ attachDebuggerCommand(stdin: stdin, completer: completer, isWirelessDevice: true), ]); final IOSDevice device = setUpIOSDevice( processManager: processManager, fileSystem: fileSystem, + analytics: fakeAnalytics, logger: logger, interfaceType: DeviceConnectionInterface.wireless, ); @@ -371,6 +417,13 @@ void main() { ), ); completer.complete(); + expect(fakeAnalytics.sentEvents, [ + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: IOSDeploymentMethod.iosDeployLaunchAndAttach.name, + result: 'debugging success', + ), + ]); expect(processManager, hasNoRemainingExpectations); }, overrides: {MDnsVmServiceDiscovery: () => FakeMDnsVmServiceDiscovery()}, @@ -382,6 +435,7 @@ void main() { final logger = BufferLogger.test(); final FileSystem fileSystem = MemoryFileSystem.test(); final completer = Completer(); + final fakeAnalytics = FakeAnalytics(); final processManager = FakeProcessManager.list([ attachDebuggerCommand( stdout: @@ -399,6 +453,7 @@ void main() { processManager: processManager, fileSystem: fileSystem, logger: logger, + analytics: fakeAnalytics, ); final IOSApp iosApp = PrebuiltIOSApp( projectBundleId: 'app', @@ -425,6 +480,13 @@ void main() { expect(launchResult.started, true); expect(launchResult.hasVmService, true); expect(await device.stopApp(iosApp), true); + expect(fakeAnalytics.sentEvents, [ + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: IOSDeploymentMethod.iosDeployLaunchAndAttach.name, + result: 'debugging success', + ), + ]); }, ); @@ -433,6 +495,7 @@ void main() { () async { final logger = BufferLogger.test(); final FileSystem fileSystem = MemoryFileSystem.test(); + final fakeAnalytics = FakeAnalytics(); final processManager = FakeProcessManager.list([ attachDebuggerCommand( stdout: @@ -442,6 +505,7 @@ void main() { final IOSDevice device = setUpIOSDevice( processManager: processManager, fileSystem: fileSystem, + analytics: fakeAnalytics, logger: logger, ); final IOSApp iosApp = PrebuiltIOSApp( @@ -464,13 +528,25 @@ void main() { expect(launchResult.started, false); expect(launchResult.hasVmService, false); expect(await device.stopApp(iosApp), false); + expect(fakeAnalytics.sentEvents, [ + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: IOSDeploymentMethod.iosDeployLaunchAndAttach.name, + result: 'debugging failed', + ), + ]); }, ); testWithoutContext('IOSDevice.startApp succeeds in release mode', () async { final FileSystem fileSystem = MemoryFileSystem.test(); final processManager = FakeProcessManager.list([kLaunchReleaseCommand]); - final IOSDevice device = setUpIOSDevice(processManager: processManager, fileSystem: fileSystem); + final fakeAnalytics = FakeAnalytics(); + final IOSDevice device = setUpIOSDevice( + processManager: processManager, + fileSystem: fileSystem, + analytics: fakeAnalytics, + ); final IOSApp iosApp = PrebuiltIOSApp( projectBundleId: 'app', bundleName: 'Runner', @@ -489,10 +565,18 @@ void main() { expect(launchResult.hasVmService, false); expect(await device.stopApp(iosApp), false); expect(processManager, hasNoRemainingExpectations); + expect(fakeAnalytics.sentEvents, [ + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: IOSDeploymentMethod.iosDeployLaunch.name, + result: 'release success', + ), + ]); }); testWithoutContext('IOSDevice.startApp forwards all supported debugging options', () async { final FileSystem fileSystem = MemoryFileSystem.test(); + final fakeAnalytics = FakeAnalytics(); final processManager = FakeProcessManager.list([ FakeCommand( command: [ @@ -545,6 +629,7 @@ void main() { sdkVersion: '13.3', processManager: processManager, fileSystem: fileSystem, + analytics: fakeAnalytics, ); final IOSApp iosApp = PrebuiltIOSApp( projectBundleId: 'app', @@ -592,10 +677,18 @@ void main() { expect(launchResult.started, true); expect(await device.stopApp(iosApp), false); expect(processManager, hasNoRemainingExpectations); + expect(fakeAnalytics.sentEvents, [ + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: IOSDeploymentMethod.iosDeployLaunchAndAttach.name, + result: 'debugging success', + ), + ]); }); testWithoutContext('startApp using route', () async { final FileSystem fileSystem = MemoryFileSystem.test(); + final fakeAnalytics = FakeAnalytics(); final processManager = FakeProcessManager.list([ FakeCommand( command: [ @@ -631,6 +724,7 @@ void main() { sdkVersion: '13.3', processManager: processManager, fileSystem: fileSystem, + analytics: fakeAnalytics, ); final IOSApp iosApp = PrebuiltIOSApp( projectBundleId: 'app', @@ -659,10 +753,18 @@ void main() { expect(launchResult.started, true); expect(await device.stopApp(iosApp), false); expect(processManager, hasNoRemainingExpectations); + expect(fakeAnalytics.sentEvents, [ + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: IOSDeploymentMethod.iosDeployLaunchAndAttach.name, + result: 'debugging success', + ), + ]); }); testWithoutContext('startApp using trace-startup', () async { final FileSystem fileSystem = MemoryFileSystem.test(); + final fakeAnalytics = FakeAnalytics(); final processManager = FakeProcessManager.list([ FakeCommand( command: [ @@ -698,6 +800,7 @@ void main() { sdkVersion: '13.3', processManager: processManager, fileSystem: fileSystem, + analytics: fakeAnalytics, ); final IOSApp iosApp = PrebuiltIOSApp( projectBundleId: 'app', @@ -725,14 +828,210 @@ void main() { expect(launchResult.started, true); expect(await device.stopApp(iosApp), false); expect(processManager, hasNoRemainingExpectations); + expect(fakeAnalytics.sentEvents, [ + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: IOSDeploymentMethod.iosDeployLaunchAndAttach.name, + result: 'debugging success', + ), + ]); }); group('IOSDevice.startApp for CoreDevice', () { group('in debug mode', () { - testUsingContext('succeeds', () async { + testUsingContext( + 'uses LLDB with Xcode 26+', + () async { + final FileSystem fileSystem = MemoryFileSystem.test(); + final processManager = FakeProcessManager.empty(); + final Directory bundleLocation = fileSystem.currentDirectory; + final fakeAnalytics = FakeAnalytics(); + final fakeLauncher = FakeIOSCoreDeviceLauncher(); + final IOSDevice device = setUpIOSDevice( + processManager: processManager, + fileSystem: fileSystem, + isCoreDevice: true, + coreDeviceLauncher: fakeLauncher, + analytics: fakeAnalytics, + ); + final IOSApp iosApp = PrebuiltIOSApp( + projectBundleId: 'app', + bundleName: 'Runner', + uncompressedBundle: bundleLocation, + applicationPackage: bundleLocation, + ); + final deviceLogReader = FakeDeviceLogReader(); + + device.portForwarder = const NoOpDevicePortForwarder(); + device.setLogReader(iosApp, deviceLogReader); + + // Start writing messages to the log reader. + Timer.run(() { + deviceLogReader.addLine('Foo'); + deviceLogReader.addLine('The Dart VM service is listening on http://127.0.0.1:456'); + }); + + final LaunchResult launchResult = await device.startApp( + iosApp, + prebuiltApplication: true, + debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug), + platformArgs: {}, + ); + + expect(launchResult.started, true); + expect(fakeLauncher.launchedWithLLDB, true); + expect(fakeLauncher.launchedWithXcode, false); + expect(fakeAnalytics.sentEvents, [ + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: IOSDeploymentMethod.coreDeviceWithLLDB.name, + result: 'debugging success', + ), + ]); + }, + overrides: { + Xcode: () => FakeXcode(currentVersion: Version(26, 0, 0)), + Analytics: () => FakeAnalytics(), + }, + ); + + testUsingContext('uses Xcode if LLDB fails', () async { final FileSystem fileSystem = MemoryFileSystem.test(); final processManager = FakeProcessManager.empty(); + final Directory temporaryXcodeProjectDirectory = fileSystem.systemTempDirectory + .childDirectory('flutter_empty_xcode.rand0'); + final Directory bundleLocation = fileSystem.currentDirectory; + final fakeAnalytics = FakeAnalytics(); + final fakeLauncher = FakeIOSCoreDeviceLauncher(lldbLaunchResult: false); + final IOSDevice device = setUpIOSDevice( + processManager: processManager, + fileSystem: fileSystem, + isCoreDevice: true, + coreDeviceControl: FakeIOSCoreDeviceControl(), + xcodeDebug: FakeXcodeDebug( + expectedProject: XcodeDebugProject( + scheme: 'Runner', + xcodeWorkspace: temporaryXcodeProjectDirectory.childDirectory('Runner.xcworkspace'), + xcodeProject: temporaryXcodeProjectDirectory.childDirectory('Runner.xcodeproj'), + hostAppProjectName: 'Runner', + ), + expectedDeviceId: '123', + expectedLaunchArguments: ['--enable-dart-profiling'], + expectedBundlePath: bundleLocation.path, + ), + coreDeviceLauncher: fakeLauncher, + analytics: fakeAnalytics, + ); + final IOSApp iosApp = PrebuiltIOSApp( + projectBundleId: 'app', + bundleName: 'Runner', + uncompressedBundle: bundleLocation, + applicationPackage: bundleLocation, + ); + final deviceLogReader = FakeDeviceLogReader(); + + device.portForwarder = const NoOpDevicePortForwarder(); + device.setLogReader(iosApp, deviceLogReader); + + // Start writing messages to the log reader. + Timer.run(() { + deviceLogReader.addLine('Foo'); + deviceLogReader.addLine('The Dart VM service is listening on http://127.0.0.1:456'); + }); + + final LaunchResult launchResult = await device.startApp( + iosApp, + prebuiltApplication: true, + debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug), + platformArgs: {}, + ); + + expect(launchResult.started, true); + expect(fakeLauncher.launchedWithLLDB, true); + expect(fakeAnalytics.sentEvents, [ + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: IOSDeploymentMethod.coreDeviceWithLLDB.name, + result: 'launch failed', + ), + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: IOSDeploymentMethod.coreDeviceWithXcodeFallback.name, + result: 'debugging success', + ), + ]); + }, overrides: {Xcode: () => FakeXcode(currentVersion: Version(26, 0, 0))}); + + testUsingContext( + 'uses Xcode if less than Xcode 26', + () async { + final FileSystem fileSystem = MemoryFileSystem.test(); + final processManager = FakeProcessManager.empty(); + final Directory temporaryXcodeProjectDirectory = fileSystem.systemTempDirectory + .childDirectory('flutter_empty_xcode.rand0'); + final Directory bundleLocation = fileSystem.currentDirectory; + final fakeAnalytics = FakeAnalytics(); + final fakeLauncher = FakeIOSCoreDeviceLauncher(); + final IOSDevice device = setUpIOSDevice( + processManager: processManager, + fileSystem: fileSystem, + isCoreDevice: true, + coreDeviceControl: FakeIOSCoreDeviceControl(), + xcodeDebug: FakeXcodeDebug( + expectedProject: XcodeDebugProject( + scheme: 'Runner', + xcodeWorkspace: temporaryXcodeProjectDirectory.childDirectory('Runner.xcworkspace'), + xcodeProject: temporaryXcodeProjectDirectory.childDirectory('Runner.xcodeproj'), + hostAppProjectName: 'Runner', + ), + expectedDeviceId: '123', + expectedLaunchArguments: ['--enable-dart-profiling'], + expectedBundlePath: bundleLocation.path, + ), + coreDeviceLauncher: fakeLauncher, + analytics: fakeAnalytics, + ); + final IOSApp iosApp = PrebuiltIOSApp( + projectBundleId: 'app', + bundleName: 'Runner', + uncompressedBundle: bundleLocation, + applicationPackage: bundleLocation, + ); + final deviceLogReader = FakeDeviceLogReader(); + + device.portForwarder = const NoOpDevicePortForwarder(); + device.setLogReader(iosApp, deviceLogReader); + + // Start writing messages to the log reader. + Timer.run(() { + deviceLogReader.addLine('Foo'); + deviceLogReader.addLine('The Dart VM service is listening on http://127.0.0.1:456'); + }); + final LaunchResult launchResult = await device.startApp( + iosApp, + prebuiltApplication: true, + debuggingOptions: DebuggingOptions.enabled(BuildInfo.debug), + platformArgs: {}, + ); + + expect(launchResult.started, true); + expect(fakeLauncher.launchedWithLLDB, false); + expect(fakeAnalytics.sentEvents, [ + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: IOSDeploymentMethod.coreDeviceWithXcode.name, + result: 'debugging success', + ), + ]); + }, + overrides: {Xcode: () => FakeXcode(currentVersion: Version(16, 0, 0))}, + ); + + testUsingContext('succeeds', () async { + final FileSystem fileSystem = MemoryFileSystem.test(); + final processManager = FakeProcessManager.empty(); + final fakeAnalytics = FakeAnalytics(); final Directory temporaryXcodeProjectDirectory = fileSystem.systemTempDirectory .childDirectory('flutter_empty_xcode.rand0'); final Directory bundleLocation = fileSystem.currentDirectory; @@ -752,6 +1051,7 @@ void main() { expectedLaunchArguments: ['--enable-dart-profiling'], expectedBundlePath: bundleLocation.path, ), + analytics: fakeAnalytics, ); final IOSApp iosApp = PrebuiltIOSApp( projectBundleId: 'app', @@ -778,6 +1078,13 @@ void main() { ); expect(launchResult.started, true); + expect(fakeAnalytics.sentEvents, [ + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: IOSDeploymentMethod.coreDeviceWithXcode.name, + result: 'debugging success', + ), + ]); }); testUsingContext('prints warning message if it takes too long to start debugging', () async { @@ -837,7 +1144,8 @@ void main() { expect( logger.errorText, contains( - 'Xcode is taking longer than expected to start debugging the app. Ensure the project is opened in Xcode.', + 'Xcode is taking longer than expected to start debugging the app. ' + 'If the issue persists, try closing Xcode and re-running your Flutter command.', ), ); completer.complete(); @@ -851,6 +1159,7 @@ void main() { final Directory temporaryXcodeProjectDirectory = fileSystem.systemTempDirectory .childDirectory('flutter_empty_xcode.rand0'); final Directory bundleLocation = fileSystem.currentDirectory; + final fakeAnalytics = FakeAnalytics(); final IOSDevice device = setUpIOSDevice( processManager: processManager, fileSystem: fileSystem, @@ -867,6 +1176,7 @@ void main() { expectedLaunchArguments: ['--enable-dart-profiling'], expectedBundlePath: bundleLocation.path, ), + analytics: fakeAnalytics, ); final IOSApp iosApp = PrebuiltIOSApp( projectBundleId: 'app', @@ -897,6 +1207,13 @@ void main() { expect(launchResult.started, true); expect(shutDownHooks.hooks.length, 1); + expect(fakeAnalytics.sentEvents, [ + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: IOSDeploymentMethod.coreDeviceWithXcode.name, + result: 'debugging success', + ), + ]); }); testUsingContext( @@ -908,10 +1225,12 @@ void main() { final Directory temporaryXcodeProjectDirectory = fileSystem.systemTempDirectory .childDirectory('flutter_empty_xcode.rand0'); final Directory bundleLocation = fileSystem.currentDirectory; + final fakeAnalytics = FakeAnalytics(); final IOSDevice device = setUpIOSDevice( processManager: processManager, fileSystem: fileSystem, isCoreDevice: true, + analytics: fakeAnalytics, coreDeviceControl: FakeIOSCoreDeviceControl(), xcodeDebug: FakeXcodeDebug( expectedProject: XcodeDebugProject( @@ -946,6 +1265,13 @@ void main() { expect(launchResult.started, true); expect(launchResult.hasVmService, true); expect(await device.stopApp(iosApp), true); + expect(fakeAnalytics.sentEvents, [ + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: IOSDeploymentMethod.coreDeviceWithXcode.name, + result: 'debugging success', + ), + ]); }, // If mDNS is not the only method of discovery, it shouldn't throw on error. overrides: { @@ -963,7 +1289,7 @@ void main() { testUsingContext('when mDNS fails', () async { final FileSystem fileSystem = MemoryFileSystem.test(); final processManager = FakeProcessManager.empty(); - + final fakeAnalytics = FakeAnalytics(); final Directory temporaryXcodeProjectDirectory = fileSystem.systemTempDirectory .childDirectory('flutter_empty_xcode.rand0'); final Directory bundleLocation = fileSystem.currentDirectory; @@ -983,6 +1309,7 @@ void main() { expectedLaunchArguments: ['--enable-dart-profiling'], expectedBundlePath: bundleLocation.path, ), + analytics: fakeAnalytics, ); final IOSApp iosApp = PrebuiltIOSApp( projectBundleId: 'app', @@ -1015,6 +1342,13 @@ void main() { expect(launchResult.started, true); expect(launchResult.hasVmService, true); expect(await device.stopApp(iosApp), true); + expect(fakeAnalytics.sentEvents, [ + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: IOSDeploymentMethod.coreDeviceWithXcode.name, + result: 'debugging success', + ), + ]); }, overrides: {MDnsVmServiceDiscovery: () => mdnsDiscovery}); }); @@ -1030,6 +1364,7 @@ void main() { final Directory temporaryXcodeProjectDirectory = fileSystem.systemTempDirectory .childDirectory('flutter_empty_xcode.rand0'); final Directory bundleLocation = fileSystem.currentDirectory; + final fakeAnalytics = FakeAnalytics(); final IOSDevice device = setUpIOSDevice( processManager: processManager, fileSystem: fileSystem, @@ -1050,6 +1385,7 @@ void main() { operatingSystem: 'macos', environment: {'HOME': pathToHome}, ), + analytics: fakeAnalytics, ); final IOSApp iosApp = PrebuiltIOSApp( @@ -1089,6 +1425,13 @@ void main() { .existsSync(), true, ); + expect(fakeAnalytics.sentEvents, [ + Event.appleUsageEvent( + workflow: 'ios-physical-deployment', + parameter: IOSDeploymentMethod.coreDeviceWithXcode.name, + result: 'debugging failed', + ), + ]); completer.complete(); }); time.elapse(const Duration(minutes: 15)); @@ -1182,6 +1525,8 @@ IOSDevice setUpIOSDevice({ DeviceConnectionInterface interfaceType = DeviceConnectionInterface.attached, bool isCoreDevice = false, IOSCoreDeviceControl? coreDeviceControl, + IOSCoreDeviceLauncher? coreDeviceLauncher, + Analytics? analytics, FakeXcodeDebug? xcodeDebug, FakePlatform? platform, }) { @@ -1212,6 +1557,7 @@ IOSDevice setUpIOSDevice({ artifacts: artifacts, cache: cache, ), + analytics: analytics ?? FakeAnalytics(), iMobileDevice: IMobileDevice( logger: logger, processManager: processManager ?? FakeProcessManager.any(), @@ -1219,7 +1565,7 @@ IOSDevice setUpIOSDevice({ cache: cache, ), coreDeviceControl: coreDeviceControl ?? FakeIOSCoreDeviceControl(), - coreDeviceLauncher: FakeIOSCoreDeviceLauncher(), + coreDeviceLauncher: coreDeviceLauncher ?? FakeIOSCoreDeviceLauncher(), xcodeDebug: xcodeDebug ?? FakeXcodeDebug(), cpuArchitecture: DarwinArch.arm64, connectionInterface: interfaceType, @@ -1347,4 +1693,61 @@ class FakeShutDownHooks extends Fake implements ShutdownHooks { } } -class FakeIOSCoreDeviceLauncher extends Fake implements IOSCoreDeviceLauncher {} +class FakeXcode extends Fake implements Xcode { + FakeXcode({this.currentVersion}); + + @override + Version? currentVersion; +} + +class FakeIOSCoreDeviceLauncher extends Fake implements IOSCoreDeviceLauncher { + FakeIOSCoreDeviceLauncher({this.lldbLaunchResult = true, this.xcodeLaunchResult = true}); + bool lldbLaunchResult; + bool xcodeLaunchResult; + var launchedWithLLDB = false; + var launchedWithXcode = false; + + Completer? xcodeCompleter; + + @override + Future launchAppWithLLDBDebugger({ + required String deviceId, + required String bundlePath, + required String bundleId, + required List launchArguments, + }) async { + launchedWithLLDB = true; + return lldbLaunchResult; + } + + @override + Future launchAppWithXcodeDebugger({ + required String deviceId, + required DebuggingOptions debuggingOptions, + required IOSApp package, + required List launchArguments, + required TemplateRenderer templateRenderer, + String? mainPath, + Duration? discoveryTimeout, + }) async { + if (xcodeCompleter != null) { + await xcodeCompleter!.future; + } + launchedWithXcode = true; + return xcodeLaunchResult; + } + + @override + Future stopApp({required String deviceId, int? processId}) async { + return false; + } +} + +class FakeAnalytics extends Fake implements Analytics { + final sentEvents = []; + + @override + void send(Event event) { + sentEvents.add(event); + } +} diff --git a/packages/flutter_tools/test/general.shard/ios/lldb_test.dart b/packages/flutter_tools/test/general.shard/ios/lldb_test.dart index e94da55b1b8f4..3756f58d6ded6 100644 --- a/packages/flutter_tools/test/general.shard/ios/lldb_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/lldb_test.dart @@ -43,7 +43,7 @@ void main() { expect(lldb.isRunning, isFalse); expect(lldb.appProcessId, isNull); expect(processManager.hasRemainingExpectations, isFalse); - expect(logger.errorText, contains('Process exception running lldb')); + expect(logger.traceText, contains('Process exception running lldb')); }); testWithoutContext('attachAndStart returns true on success', () async { @@ -174,7 +174,7 @@ Target 0: (Runner) stopped. expect(lldb.appProcessId, isNull); expect(expectedInputs, isEmpty); expect(processManager.hasRemainingExpectations, isFalse); - expect(logger.errorText, contains(errorText)); + expect(logger.traceText, contains(errorText)); }); testWithoutContext('attachAndStart returns false when stderr not during log waiter', () async { @@ -220,7 +220,7 @@ Target 0: (Runner) stopped. expect(lldb.appProcessId, isNull); expect(expectedInputs, isEmpty); expect(processManager.hasRemainingExpectations, isFalse); - expect(logger.errorText, contains(errorText)); + expect(logger.traceText, contains(errorText)); }); testWithoutContext('attachAndStart prints warning if takes too long', () async { diff --git a/packages/flutter_tools/test/src/fakes.dart b/packages/flutter_tools/test/src/fakes.dart index 97d587b7ff391..63c0cbccd1b11 100644 --- a/packages/flutter_tools/test/src/fakes.dart +++ b/packages/flutter_tools/test/src/fakes.dart @@ -502,6 +502,7 @@ class TestFeatureFlags implements FeatureFlags { this.isNativeAssetsEnabled = false, this.isSwiftPackageManagerEnabled = false, this.isOmitLegacyVersionFileEnabled = false, + this.isLLDBDebuggingEnabled = false, }); @override @@ -540,6 +541,9 @@ class TestFeatureFlags implements FeatureFlags { @override final bool isOmitLegacyVersionFileEnabled; + @override + final bool isLLDBDebuggingEnabled; + @override bool isEnabled(Feature feature) { return switch (feature) { @@ -553,7 +557,9 @@ class TestFeatureFlags implements FeatureFlags { flutterCustomDevicesFeature => areCustomDevicesEnabled, cliAnimation => isCliAnimationEnabled, nativeAssets => isNativeAssetsEnabled, + swiftPackageManager => isSwiftPackageManagerEnabled, omitLegacyVersionFile => isOmitLegacyVersionFileEnabled, + lldbDebugging => isLLDBDebuggingEnabled, _ => false, }; } @@ -572,6 +578,7 @@ class TestFeatureFlags implements FeatureFlags { nativeAssets, swiftPackageManager, omitLegacyVersionFile, + lldbDebugging, ]; @override From 1ddd180d85131c041c8c1af1674b04aa101de475 Mon Sep 17 00:00:00 2001 From: Camille Simon <43054281+camsim99@users.noreply.github.com> Date: Mon, 11 Aug 2025 17:45:44 -0700 Subject: [PATCH 20/23] Update Dart revision for 3.35 stable release (#173582) Updates Dart has for 3.35 stable release to Dart 3.9 hash -- https://dart.googlesource.com/sdk/+/54588cb8088890ea08fe1a31b95efe478a4609b5. Steps I took: 1. Updated hash to desired version. 2. Run `engine/src/tools/dart/create_updated_flutter_deps.py -f DEPS` to update any Dart dependencies if needed (none updated). 3. Updated `engine/src/flutter/ci/licenses_golden/licenses_dart` with diff produced in error from https://ci.chromium.org/ui/p/flutter/builders/try/Linux%20linux_license/36032/overview build failure. **Note**: The Flutter team is currently trialing the use of [Gemini Code Assist for GitHub](https://developers.google.com/gemini-code-assist/docs/review-github-code). Comments from the `gemini-code-assist` bot should not be taken as authoritative feedback from the Flutter team. If you find its comments useful you can update your code accordingly, but if you are unsure or disagree with the feedback, please feel free to wait for a Flutter team member's review for guidance on which automated comments should be addressed. --- DEPS | 2 +- engine/src/flutter/ci/licenses_golden/licenses_dart | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/DEPS b/DEPS index 6258ab195df8a..284f497a01998 100644 --- a/DEPS +++ b/DEPS @@ -56,7 +56,7 @@ vars = { # Dart is: https://github.com/dart-lang/sdk/blob/main/DEPS # You can use //tools/dart/create_updated_flutter_deps.py to produce # updated revision list of existing dependencies. - 'dart_revision': 'a4e60e5add75b3d06f380aad73ca660a719fa738', + 'dart_revision': '54588cb8088890ea08fe1a31b95efe478a4609b5', # WARNING: DO NOT EDIT MANUALLY # The lines between blank lines above and below are generated by a script. See create_updated_flutter_deps.py diff --git a/engine/src/flutter/ci/licenses_golden/licenses_dart b/engine/src/flutter/ci/licenses_golden/licenses_dart index 968247e9a0724..6fe2f61959869 100644 --- a/engine/src/flutter/ci/licenses_golden/licenses_dart +++ b/engine/src/flutter/ci/licenses_golden/licenses_dart @@ -1,4 +1,4 @@ -Signature: 9e71853011444b1c40e5780c309930f4 +Signature: 90c64afcbe53b95cb9488d51b74ef207 ==================================================================================================== LIBRARY: dart @@ -4894,7 +4894,7 @@ Exhibit B - "Incompatible With Secondary Licenses" Notice This Source Code Form is "Incompatible With Secondary Licenses", as defined by the Mozilla Public License, v. 2.0. -You may obtain a copy of this library's Source Code Form from: https://dart.googlesource.com/sdk/+/3ad640094c386c2c63be7c6a2bb3256c5ad5726c +You may obtain a copy of this library's Source Code Form from: https://dart.googlesource.com/sdk/+/54588cb8088890ea08fe1a31b95efe478a4609b5 /third_party/fallback_root_certificates/ ==================================================================================================== From afefd5da0b2d253a3f8a39fdad23ab0dbb2865d6 Mon Sep 17 00:00:00 2001 From: Robert Ancell Date: Thu, 14 Aug 2025 04:06:05 +1200 Subject: [PATCH 21/23] [beta] Cherry pick fix GTK redraw call being called from non-GTK thread (#173667) Cherry pick of https://github.com/flutter/flutter/pull/173602 Impacted users: All Linux users of Flutter Impact Description: Due to calling gtk_window_redraw on a Flutter thread a lock up may occur. The Flutter app will then become unresponsive. Workaround: No workaround Risk: Low - fix is to run the GTK call on the GTK thread which is what the correct behaviour should be. Test coverage: Rendering covered by existing tests, use of thread not explicitly tested, but https://github.com/flutter/flutter/issues/173660 opened to add this in future. Validation Steps: Run test program in https://github.com/flutter/flutter/issues/173447 which generates many frames and maximizes the chance of a lock up. --- .../flutter/shell/platform/linux/fl_view.cc | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/engine/src/flutter/shell/platform/linux/fl_view.cc b/engine/src/flutter/shell/platform/linux/fl_view.cc index bf52646909407..bbe4694174ec3 100644 --- a/engine/src/flutter/shell/platform/linux/fl_view.cc +++ b/engine/src/flutter/shell/platform/linux/fl_view.cc @@ -95,11 +95,16 @@ G_DEFINE_TYPE_WITH_CODE( G_IMPLEMENT_INTERFACE(fl_plugin_registry_get_type(), fl_view_plugin_registry_iface_init)) -// Emit the first frame signal in the main thread. -static gboolean first_frame_idle_cb(gpointer user_data) { +// Redraw the view from the GTK thread. +static gboolean redraw_cb(gpointer user_data) { FlView* self = FL_VIEW(user_data); - g_signal_emit(self, fl_view_signals[SIGNAL_FIRST_FRAME], 0); + gtk_widget_queue_draw(GTK_WIDGET(self->render_area)); + + if (!self->have_first_frame) { + self->have_first_frame = TRUE; + g_signal_emit(self, fl_view_signals[SIGNAL_FIRST_FRAME], 0); + } return FALSE; } @@ -256,14 +261,8 @@ static void fl_view_present_layers(FlRenderable* renderable, fl_compositor_present_layers(self->compositor, layers, layers_count); - gtk_widget_queue_draw(self->render_area); - - if (!self->have_first_frame) { - self->have_first_frame = TRUE; - // This is not the main thread, so the signal needs to be done via an idle - // callback. - g_idle_add(first_frame_idle_cb, self); - } + // Perform the redraw in the GTK thead. + g_idle_add(redraw_cb, self); } // Implements FlPluginRegistry::get_registrar_for_plugin. From 1e9a811bf8e70466596bcf0ea3a8b5adb5f17f7f Mon Sep 17 00:00:00 2001 From: flutteractionsbot <154381524+flutteractionsbot@users.noreply.github.com> Date: Wed, 13 Aug 2025 16:35:25 -0700 Subject: [PATCH 22/23] [3.35] Do not include `:unittests` unless `enable_unittests` (#173734) This pull request is created by [automatic cherry pick workflow](https://github.com/flutter/flutter/blob/main/docs/releases/Flutter-Cherrypick-Process.md#automatically-creates-a-cherry-pick-request) Please fill in the form below, and a flutter domain expert will evaluate this cherry pick request. ### Issue Link: What is the link to the issue this cherry-pick is addressing? https://github.com/flutter/flutter/issues/173728 ### Changelog Description: Explain this cherry pick in one line that is accessible to most Flutter developers. See [best practices](https://github.com/flutter/flutter/blob/main/docs/releases/Hotfix-Documentation-Best-Practices.md) for examples N/A ### Impact Description: What is the impact (ex. visual jank on Samsung phones, app crash, cannot ship an iOS app)? Does it impact development (ex. flutter doctor crashes when Android Studio is installed), or the shipping production app (the app crashes on launch) Skips building Android unit tests during the engine release builder ### Workaround: Is there a workaround for this issue? No ### Risk: What is the risk level of this cherry-pick? ### Test Coverage: Are you confident that your fix is well-tested by automated tests? ### Validation Steps: What are the steps to validate that this fix works? The release builder step works. --- engine/src/flutter/BUILD.gn | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/engine/src/flutter/BUILD.gn b/engine/src/flutter/BUILD.gn index dfff40f371432..7d54efc9b643b 100644 --- a/engine/src/flutter/BUILD.gn +++ b/engine/src/flutter/BUILD.gn @@ -78,10 +78,12 @@ group("flutter") { if (!is_qnx) { public_deps = [ - ":unittests", "//flutter/shell/platform/embedder:flutter_engine", "//flutter/sky", ] + if (enable_unittests) { + public_deps += [ ":unittests" ] + } } # Ensure the example for a sample embedder compiles. From b8962555571d8c170cff8e76023ea7bf60e5ec4b Mon Sep 17 00:00:00 2001 From: Matan Lurey Date: Wed, 13 Aug 2025 17:14:08 -0700 Subject: [PATCH 23/23] [3.35] Update `engine.version` (#173748) For 1e9a811bf8e70466596bcf0ea3a8b5adb5f17f7f. --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index 60308569b8419..ef957a8fd2a32 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -659d9553df45256ed2aa388aae7ed5a1a4f51bae +1e9a811bf8e70466596bcf0ea3a8b5adb5f17f7f