diff --git a/.ci.yaml b/.ci.yaml index c57f0f015bfe5..76dda50b3a2ff 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 @@ -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/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/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/bin/internal/engine.version b/bin/internal/engine.version new file mode 100644 index 0000000000000..ef957a8fd2a32 --- /dev/null +++ b/bin/internal/engine.version @@ -0,0 +1 @@ +1e9a811bf8e70466596bcf0ea3a8b5adb5f17f7f 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/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 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/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 ' 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 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. 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/ ==================================================================================================== 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, 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); } 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 { 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. 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'); + }); } 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/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) 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/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/ios/core_devices.dart b/packages/flutter_tools/lib/src/ios/core_devices.dart index 28820446439fb..bab9e5d27ce1b 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.launchApp( + 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.launchApp( + 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. /// @@ -316,14 +532,19 @@ class IOSCoreDeviceControl { } } - Future launchApp({ + /// Launches the app on the device. + /// + /// If [startStopped] is true, the app will be launched and paused, waiting + /// for a debugger to attach. + Future launchApp({ required String deviceId, required String bundleId, List launchArguments = const [], + bool startStopped = false, }) async { if (!_xcode.isDevicectlInstalled) { - _logger.printError('devicectl is not installed.'); - return false; + _logger.printTrace('devicectl is not installed.'); + return null; } final Directory tempDirectory = _fileSystem.systemTempDirectory.createTempSync('core_devices.'); @@ -338,12 +559,65 @@ class IOSCoreDeviceControl { '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(); @@ -353,15 +627,18 @@ class IOSCoreDeviceControl { if (decodeResult is Map && decodeResult['outcome'] == 'success') { return true; } - _logger.printError('devicectl returned unexpected JSON response: $stringOutput'); + _logger.printTrace('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'); + _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.printError('Error executing devicectl: $err'); + _logger.printTrace('Error executing devicectl: $err'); return false; } finally { tempDirectory.deleteSync(recursive: true); @@ -844,3 +1121,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..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, @@ -280,17 +291,21 @@ 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, + required Analytics analytics, }) : _sdkVersion = sdkVersion, _iosDeploy = iosDeploy, _iMobileDevice = iMobileDevice, _coreDeviceControl = coreDeviceControl, + _coreDeviceLauncher = coreDeviceLauncher, _xcodeDebug = xcodeDebug, _iproxy = iProxy, _fileSystem = fileSystem, _logger = logger, + _analytics = analytics, _platform = platform, super(category: Category.mobile, platformType: PlatformType.ios, ephemeral: true) { if (!_platform.isMacOS) { @@ -301,11 +316,13 @@ 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; + final IOSCoreDeviceLauncher _coreDeviceLauncher; final XcodeDebug _xcodeDebug; final IProxy _iproxy; @@ -486,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, @@ -513,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; @@ -531,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, @@ -552,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(); } @@ -581,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) { @@ -626,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(); } @@ -682,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(); @@ -875,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, @@ -902,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 @@ -1006,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 new file mode 100644 index 0000000000000..025876ecec4f6 --- /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.printTrace('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.printTrace( + '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.printTrace('[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.printTrace('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..9534531b1eb7d 100644 --- a/packages/flutter_tools/lib/src/macos/xcdevice.dart +++ b/packages/flutter_tools/lib/src/macos/xcdevice.dart @@ -602,9 +602,17 @@ class XCDevice { iProxy: _iProxy, fileSystem: globals.fs, logger: _logger, + analytics: globals.analytics, 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/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/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/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/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 { 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 18fb9174ea544..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 @@ -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; @@ -95,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 { @@ -537,10 +1123,424 @@ ERROR: The file couldn’t be opened because it doesn’t exist. (NSCocoaErrorDo contains('ERROR: Could not obtain access to one or more requested file system'), ); expect(tempFile, isNot(exists)); - expect(status, false); + expect(status, false); + }); + + testWithoutContext('fails uninstall because of unexpected JSON', () async { + const deviceControlOutput = ''' +{ + "valid_unexpected_json": true +} +'''; + 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 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 IOSCoreDeviceLaunchResult? result = await deviceControl.launchApp( + 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.launchApp( + 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.launchApp( + 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.launchApp( + deviceId: deviceId, + bundleId: bundleId, + ); + + expect(fakeProcessManager, hasNoRemainingExpectations); + expect(tempFile, isNot(exists)); + expect(result, isNotNull); + expect(result!.outcome, isNot('success')); }); - testWithoutContext('fails uninstall because of unexpected JSON', () async { + testWithoutContext('fails launch because of unexpected JSON', () async { const deviceControlOutput = ''' { "valid_unexpected_json": true @@ -548,15 +1548,15 @@ ERROR: The file couldn’t be opened because it doesn’t exist. (NSCocoaErrorDo '''; 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, @@ -570,32 +1570,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.launchApp( 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 +1609,23 @@ invalid JSON ), ); - final bool status = await deviceControl.uninstallApp( + final IOSCoreDeviceLaunchResult? result = await deviceControl.launchApp( 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 +1633,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 +1673,12 @@ invalid JSON 'devicectl', 'device', 'process', - 'launch', + 'terminate', '--device', deviceId, - bundleId, + '--pid', + processId.toString(), + '--kill', '--json-output', tempFile.path, ], @@ -701,7 +1689,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 +1700,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 +1743,12 @@ invalid JSON 'devicectl', 'device', 'process', - 'launch', + 'terminate', '--device', deviceId, - bundleId, - '--arg1', - '--arg2', + '--pid', + processId.toString(), + '--kill', '--json-output', tempFile.path, ], @@ -786,60 +1756,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 +1824,12 @@ invalid JSON 'devicectl', 'device', 'process', - 'launch', + 'terminate', '--device', deviceId, - bundleId, + '--pid', + processId.toString(), + '--kill', '--json-output', tempFile.path, ], @@ -858,19 +1837,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 +1858,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 +1866,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 +1882,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 +1899,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 +1907,12 @@ invalid JSON 'devicectl', 'device', 'process', - 'launch', + 'terminate', '--device', deviceId, - bundleId, + '--pid', + processId.toString(), + '--kill', '--json-output', tempFile.path, ], @@ -941,10 +1923,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 +3168,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 launchApp({ + 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..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'; @@ -45,6 +46,7 @@ void main() { late IMobileDevice iMobileDevice; late FileSystem fileSystem; late IOSCoreDeviceControl coreDeviceControl; + late IOSCoreDeviceLauncher coreDeviceLauncher; late XcodeDebug xcodeDebug; setUp(() { @@ -66,6 +68,7 @@ void main() { processManager: FakeProcessManager.any(), ); coreDeviceControl = FakeIOSCoreDeviceControl(); + coreDeviceLauncher = FakeIOSCoreDeviceLauncher(); xcodeDebug = FakeXcodeDebug(); }); @@ -77,8 +80,10 @@ void main() { logger: logger, platform: macPlatform, iosDeploy: iosDeploy, + analytics: FakeAnalytics(), iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', sdkVersion: '13.3', @@ -98,10 +103,12 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, logger: logger, + analytics: FakeAnalytics(), platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.armv7, @@ -121,10 +128,12 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, logger: logger, + analytics: FakeAnalytics(), platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, @@ -143,10 +152,12 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, logger: logger, + analytics: FakeAnalytics(), platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, @@ -165,10 +176,12 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, logger: logger, + analytics: FakeAnalytics(), platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, @@ -187,10 +200,12 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, logger: logger, + analytics: FakeAnalytics(), platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, @@ -209,10 +224,12 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, logger: logger, + analytics: FakeAnalytics(), platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, @@ -233,10 +250,12 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, logger: logger, + analytics: FakeAnalytics(), platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, @@ -257,10 +276,12 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, logger: logger, + analytics: FakeAnalytics(), platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, @@ -281,10 +302,12 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, logger: logger, + analytics: FakeAnalytics(), platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, @@ -305,10 +328,12 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, logger: logger, + analytics: FakeAnalytics(), platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, @@ -329,10 +354,12 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, logger: logger, + analytics: FakeAnalytics(), platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, @@ -349,10 +376,12 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, logger: logger, + analytics: FakeAnalytics(), platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', cpuArchitecture: DarwinArch.arm64, @@ -372,10 +401,12 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, logger: logger, + analytics: FakeAnalytics(), platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', sdkVersion: '13.3 17C54', @@ -396,10 +427,12 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, logger: logger, + analytics: FakeAnalytics(), platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', sdkVersion: '13.3', @@ -427,10 +460,12 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, logger: logger, + analytics: FakeAnalytics(), platform: platform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', sdkVersion: '13.3', @@ -515,10 +550,12 @@ void main() { iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, logger: logger, + analytics: FakeAnalytics(), platform: macPlatform, iosDeploy: iosDeploy, iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', sdkVersion: '13.3', @@ -554,6 +591,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 +617,7 @@ void main() { logger: logger, ); coreDeviceControl = FakeIOSCoreDeviceControl(); + coreDeviceLauncher = FakeIOSCoreDeviceLauncher(); xcodeDebug = FakeXcodeDebug(); device1 = IOSDevice( @@ -588,8 +627,10 @@ void main() { cpuArchitecture: DarwinArch.arm64, iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), iosDeploy: iosDeploy, + analytics: FakeAnalytics(), iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, logger: logger, platform: macPlatform, @@ -608,8 +649,10 @@ void main() { cpuArchitecture: DarwinArch.arm64, iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), iosDeploy: iosDeploy, + analytics: FakeAnalytics(), iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, logger: logger, platform: macPlatform, @@ -882,6 +925,7 @@ void main() { late IMobileDevice iMobileDevice; late IOSWorkflow iosWorkflow; late IOSCoreDeviceControl coreDeviceControl; + late IOSCoreDeviceLauncher coreDeviceLauncher; late XcodeDebug xcodeDebug; late IOSDevice notConnected1; @@ -906,6 +950,7 @@ void main() { logger: logger, ); coreDeviceControl = FakeIOSCoreDeviceControl(); + coreDeviceLauncher = FakeIOSCoreDeviceLauncher(); xcodeDebug = FakeXcodeDebug(); notConnected1 = IOSDevice( '00000001-0000000000000000', @@ -914,8 +959,10 @@ void main() { cpuArchitecture: DarwinArch.arm64, iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), iosDeploy: iosDeploy, + analytics: FakeAnalytics(), iMobileDevice: iMobileDevice, coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, logger: logger, platform: macPlatform, @@ -1080,3 +1127,7 @@ 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 a802f9bf73405..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,7 +381,9 @@ IOSDevice setUpIOSDevice({ artifacts: artifacts, cache: cache, ), + analytics: FakeAnalytics(), coreDeviceControl: FakeIOSCoreDeviceControl(), + coreDeviceLauncher: FakeIOSCoreDeviceLauncher(), xcodeDebug: FakeXcodeDebug(), iProxy: IProxy.test(logger: logger, processManager: processManager), connectionInterface: interfaceType ?? DeviceConnectionInterface.attached, @@ -409,3 +412,7 @@ class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl { return true; } } + +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 6e6ef52fc6c10..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,8 +108,10 @@ IOSDevice setUpIOSDevice(FileSystem fileSystem) { artifacts: Artifacts.test(), cache: Cache.test(processManager: processManager), ), + analytics: FakeAnalytics(), iMobileDevice: IMobileDevice.test(processManager: processManager), coreDeviceControl: FakeIOSCoreDeviceControl(), + coreDeviceLauncher: FakeIOSCoreDeviceLauncher(), xcodeDebug: FakeXcodeDebug(), platform: platform, name: 'iPhone 1', @@ -126,3 +129,7 @@ IOSDevice setUpIOSDevice(FileSystem fileSystem) { 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 18dcb90ec7960..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(), @@ -1174,8 +1232,10 @@ IOSDevice setUpIOSDevice({ Artifacts? artifacts, bool isCoreDevice = false, IOSCoreDeviceControl? coreDeviceControl, + IOSCoreDeviceLauncher? coreDeviceLauncher, FakeXcodeDebug? xcodeDebug, DarwinArch cpuArchitecture = DarwinArch.arm64, + FakeExactAnalytics? analytics, }) { artifacts ??= Artifacts.test(); final cache = Cache.test( @@ -1199,6 +1259,7 @@ IOSDevice setUpIOSDevice({ artifacts: artifacts, cache: cache, ), + analytics: analytics ?? FakeExactAnalytics(), iMobileDevice: IMobileDevice( logger: logger, processManager: processManager ?? FakeProcessManager.any(), @@ -1206,6 +1267,7 @@ IOSDevice setUpIOSDevice({ cache: cache, ), coreDeviceControl: coreDeviceControl ?? FakeIOSCoreDeviceControl(), + coreDeviceLauncher: coreDeviceLauncher ?? FakeIOSCoreDeviceLauncher(), xcodeDebug: xcodeDebug ?? FakeXcodeDebug(), cpuArchitecture: cpuArchitecture, connectionInterface: DeviceConnectionInterface.attached, @@ -1224,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; @@ -1233,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(); @@ -1317,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}, + }); } } @@ -1392,3 +1459,37 @@ const _validScheme = ''' '''; + +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 279b1418e1ed8..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,6 +1565,7 @@ IOSDevice setUpIOSDevice({ cache: cache, ), coreDeviceControl: coreDeviceControl ?? FakeIOSCoreDeviceControl(), + coreDeviceLauncher: coreDeviceLauncher ?? FakeIOSCoreDeviceLauncher(), xcodeDebug: xcodeDebug ?? FakeXcodeDebug(), cpuArchitecture: DarwinArch.arm64, connectionInterface: interfaceType, @@ -1345,3 +1692,62 @@ class FakeShutDownHooks extends Fake implements ShutdownHooks { hooks.add(shutdownHook); } } + +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 new file mode 100644 index 0000000000000..3756f58d6ded6 --- /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.traceText, 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.traceText, 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.traceText, 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/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, }, ); 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/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', 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()); }, ); 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 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;