diff --git a/.agents/skills/flutter-cherry-pick/scripts/flutter_cp.dart b/.agents/skills/flutter-cherry-pick/scripts/flutter_cp.dart index 5b87a4e817ffe..22bfde7dd10a1 100644 --- a/.agents/skills/flutter-cherry-pick/scripts/flutter_cp.dart +++ b/.agents/skills/flutter-cherry-pick/scripts/flutter_cp.dart @@ -71,7 +71,12 @@ class CherryPickHelper { /// Adds the cherry-pick label (`cp: stable` or `cp: beta`) to the original PR. Future addLabel() async { - _info('Adding "cp: $channel" label to PR #$pr...'); + final String label = switch (channel) { + 'stable' => 'cp: stable', + 'beta' => 'cp: beta', + _ => throw ArgumentError('Invalid channel "$channel". Must be "stable" or "beta".'), + }; + _info('Adding "$label" label to PR #$pr...'); await runCmd([ 'gh', 'api', @@ -79,7 +84,7 @@ class CherryPickHelper { 'POST', 'repos/flutter/flutter/issues/$pr/labels', '-F', - 'labels[]=cp:$channel', + 'labels[]=$label', ]); } @@ -359,7 +364,10 @@ void main(List arguments) async { } } - if (pr == null || channel == null || action == null) { + if (pr == null || + channel == null || + action == null || + (channel != 'stable' && channel != 'beta')) { _error( 'Usage: dart flutter_cp.dart --pr --channel --action [--repo-path ]', ); diff --git a/.ci.yaml b/.ci.yaml index d7ceceda04694..d0f5c8a177512 100644 --- a/.ci.yaml +++ b/.ci.yaml @@ -53,7 +53,7 @@ platform_properties: ] dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "android_virtual_device", "version": "android_36_google_apis_x64.textpb"}, {"dependency": "avd_cipd_version", "version": "build_id:8719362231152674241"}, {"dependency": "open_jdk", "version": "version:21"}, @@ -76,7 +76,7 @@ platform_properties: ] dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "android_virtual_device", "version": "android_36_google_apis_x64.textpb"}, {"dependency": "avd_cipd_version", "version": "build_id:8702262057250908257"}, {"dependency": "open_jdk", "version": "version:21"}, @@ -100,7 +100,7 @@ platform_properties: ] dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "android_virtual_device", "version": "android_35_google_apis_x64.textpb"}, {"dependency": "avd_cipd_version", "version": "build_id:8733065022087935185"}, {"dependency": "open_jdk", "version": "version:21"}, @@ -117,7 +117,7 @@ platform_properties: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "curl", "version": "version:8.20.0"} ] @@ -129,7 +129,7 @@ platform_properties: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "curl", "version": "version:8.20.0"} ] @@ -140,7 +140,7 @@ platform_properties: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "curl", "version": "version:8.20.0"} ] @@ -151,7 +151,7 @@ platform_properties: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "curl", "version": "version:8.20.0"} ] @@ -254,7 +254,7 @@ platform_properties: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "chrome_and_driver", "version": "version:125.0.6422.141"}, {"dependency": "open_jdk", "version": "version:21"} ] @@ -266,7 +266,7 @@ platform_properties: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] os: Mac-15.7 @@ -344,7 +344,7 @@ platform_properties: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "chrome_and_driver", "version": "version:125.0.6422.141"}, {"dependency": "open_jdk", "version": "version:21"} ] @@ -377,7 +377,7 @@ targets: test_timeout_secs: "3600" # 1 hour dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "curl", "version": "version:8.20.0"} ] @@ -411,7 +411,7 @@ targets: # Requires Android SDK since we may re-generate Gradle lockfiles dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "gh_cli", "version": "version:2.8.0-2-g32256d38"}, {"dependency": "open_jdk", "version": "version:21"} ] @@ -432,7 +432,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "clang", "version": "git_revision:5d5aba78dbbee75508f01bcaa69aedb2ab79065a"}, {"dependency": "cmake", "version": "build_id:8787856497187628321"}, @@ -455,7 +455,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, {"dependency": "clang", "version": "git_revision:5d5aba78dbbee75508f01bcaa69aedb2ab79065a"}, @@ -473,7 +473,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, {"dependency": "clang", "version": "git_revision:5d5aba78dbbee75508f01bcaa69aedb2ab79065a"}, @@ -491,7 +491,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, {"dependency": "clang", "version": "git_revision:5d5aba78dbbee75508f01bcaa69aedb2ab79065a"}, @@ -509,7 +509,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, {"dependency": "clang", "version": "git_revision:5d5aba78dbbee75508f01bcaa69aedb2ab79065a"}, @@ -527,7 +527,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, {"dependency": "clang", "version": "git_revision:5d5aba78dbbee75508f01bcaa69aedb2ab79065a"}, @@ -686,7 +686,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] tags: > @@ -728,7 +728,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "clang", "version": "git_revision:5d5aba78dbbee75508f01bcaa69aedb2ab79065a"}, {"dependency": "cmake", "version": "build_id:8787856497187628321"}, @@ -817,7 +817,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "clang", "version": "git_revision:5d5aba78dbbee75508f01bcaa69aedb2ab79065a"}, {"dependency": "cmake", "version": "build_id:8787856497187628321"}, @@ -853,7 +853,7 @@ targets: {"dependency": "cmake", "version": "build_id:8787856497187628321"}, {"dependency": "ninja", "version": "version:1.9.0"}, {"dependency": "open_jdk", "version": "version:21"}, - {"dependency": "android_sdk", "version": "version:36v9unmodified"} + {"dependency": "android_sdk", "version": "version:37v2"} ] shard: framework_tests subshard: misc @@ -924,7 +924,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "clang", "version": "git_revision:5d5aba78dbbee75508f01bcaa69aedb2ab79065a"}, {"dependency": "cmake", "version": "build_id:8787856497187628321"}, @@ -946,7 +946,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "clang", "version": "git_revision:5d5aba78dbbee75508f01bcaa69aedb2ab79065a"}, {"dependency": "cmake", "version": "build_id:8787856497187628321"}, @@ -968,7 +968,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "clang", "version": "git_revision:5d5aba78dbbee75508f01bcaa69aedb2ab79065a"}, {"dependency": "cmake", "version": "build_id:8787856497187628321"}, @@ -990,7 +990,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "clang", "version": "git_revision:5d5aba78dbbee75508f01bcaa69aedb2ab79065a"}, {"dependency": "cmake", "version": "build_id:8787856497187628321"}, @@ -1013,7 +1013,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "clang", "version": "git_revision:5d5aba78dbbee75508f01bcaa69aedb2ab79065a"}, {"dependency": "cmake", "version": "build_id:8787856497187628321"}, @@ -1036,7 +1036,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "clang", "version": "git_revision:5d5aba78dbbee75508f01bcaa69aedb2ab79065a"}, {"dependency": "cmake", "version": "build_id:8787856497187628321"}, @@ -1059,7 +1059,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "clang", "version": "git_revision:5d5aba78dbbee75508f01bcaa69aedb2ab79065a"}, {"dependency": "cmake", "version": "build_id:8787856497187628321"}, @@ -1082,7 +1082,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "clang", "version": "git_revision:5d5aba78dbbee75508f01bcaa69aedb2ab79065a"}, {"dependency": "cmake", "version": "build_id:8787856497187628321"}, @@ -1106,7 +1106,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "clang", "version": "git_revision:5d5aba78dbbee75508f01bcaa69aedb2ab79065a"}, {"dependency": "cmake", "version": "build_id:8787856497187628321"}, @@ -1130,7 +1130,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "clang", "version": "git_revision:5d5aba78dbbee75508f01bcaa69aedb2ab79065a"}, {"dependency": "cmake", "version": "build_id:8787856497187628321"}, @@ -1153,7 +1153,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "clang", "version": "git_revision:5d5aba78dbbee75508f01bcaa69aedb2ab79065a"}, {"dependency": "cmake", "version": "build_id:8787856497187628321"}, @@ -1180,7 +1180,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "clang", "version": "git_revision:5d5aba78dbbee75508f01bcaa69aedb2ab79065a"}, {"dependency": "cmake", "version": "build_id:8787856497187628321"}, @@ -1320,7 +1320,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] tags: > @@ -1383,7 +1383,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"} + {"dependency": "android_sdk", "version": "version:37v2"} ] tags: > ["devicelab", "hostonly", "linux"] @@ -1396,7 +1396,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "chrome_and_driver", "version": "version:125.0.6422.141"}, {"dependency": "clang", "version": "git_revision:5d5aba78dbbee75508f01bcaa69aedb2ab79065a"}, {"dependency": "cmake", "version": "build_id:8787856497187628321"}, @@ -1424,7 +1424,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "chrome_and_driver", "version": "version:125.0.6422.141"}, {"dependency": "clang", "version": "git_revision:5d5aba78dbbee75508f01bcaa69aedb2ab79065a"}, {"dependency": "cmake", "version": "build_id:8787856497187628321"}, @@ -1452,7 +1452,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "chrome_and_driver", "version": "version:125.0.6422.141"}, {"dependency": "clang", "version": "git_revision:5d5aba78dbbee75508f01bcaa69aedb2ab79065a"}, {"dependency": "cmake", "version": "build_id:8787856497187628321"}, @@ -1480,7 +1480,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "chrome_and_driver", "version": "version:125.0.6422.141"}, {"dependency": "clang", "version": "git_revision:5d5aba78dbbee75508f01bcaa69aedb2ab79065a"}, {"dependency": "cmake", "version": "build_id:8787856497187628321"}, @@ -1508,7 +1508,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "chrome_and_driver", "version": "version:125.0.6422.141"}, {"dependency": "clang", "version": "git_revision:5d5aba78dbbee75508f01bcaa69aedb2ab79065a"}, {"dependency": "cmake", "version": "build_id:8787856497187628321"}, @@ -1536,7 +1536,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "chrome_and_driver", "version": "version:125.0.6422.141"}, {"dependency": "clang", "version": "git_revision:5d5aba78dbbee75508f01bcaa69aedb2ab79065a"}, {"dependency": "cmake", "version": "build_id:8787856497187628321"}, @@ -1564,7 +1564,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "chrome_and_driver", "version": "version:125.0.6422.141"}, {"dependency": "clang", "version": "git_revision:5d5aba78dbbee75508f01bcaa69aedb2ab79065a"}, {"dependency": "cmake", "version": "build_id:8787856497187628321"}, @@ -1622,7 +1622,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "clang", "version": "git_revision:5d5aba78dbbee75508f01bcaa69aedb2ab79065a"}, {"dependency": "cmake", "version": "build_id:8787856497187628321"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, @@ -1648,7 +1648,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:11"} ] task_name: android_java11_dependency_smoke_tests @@ -1671,7 +1671,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:17"}, {"dependency": "clang", "version": "git_revision:5d5aba78dbbee75508f01bcaa69aedb2ab79065a"}, {"dependency": "cmake", "version": "build_id:8787856497187628321"}, @@ -1697,7 +1697,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "clang", "version": "git_revision:5d5aba78dbbee75508f01bcaa69aedb2ab79065a"}, {"dependency": "cmake", "version": "build_id:8787856497187628321"}, @@ -1722,7 +1722,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] shard: tool_tests @@ -1744,7 +1744,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] shard: tool_tests @@ -1780,7 +1780,7 @@ targets: ["framework", "hostonly", "shard", "linux"] dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"} ] @@ -1807,7 +1807,7 @@ targets: ["framework", "hostonly", "shard", "linux"] dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"} ] @@ -3769,7 +3769,7 @@ targets: test_timeout_secs: "2700" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"} @@ -3788,7 +3788,7 @@ targets: test_timeout_secs: "2700" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"} @@ -3807,7 +3807,7 @@ targets: test_timeout_secs: "2700" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"} @@ -3826,7 +3826,7 @@ targets: test_timeout_secs: "2700" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"} @@ -3845,7 +3845,7 @@ targets: test_timeout_secs: "2700" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"} @@ -3862,7 +3862,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"} @@ -3879,7 +3879,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"} @@ -3896,7 +3896,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"} @@ -3913,7 +3913,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"} @@ -3930,7 +3930,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"} @@ -4141,7 +4141,7 @@ targets: {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, {"dependency": "open_jdk", "version": "version:21"}, - {"dependency": "android_sdk", "version": "version:36v9unmodified"} + {"dependency": "android_sdk", "version": "version:37v2"} ] shard: framework_tests subshard: misc @@ -4172,7 +4172,7 @@ targets: {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, {"dependency": "open_jdk", "version": "version:21"}, - {"dependency": "android_sdk", "version": "version:36v9unmodified"} + {"dependency": "android_sdk", "version": "version:37v2"} ] shard: framework_tests subshard: misc @@ -4228,7 +4228,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] tags: > @@ -4276,7 +4276,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] tags: > @@ -4296,7 +4296,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] tags: > @@ -4316,7 +4316,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] tags: > @@ -4337,7 +4337,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] tags: > @@ -4419,7 +4419,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"} ] @@ -4481,7 +4481,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] tags: > @@ -4501,7 +4501,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] tags: > @@ -4596,25 +4596,25 @@ targets: - engine/** - DEPS - - name: Mac tool_integration_tests_1_5 + - name: Mac tool_integration_tests_1 recipe: flutter/flutter_drone + bringup: true timeout: 60 properties: cpu: arm64 add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "chrome_and_driver", "version": "version:125.0.6422.141"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"} ] shard: tool_integration_tests - subshard: "1_5" + subshard: "1_10" tags: > ["framework", "hostonly", "shard", "mac"] - test_timeout_secs: "2700" runIf: - dev/** - packages/flutter_tools/** @@ -4623,25 +4623,25 @@ targets: - engine/** - DEPS - - name: Mac tool_integration_tests_2_5 + - name: Mac tool_integration_tests_2 recipe: flutter/flutter_drone + bringup: true timeout: 60 properties: cpu: arm64 add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "chrome_and_driver", "version": "version:125.0.6422.141"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"} ] shard: tool_integration_tests - subshard: "2_5" + subshard: "2_10" tags: > ["framework", "hostonly", "shard", "mac"] - test_timeout_secs: "2700" runIf: - dev/** - packages/flutter_tools/** @@ -4650,25 +4650,25 @@ targets: - engine/** - DEPS - - name: Mac tool_integration_tests_3_5 + - name: Mac tool_integration_tests_3 recipe: flutter/flutter_drone + bringup: true timeout: 60 properties: cpu: arm64 add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "chrome_and_driver", "version": "version:125.0.6422.141"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"} ] shard: tool_integration_tests - subshard: "3_5" + subshard: "3_10" tags: > ["framework", "hostonly", "shard", "mac"] - test_timeout_secs: "2700" runIf: - dev/** - packages/flutter_tools/** @@ -4677,25 +4677,25 @@ targets: - engine/** - DEPS - - name: Mac tool_integration_tests_4_5 + - name: Mac tool_integration_tests_4 recipe: flutter/flutter_drone + bringup: true timeout: 60 properties: cpu: arm64 add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "chrome_and_driver", "version": "version:125.0.6422.141"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"} ] shard: tool_integration_tests - subshard: "4_5" + subshard: "4_10" tags: > ["framework", "hostonly", "shard", "mac"] - test_timeout_secs: "2700" runIf: - dev/** - packages/flutter_tools/** @@ -4704,25 +4704,160 @@ targets: - engine/** - DEPS - - name: Mac tool_integration_tests_5_5 + - name: Mac tool_integration_tests_5 recipe: flutter/flutter_drone + bringup: true timeout: 60 properties: cpu: arm64 add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "chrome_and_driver", "version": "version:125.0.6422.141"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"} ] shard: tool_integration_tests - subshard: "5_5" + subshard: "5_10" + tags: > + ["framework", "hostonly", "shard", "mac"] + runIf: + - dev/** + - packages/flutter_tools/** + - bin/** + - .ci.yaml + - engine/** + - DEPS + + - name: Mac tool_integration_tests_6 + recipe: flutter/flutter_drone + bringup: true + timeout: 60 + properties: + cpu: arm64 + add_recipes_cq: "true" + dependencies: >- + [ + {"dependency": "android_sdk", "version": "version:37v2"}, + {"dependency": "chrome_and_driver", "version": "version:145.0.7632.117"}, + {"dependency": "open_jdk", "version": "version:21"}, + {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, + {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"} + ] + shard: tool_integration_tests + subshard: "6_10" + tags: > + ["framework", "hostonly", "shard", "mac"] + runIf: + - dev/** + - packages/flutter_tools/** + - bin/** + - .ci.yaml + - engine/** + - DEPS + + - name: Mac tool_integration_tests_7 + recipe: flutter/flutter_drone + bringup: true + timeout: 60 + properties: + cpu: arm64 + add_recipes_cq: "true" + dependencies: >- + [ + {"dependency": "android_sdk", "version": "version:37v2"}, + {"dependency": "chrome_and_driver", "version": "version:145.0.7632.117"}, + {"dependency": "open_jdk", "version": "version:21"}, + {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, + {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"} + ] + shard: tool_integration_tests + subshard: "7_10" + tags: > + ["framework", "hostonly", "shard", "mac"] + runIf: + - dev/** + - packages/flutter_tools/** + - bin/** + - .ci.yaml + - engine/** + - DEPS + + - name: Mac tool_integration_tests_8 + recipe: flutter/flutter_drone + bringup: true + timeout: 60 + properties: + cpu: arm64 + add_recipes_cq: "true" + dependencies: >- + [ + {"dependency": "android_sdk", "version": "version:37v2"}, + {"dependency": "chrome_and_driver", "version": "version:145.0.7632.117"}, + {"dependency": "open_jdk", "version": "version:21"}, + {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, + {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"} + ] + shard: tool_integration_tests + subshard: "8_10" + tags: > + ["framework", "hostonly", "shard", "mac"] + runIf: + - dev/** + - packages/flutter_tools/** + - bin/** + - .ci.yaml + - engine/** + - DEPS + + - name: Mac tool_integration_tests_9 + recipe: flutter/flutter_drone + bringup: true + timeout: 60 + properties: + cpu: arm64 + add_recipes_cq: "true" + dependencies: >- + [ + {"dependency": "android_sdk", "version": "version:37v2"}, + {"dependency": "chrome_and_driver", "version": "version:145.0.7632.117"}, + {"dependency": "open_jdk", "version": "version:21"}, + {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, + {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"} + ] + shard: tool_integration_tests + subshard: "9_10" + tags: > + ["framework", "hostonly", "shard", "mac"] + runIf: + - dev/** + - packages/flutter_tools/** + - bin/** + - .ci.yaml + - engine/** + - DEPS + + - name: Mac tool_integration_tests_10 + recipe: flutter/flutter_drone + bringup: true + timeout: 60 + properties: + cpu: arm64 + add_recipes_cq: "true" + dependencies: >- + [ + {"dependency": "android_sdk", "version": "version:37v2"}, + {"dependency": "chrome_and_driver", "version": "version:145.0.7632.117"}, + {"dependency": "open_jdk", "version": "version:21"}, + {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, + {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"} + ] + shard: tool_integration_tests + subshard: "10_10" tags: > ["framework", "hostonly", "shard", "mac"] - test_timeout_secs: "2700" runIf: - dev/** - packages/flutter_tools/** @@ -4738,7 +4873,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] shard: tool_tests_commands @@ -4753,7 +4888,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] shard: tool_tests_commands @@ -4768,7 +4903,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] shard: tool_tests @@ -4891,9 +5026,10 @@ targets: task_name: hello_world_android__compile # mac mokey benchmark - - name: Mac_mokey hot_mode_dev_cycle__benchmark + - name: Mac_arm64_mokey hot_mode_dev_cycle__benchmark recipe: devicelab/devicelab_drone presubmit: false + bringup: true timeout: 60 properties: tags: > @@ -4931,9 +5067,10 @@ targets: task_name: integration_ui_frame_number # mac mokey benchmark - - name: Mac_mokey microbenchmarks + - name: Mac_arm64_mokey microbenchmarks recipe: devicelab/devicelab_drone presubmit: false + bringup: true timeout: 60 properties: tags: > @@ -5556,9 +5693,10 @@ targets: ["devicelab", "ios", "mac"] task_name: hot_mode_dev_cycle_ios__benchmark - - name: Mac_x64 hot_mode_dev_cycle_ios_simulator + - name: Mac_arm64 hot_mode_dev_cycle_ios_simulator recipe: devicelab/devicelab_drone presubmit: false + bringup: true timeout: 60 properties: dependencies: >- @@ -5799,7 +5937,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, {"dependency": "vs_build", "version": "version:vs2019"} @@ -5816,7 +5954,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, {"dependency": "vs_build", "version": "version:vs2019"} @@ -5833,7 +5971,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, {"dependency": "vs_build", "version": "version:vs2019"} @@ -5850,7 +5988,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, {"dependency": "vs_build", "version": "version:vs2019"} @@ -5867,7 +6005,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, {"dependency": "vs_build", "version": "version:vs2019"} @@ -5884,7 +6022,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, {"dependency": "vs_build", "version": "version:vs2019"} @@ -5901,7 +6039,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, {"dependency": "vs_build", "version": "version:vs2019"} @@ -5918,7 +6056,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, {"dependency": "vs_build", "version": "version:vs2019"} @@ -5935,7 +6073,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, {"dependency": "vs_build", "version": "version:vs2019"} @@ -6023,7 +6161,7 @@ targets: {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, {"dependency": "vs_build", "version": "version:vs2019"}, {"dependency": "open_jdk", "version": "version:21"}, - {"dependency": "android_sdk", "version": "version:36v9unmodified"} + {"dependency": "android_sdk", "version": "version:37v2"} ] shard: framework_tests subshard: misc @@ -6055,7 +6193,7 @@ targets: {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, {"dependency": "vs_build", "version": "version:vs2019"}, {"dependency": "open_jdk", "version": "version:21"}, - {"dependency": "android_sdk", "version": "version:36v9unmodified"} + {"dependency": "android_sdk", "version": "version:37v2"} ] shard: framework_tests subshard: misc @@ -6141,7 +6279,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] tags: > @@ -6186,7 +6324,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] tags: > @@ -6206,7 +6344,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] tags: > @@ -6226,7 +6364,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] tags: > @@ -6248,7 +6386,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] tags: > @@ -6295,7 +6433,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] tags: > @@ -6315,7 +6453,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] tags: > @@ -6335,7 +6473,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] tags: > @@ -6497,7 +6635,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "chrome_and_driver", "version": "version:125.0.6422.141"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, @@ -6523,7 +6661,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "chrome_and_driver", "version": "version:125.0.6422.141"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, @@ -6549,7 +6687,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "chrome_and_driver", "version": "version:125.0.6422.141"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, @@ -6575,7 +6713,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "chrome_and_driver", "version": "version:125.0.6422.141"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, @@ -6601,7 +6739,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "chrome_and_driver", "version": "version:125.0.6422.141"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, @@ -6627,7 +6765,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "chrome_and_driver", "version": "version:125.0.6422.141"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, @@ -6653,7 +6791,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "chrome_and_driver", "version": "version:125.0.6422.141"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, @@ -6679,7 +6817,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "chrome_and_driver", "version": "version:125.0.6422.141"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, @@ -6705,7 +6843,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "chrome_and_driver", "version": "version:125.0.6422.141"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, @@ -6731,7 +6869,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "chrome_and_driver", "version": "version:125.0.6422.141"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, @@ -6757,7 +6895,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "vs_build", "version": "version:vs2019"} ] @@ -6804,7 +6942,7 @@ targets: add_recipes_cq: "true" dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] shard: tool_tests diff --git a/CHANGELOG.md b/CHANGELOG.md index a422a565cfb7d..32e59246b64dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,8 +30,72 @@ More information and tips: docs/releases/Hotfix-Documentation-Best-Practices.md --> +## Flutter 3.47 Changes + +### [3.47.5](https://github.com/flutter/flutter/releases/tag/3.47.5) +- [flutter/190307](https://github.com/flutter/flutter/issues/190307) When debugging on physical iOS 27 devices, app occasionally crashes. +- [flutter/191242](https://github.com/flutter/flutter/issues/191242) When re-expanding a preview group in the Widget Previewer, previews crash due to a type error in scroll restoration. +- [flutter/189507](https://github.com/flutter/flutter/pull/189507) When Dart Development Service encounters a startup failure, flutter_tools crashes with an unhandled FormatException. + +### [3.47.4](https://github.com/flutter/flutter/releases/tag/3.47.4) +- [flutter/191899](https://github.com/flutter/flutter/issues/191899) When Windows Smart App Control or security policies block binary execution on Windows, gracefully handle the error with an actionable message instead of crashing. +- [flutter/192120](https://github.com/flutter/flutter/pull/192120) Fixes missing preview reload timing analytics in LspPreviewDetector across all platforms. +- [flutter/181560](https://github.com/flutter/flutter/issues/181560) When building iOS and macOS app, SwiftPM migration warnings and errors are not tracked in analytics. +- [flutter/191964](https://github.com/flutter/flutter/pull/191964) On iOS, apps bundling native assets fail App Store submission when native libraries are compiled with deployment targets above iOS 13. +- [flutter/189284](https://github.com/flutter/flutter/issues/189284) When debugging with Xcode 27, the app may launch to a white screen and hang for multiple minutes. +- [flutter/190465](https://github.com/flutter/flutter/issues/190465) When running WebAssembly dry-run builds on Web, remove redundant experiment flag warning that dropped telemetry error findings. +- [flutter/191898](https://github.com/flutter/flutter/issues/191898) When remote test processes emit plain-text error output on all platforms, prevent flutter_tools from crashing with an unhandled FormatException. + +### [3.47.3](https://github.com/flutter/flutter/releases/tag/3.47.3) +- [flutter/191045](https://github.com/flutter/flutter/issues/191045) When calling Actions.handler on all platforms, null was always returned. +- [flutter/181315](https://github.com/flutter/flutter/issues/181315) When using Impeller on Android devices with B-Series PowerVR GPUs, visual oddities and performance drops can be experienced. +- [flutter/191176](https://github.com/flutter/flutter/issues/191176) When building for macOS or iOS on machines with missing or incomplete Xcode installations, flutter_tools displays an actionable error message instead of crashing with an unhandled ProcessException. +- [flutter/191487](https://github.com/flutter/flutter/issues/191487) When using Android SDK Command-line Tools 23.0+ for Android, flutter doctor incorrectly reports "Android license status unknown". + +### [3.47.2](https://github.com/flutter/flutter/releases/tag/3.47.2) +- [flutter/191179](https://github.com/flutter/flutter/issues/191179) When WebSocket upgrade fails during DDS startup on web, handle the `DartDevelopmentServiceException` gracefully instead of crashing. +- [flutter/190518](https://github.com/flutter/flutter/pull/190518) Fix memory leak caused by processing touch events on Linux. +- [flutter/188265](https://github.com/flutter/flutter/issues/188265) iOS or macOS builds may fail when Swift Package Manager is enabled. +- [flutter/190846](https://github.com/flutter/flutter/issues/190846) When building an existing native iOS app that's integrated with Flutter using SwiftPM on Xcode 27, it may fail to build Flutter's Swift packages. +- [flutter/187925](https://github.com/flutter/flutter/issues/187925) On Linux, fix header elements not being announced by screen reader. +- [flutter/190774](https://github.com/flutter/flutter/issues/190774) When using external textures on Windows, a crash could occur when the engine paints them. +- [flutter/191177](https://github.com/flutter/flutter/issues/191177) The Flutter tool crashes if a configured custom device ping times out. +- [flutter/190987](https://github.com/flutter/flutter/issues/190987) Updates `libpng` to fix security vulnerability. +- [flutter/191060](https://github.com/flutter/flutter/issues/191060) When `analysis_options.yaml` contains flow-style `exclude:` sequences, `AnalysisOptionsMigration` converts the node to block style before appending to avoid `YamlEditor` assertion crashes. +- [flutter/191131](https://github.com/flutter/flutter/issues/191131) Only exclude platform directories that actually exist from analysis, so web/** sources in Dart web packages are no longer silently dropped. +- [flutter/191198](https://github.com/flutter/flutter/issues/191198) On Windows, truncate `lastCompiled` timestamp to whole seconds in `ProjectFileInvalidator` so rapid consecutive file modifications within the same second are not silently ignored by hot reload. +- [flutter/152236](https://github.com/flutter/flutter/issues/152236) On desktop platforms, forward `--build-name` and `--build-number` flags to the desktop build pipeline so generated `version.json` reflects command-line build version parameters. +- [flutter/191205](https://github.com/flutter/flutter/issues/191205) Prevent `StateError: Bad state: Future already completed in Symbolizer` during stack symbolization. +- [flutter/191178](https://github.com/flutter/flutter/issues/191178) Handle `HttpException` during VM service connection retries and ensure typed exceptions are passed to error completers. + +### [3.47.1](https://github.com/flutter/flutter/releases/tag/3.47.1) +- [flutter/190871](https://github.com/flutter/flutter/issues/190871) Flutter GPU could not be enabled in release builds on Linux and Windows, since those embedders had no project-level opt-in and release builds ignore engine switches from the environment. +- [flutter/188446](https://github.com/flutter/flutter/issues/188446) When building multi-target applications in parallel on iOS and macOS, a race condition in SwiftPM integration causes `FileSystemException`. +- [flutter/189550](https://github.com/flutter/flutter/issues/189550) When running `flutter pub get` in a Dart workspace, platform tooling regeneration and analysis options migration are skipped for packages that do not depend on Flutter. +- [flutter/190234](https://github.com/flutter/flutter/issues/190234) Avoid crashing with `PathNotFoundException` when cleaning up native assets if files are removed concurrently. +- [flutter/189128](https://github.com/flutter/flutter/issues/189128) Invalidate `WebEntrypointTarget` and regenerate `web_plugin_registrant.dart` when web plugins are added or removed. +- [flutter/186445](https://github.com/flutter/flutter/issues/186445) Fix hot restart for WASM web builds. +- [flutter/190284](https://github.com/flutter/flutter/issues/190284) Fix hot reload failing to reload edits in nested Pub workspace member packages located under root `lib/`. +- [flutter/191056](https://github.com/flutter/flutter/issues/191056) Recursively resolve includes in `AnalysisOptionsMigration` to prevent redundant rewrites of `analysis_options.yaml`. +- [flutter/189156](https://github.com/flutter/flutter/issues/189156) Validate plugin class and package identifiers to prevent arbitrary code injection into `GeneratedPluginRegistrant`. +- [flutter/190721](https://github.com/flutter/flutter/issues/190721) Fix deadlock in debug adapters when the target process exits early during startup before VM service connection. +- [flutter/189972](https://github.com/flutter/flutter/issues/189972) Fix ADB device list parsing for long wireless mDNS serials separated from state by a single space. +- [flutter/190233](https://github.com/flutter/flutter/issues/190233) Fix `impellerc` crash on Windows when paths contain Unicode characters, and improve shader compiler error diagnostics. + +### [3.47.0](https://github.com/flutter/flutter/releases/tag/3.47.0) + +Learn about what's new in this release in [the blog post](https://flutter.dev/blog/whats-new-in-flutter-3-47), and check out the [CHANGELOG](https://docs.flutter.dev/release/release-notes/release-notes-3.47.0) for a detailed list of all the new changes. + + ## Flutter 3.44 Changes +### [3.44.7](https://github.com/flutter/flutter/releases/tag/3.44.7) +- [flutter/188161](https://github.com/flutter/flutter/issues/188161) Fixes a resource leak that sometimes caused crashes when using external textures on some GPUs (such as Arm Mali). + +### [3.44.6](https://github.com/flutter/flutter/releases/tag/3.44.6) +- [flutter/187980](https://github.com/flutter/flutter/issues/187980) On Linux, building asset bundles with native assets enabled without a native app build directory crashes due to missing `CMakeCache.txt`. +- [flutter/188805](https://github.com/flutter/flutter/issues/188805) Fixes a crash when running Android instrumented tests. + ### [3.44.5](https://github.com/flutter/flutter/releases/tag/3.44.5) - [flutter/188718](https://github.com/flutter/flutter/issues/188718) When building Flutter Android apps and add-to-app modules using AGP < 9 with KGP, the tool incorrectly logs Built-in Kotlin migration warnings. - [flutter/186810](https://github.com/flutter/flutter/issues/186810) [flutter/187388](https://github.com/flutter/flutter/issues/187388) [flutter/187553](https://github.com/flutter/flutter/issues/187553) When building Android app bundles using flavors, or with an old app template combined with a plugin coming alphabetically before app, fixes problems with failing to include libapp.so in the produced app bundle. diff --git a/DEPS b/DEPS index 2bb4a06282f67..2b179b94b2cbf 100644 --- a/DEPS +++ b/DEPS @@ -56,14 +56,14 @@ vars = { # updated revision list of existing dependencies. You will need to # gclient sync before and after update deps to ensure all deps are updated. # updated revision list of existing dependencies. - 'dart_revision': 'cdb7217e65aaee33dd28280ed407efdfd50a3902', + 'dart_revision': 'b530c21f7de367b94fb04787bfed9d8e989d75e8', # WARNING: DO NOT EDIT MANUALLY # The lines between blank lines above and below are generated by a script. See create_updated_flutter_deps.py 'dart_binaryen_rev': '9926156a583cec3d22d521232b31c70fa9a87dc1', 'dart_boringssl_rev': '2e508c973d634b3aa51b71db5062bc6b096e5031', 'dart_core_rev': 'be0b1531c445a185d3e93887b8d0355fc766c314', - 'dart_devtools_rev': '3eca94a79d17c9a3e77b51a2d2d6cb306cda9c2a', + 'dart_devtools_rev': '12d595649f189f1896722623f72599077f476848', 'dart_ecosystem_rev': '848b3bf3b757d2e9ae4d60030eeed5756c87783f', 'dart_http_rev': '5d94ef52582867e077bf41c3fa20fb8b1d1d834e', 'dart_i18n_rev': 'd0683bdea253d19a4350f5bc2be9017aba61837f', @@ -294,7 +294,7 @@ deps = { Var('chromium_git') + '/external/github.com/WebAssembly/binaryen.git' + '@' + Var('dart_binaryen_rev'), 'engine/src/flutter/third_party/dart/third_party/devtools': - {'dep_type': 'cipd', 'packages': [{'package': 'dart/third_party/flutter/devtools', 'version': 'git_revision:3eca94a79d17c9a3e77b51a2d2d6cb306cda9c2a'}]}, + {'dep_type': 'cipd', 'packages': [{'package': 'dart/third_party/flutter/devtools', 'version': 'git_revision:12d595649f189f1896722623f72599077f476848'}]}, 'engine/src/flutter/third_party/dart/third_party/perfetto/src': Var('chromium_git') + '/external/github.com/google/perfetto' + '@' + Var('dart_perfetto_rev'), @@ -303,7 +303,7 @@ deps = { Var('dart_git') + '/core.git' + '@' + Var('dart_core_rev'), 'engine/src/flutter/third_party/dart/third_party/pkg/dart_style': - Var('dart_git') + '/dart_style.git@dfdf6420c7ea923d28edef3f11e89b4ff23d03bf', + Var('dart_git') + '/dart_style.git@39edc2d946a5d7bd1caf6f1695f366b00f7b873c', 'engine/src/flutter/third_party/dart/third_party/pkg/dartdoc': Var('dart_git') + '/dartdoc.git@1d56f263955f329b6701d8f84f069eb0aef353a4', @@ -321,7 +321,7 @@ deps = { Var('dart_git') + '/leak_tracker.git@f5620600a5ce1c44f65ddaa02001e200b096e14c', 'engine/src/flutter/third_party/dart/third_party/pkg/native': - Var('dart_git') + '/native.git@81e464e7ff06aa66246b38a326025e3dba6928d3', + Var('dart_git') + '/native.git@d51c53334486af69435b0b45c4bdfe303a830170', 'engine/src/flutter/third_party/dart/third_party/pkg/protobuf': Var('dart_git') + '/protobuf.git' + '@' + Var('dart_protobuf_rev'), @@ -481,7 +481,7 @@ deps = { Var('flutter_git') + '/third_party/libjpeg-turbo' + '@' + '0fb821f3b2e570b2783a94ccd9a2fb1f4916ae9f', 'engine/src/flutter/third_party/libpng': - Var('flutter_git') + '/third_party/libpng' + '@' + 'b6004397d2ab98f0250376d9b357337b7f422d13', + Var('flutter_git') + '/third_party/libpng' + '@' + 'ced6b6c0c1e24716423a417b399d3c7d4623890d', 'engine/src/flutter/third_party/libwebp': Var('chromium_git') + '/webm/libwebp.git' + '@' + 'ca332209cb5567c9b249c86788cb2dbf8847e760', # 1.3.2 @@ -601,7 +601,7 @@ deps = { 'packages': [ { 'package': 'flutter/android/sdk/all/${{platform}}', - 'version': 'version:36v9unmodified' + 'version': 'version:37v2' } ], 'condition': 'download_android_deps', diff --git a/bin/internal/engine.version b/bin/internal/engine.version new file mode 100644 index 0000000000000..a7dfa16c90d40 --- /dev/null +++ b/bin/internal/engine.version @@ -0,0 +1 @@ +af7e796e161ae0bb1ff0758c71a7105418bd9ded diff --git a/bin/internal/release-candidate-branch.version b/bin/internal/release-candidate-branch.version new file mode 100644 index 0000000000000..9ce5ccdaf5478 --- /dev/null +++ b/bin/internal/release-candidate-branch.version @@ -0,0 +1 @@ +flutter-3.47-candidate.0 \ No newline at end of file diff --git a/dev/benchmarks/multiple_flutters/android/build.gradle b/dev/benchmarks/multiple_flutters/android/build.gradle index 8a840aa950edc..70e79f138041b 100644 --- a/dev/benchmarks/multiple_flutters/android/build.gradle +++ b/dev/benchmarks/multiple_flutters/android/build.gradle @@ -4,13 +4,13 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = "2.2.0" + ext.kotlin_version = "2.2.20" repositories { google() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:8.11.0' + classpath 'com.android.tools.build:gradle:8.11.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // Do not place your application dependencies here; they belong diff --git a/dev/benchmarks/multiple_flutters/android/gradle/wrapper/gradle-wrapper.properties b/dev/benchmarks/multiple_flutters/android/gradle/wrapper/gradle-wrapper.properties index 97584667fbf12..2b281b8a28014 100644 --- a/dev/benchmarks/multiple_flutters/android/gradle/wrapper/gradle-wrapper.properties +++ b/dev/benchmarks/multiple_flutters/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Mon Feb 24 15:28:21 EST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/dev/devicelab/bin/tasks/android_java17_dependency_smoke_tests.dart b/dev/devicelab/bin/tasks/android_java17_dependency_smoke_tests.dart index f2ba37901da19..9bf9983dafcf1 100644 --- a/dev/devicelab/bin/tasks/android_java17_dependency_smoke_tests.dart +++ b/dev/devicelab/bin/tasks/android_java17_dependency_smoke_tests.dart @@ -17,14 +17,13 @@ import 'package:flutter_devicelab/framework/framework.dart'; // (*) - support range defined in packages/flutter_tools/gradle/src/main/kotlin/dependency_version_checker.gradle.kts. List versionTuples = [ // Minimum supported - VersionTuple(agpVersion: '8.6.0', gradleVersion: '8.7', kotlinVersion: '2.0.0'), - // Template VersionTuple(agpVersion: '8.11.1', gradleVersion: '8.14', kotlinVersion: '2.2.20'), - // Max known - VersionTuple(agpVersion: '8.13.0', gradleVersion: '9.1.0', kotlinVersion: '2.2.20'), + // Template and max known + // TODO(jesswon): Separate max known once there exists a newer one: https://github.com/flutter/flutter/issues/189112. + VersionTuple(agpVersion: '9.1.0', gradleVersion: '9.3.1', kotlinVersion: '2.4.0'), /* Others */ - VersionTuple(agpVersion: '8.7.0', gradleVersion: '8.9', kotlinVersion: '2.1.0'), - VersionTuple(agpVersion: '8.9.0', gradleVersion: '8.11.1', kotlinVersion: '2.2.0'), + VersionTuple(agpVersion: '8.11.1', gradleVersion: '8.14', kotlinVersion: '2.2.20'), + VersionTuple(agpVersion: '8.12.0', gradleVersion: '8.14', kotlinVersion: '2.2.20'), ]; // Max length is 7 entries until this test is split See https://github.com/flutter/flutter/issues/167495. Future main() async { diff --git a/dev/devicelab/lib/tasks/plugin_tests.dart b/dev/devicelab/lib/tasks/plugin_tests.dart index f65c9c20f5353..ffd33ee96073a 100644 --- a/dev/devicelab/lib/tasks/plugin_tests.dart +++ b/dev/devicelab/lib/tasks/plugin_tests.dart @@ -459,6 +459,8 @@ Pod::Spec.new do |s| s.source = { :path => '.' } s.source_files = "Classes", "Classes/**/*.{h,m}" s.dependency 'plugintest' + s.ios.deployment_target = '12.0' + s.osx.deployment_target = '10.14' end '''); diff --git a/dev/integration_tests/module_host_with_custom_build_v2_embedding/build.gradle b/dev/integration_tests/module_host_with_custom_build_v2_embedding/build.gradle index c20f32d4eee77..59365ec27a943 100644 --- a/dev/integration_tests/module_host_with_custom_build_v2_embedding/build.gradle +++ b/dev/integration_tests/module_host_with_custom_build_v2_embedding/build.gradle @@ -8,7 +8,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:8.11.0' + classpath 'com.android.tools.build:gradle:8.11.1' } } diff --git a/dev/integration_tests/module_host_with_custom_build_v2_embedding/gradle/wrapper/gradle-wrapper.properties b/dev/integration_tests/module_host_with_custom_build_v2_embedding/gradle/wrapper/gradle-wrapper.properties index 37f853b1c84d2..ca025c83a7cc5 100644 --- a/dev/integration_tests/module_host_with_custom_build_v2_embedding/gradle/wrapper/gradle-wrapper.properties +++ b/dev/integration_tests/module_host_with_custom_build_v2_embedding/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/dev/integration_tests/pure_android_host_apps/android_custom_host_app/build.gradle b/dev/integration_tests/pure_android_host_apps/android_custom_host_app/build.gradle index c8e9b3523b65e..d0248f02f6d0d 100644 --- a/dev/integration_tests/pure_android_host_apps/android_custom_host_app/build.gradle +++ b/dev/integration_tests/pure_android_host_apps/android_custom_host_app/build.gradle @@ -8,7 +8,7 @@ buildscript { mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:8.11.0' + classpath 'com.android.tools.build:gradle:8.11.1' } } diff --git a/dev/integration_tests/pure_android_host_apps/android_custom_host_app/gradle/wrapper/gradle-wrapper.properties b/dev/integration_tests/pure_android_host_apps/android_custom_host_app/gradle/wrapper/gradle-wrapper.properties index 4af5d3a25670a..c28b98d53bb60 100644 --- a/dev/integration_tests/pure_android_host_apps/android_custom_host_app/gradle/wrapper/gradle-wrapper.properties +++ b/dev/integration_tests/pure_android_host_apps/android_custom_host_app/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Tue Feb 18 12:03:21 EST 2025 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/dev/integration_tests/widget_preview_scaffold/lib/src/widget_preview_rendering.dart b/dev/integration_tests/widget_preview_scaffold/lib/src/widget_preview_rendering.dart index b731021768edb..05c7f552c841a 100644 --- a/dev/integration_tests/widget_preview_scaffold/lib/src/widget_preview_rendering.dart +++ b/dev/integration_tests/widget_preview_scaffold/lib/src/widget_preview_rendering.dart @@ -217,7 +217,7 @@ class PreviewWidgetElement extends StatelessElement { PreviewWidgetElement(super.widget); } -class WidgetPreviewGroupWidget extends StatelessWidget { +class WidgetPreviewGroupWidget extends StatefulWidget { const WidgetPreviewGroupWidget({ super.key, required this.controller, @@ -235,6 +235,14 @@ class WidgetPreviewGroupWidget extends StatelessWidget { // TODO(bkonyi): inherit this from the theme. static const _kCardRadius = Radius.circular(12); + @override + State createState() => + _WidgetPreviewGroupWidgetState(); +} + +class _WidgetPreviewGroupWidgetState extends State { + final _bucket = PageStorageBucket(); + Widget _buildGridViewFlex(List previews) { return Wrap( spacing: WidgetPreviewGroupWidget._gridSpacing, @@ -242,7 +250,7 @@ class WidgetPreviewGroupWidget extends StatelessWidget { alignment: WrapAlignment.start, children: [ for (final WidgetPreview preview in previews) - WidgetPreviewWidget(controller: controller, preview: preview), + WidgetPreviewWidget(controller: widget.controller, preview: preview), ], ); } @@ -253,7 +261,7 @@ class WidgetPreviewGroupWidget extends StatelessWidget { for (final preview in previews) Center( child: WidgetPreviewWidget( - controller: controller, + controller: widget.controller, preview: preview, ), ), @@ -269,7 +277,9 @@ class WidgetPreviewGroupWidget extends StatelessWidget { data: ListTileTheme.of(context).copyWith( dense: true, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.all(_kCardRadius), + borderRadius: BorderRadius.all( + WidgetPreviewGroupWidget._kCardRadius, + ), ), ), child: Theme( @@ -277,20 +287,31 @@ class WidgetPreviewGroupWidget extends StatelessWidget { // expanded ExpansionTile. data: theme.copyWith(dividerColor: Colors.transparent), child: ExpansionTile( - key: PageStorageKey(group.name), - title: Text(group.name), + key: PageStorageKey(widget.group.name), + title: Text(widget.group.name), initiallyExpanded: true, children: [ - ValueListenableBuilder( - valueListenable: controller.layoutTypeListenable, - builder: (context, selectedLayout, _) { - return switch (selectedLayout) { - LayoutType.gridView => _buildGridViewFlex(group.previews), - LayoutType.listView => _buildVerticalListView( - group.previews, - ), - }; - }, + // Wrap children in a PageStorage to create a storage boundary. + // Without this, descendant scrollables (which search ancestor + // elements for a PageStorageKey when restoring scroll offset) + // inherit the PageStorageKey from the ExpansionTile and attempt + // to read the ExpansionTile's boolean expansion state as a double, + // throwing a TypeError (see https://github.com/flutter/flutter/issues/191242). + PageStorage( + bucket: _bucket, + child: ValueListenableBuilder( + valueListenable: widget.controller.layoutTypeListenable, + builder: (context, selectedLayout, _) { + return switch (selectedLayout) { + LayoutType.gridView => _buildGridViewFlex( + widget.group.previews, + ), + LayoutType.listView => _buildVerticalListView( + widget.group.previews, + ), + }; + }, + ), ), ], ), diff --git a/engine/src/flutter/flow/layers/layer_tree.cc b/engine/src/flutter/flow/layers/layer_tree.cc index bfdb419a65551..f125e93e4de12 100644 --- a/engine/src/flutter/flow/layers/layer_tree.cc +++ b/engine/src/flutter/flow/layers/layer_tree.cc @@ -147,7 +147,8 @@ void LayerTree::Paint(CompositorContext::ScopedFrame& frame, sk_sp LayerTree::Flatten( const DlRect& bounds, const std::shared_ptr& texture_registry, - GrDirectContext* gr_context) { + GrDirectContext* gr_context, + impeller::AiksContext* aiks_context) { TRACE_EVENT0("flutter", "LayerTree::Flatten"); DisplayListBuilder builder(bounds); @@ -188,6 +189,8 @@ sk_sp LayerTree::Flatten( #if !SLIMPELLER .raster_cache = nullptr, #endif // !SLIMPELLER + .impeller_enabled = !!aiks_context, + .aiks_context = aiks_context, // clang-format on }; diff --git a/engine/src/flutter/flow/layers/layer_tree.h b/engine/src/flutter/flow/layers/layer_tree.h index 430f836ba4147..193e0c0af4e77 100644 --- a/engine/src/flutter/flow/layers/layer_tree.h +++ b/engine/src/flutter/flow/layers/layer_tree.h @@ -48,7 +48,8 @@ class LayerTree { sk_sp Flatten( const DlRect& bounds, const std::shared_ptr& texture_registry = nullptr, - GrDirectContext* gr_context = nullptr); + GrDirectContext* gr_context = nullptr, + impeller::AiksContext* aiks_context = nullptr); Layer* root_layer() const { return root_layer_.get(); } const DlISize& frame_size() const { return frame_size_; } diff --git a/engine/src/flutter/flow/layers/layer_tree_unittests.cc b/engine/src/flutter/flow/layers/layer_tree_unittests.cc index 7ae62534da49c..e03acb2b15712 100644 --- a/engine/src/flutter/flow/layers/layer_tree_unittests.cc +++ b/engine/src/flutter/flow/layers/layer_tree_unittests.cc @@ -12,6 +12,7 @@ #include "flutter/fml/macros.h" #include "flutter/testing/canvas_test.h" #include "flutter/testing/display_list_testing.h" +#include "gmock/gmock.h" #include "gtest/gtest.h" namespace flutter { @@ -277,5 +278,43 @@ TEST_F(LayerTreeTest, PaintContextInitialization) { expect_defaults(context); } +namespace { + +class MockFlattenLayer : public Layer { + public: + MockFlattenLayer() { set_paint_bounds(DlRect::MakeWH(100, 100)); } + + MOCK_METHOD(void, Preroll, (PrerollContext*), (override)); + MOCK_METHOD(void, Paint, (PaintContext&), (const, override)); +}; + +} // namespace + +TEST_F(LayerTreeTest, FlattenInitializesContexts) { + auto layer = std::make_shared(); + auto layer_tree = BuildLayerTree(layer); + + EXPECT_CALL(*layer, Preroll(::testing::_)).Times(2); + + // Default: null contexts + EXPECT_CALL(*layer, Paint(::testing::_)).WillOnce([](PaintContext& context) { + EXPECT_EQ(context.gr_context, nullptr); + EXPECT_EQ(context.aiks_context, nullptr); + EXPECT_FALSE(context.impeller_enabled); + }); + layer_tree->Flatten(DlRect::MakeWH(100, 100)); + + // With a fake AiksContext pointer + auto fake_aiks = reinterpret_cast(0x1234); + EXPECT_CALL(*layer, Paint(::testing::_)) + .WillOnce([fake_aiks](PaintContext& context) { + EXPECT_EQ(context.gr_context, nullptr); + EXPECT_EQ(context.aiks_context, fake_aiks); + EXPECT_TRUE(context.impeller_enabled); + }); + layer_tree->Flatten(DlRect::MakeWH(100, 100), /*texture_registry=*/nullptr, + /*gr_context=*/nullptr, fake_aiks); +} + } // namespace testing } // namespace flutter diff --git a/engine/src/flutter/impeller/compiler/switches.cc b/engine/src/flutter/impeller/compiler/switches.cc index 13369bc783659..ca845b57cd195 100644 --- a/engine/src/flutter/impeller/compiler/switches.cc +++ b/engine/src/flutter/impeller/compiler/switches.cc @@ -239,7 +239,7 @@ Switches::Switches(const fml::CommandLine& command_line) } auto dir = std::make_shared(fml::OpenDirectoryReadOnly( - *working_directory, include_dir_absolute.string().c_str())); + *working_directory, Utf8FromPath(include_dir_absolute).c_str())); if (!dir || !dir->is_valid()) { continue; } diff --git a/engine/src/flutter/impeller/display_list/aiks_dl_basic_unittests.cc b/engine/src/flutter/impeller/display_list/aiks_dl_basic_unittests.cc index 75814ba17d564..77055720d39d5 100644 --- a/engine/src/flutter/impeller/display_list/aiks_dl_basic_unittests.cc +++ b/engine/src/flutter/impeller/display_list/aiks_dl_basic_unittests.cc @@ -10,6 +10,7 @@ #include "display_list/effects/dl_image_filter.h" #include "display_list/effects/dl_mask_filter.h" #include "flutter/impeller/display_list/aiks_unittests.h" +#include "flutter/impeller/geometry/constants.h" #include "flutter/display_list/dl_blend_mode.h" #include "flutter/display_list/dl_builder.h" @@ -2765,5 +2766,52 @@ TEST_P(AiksTest, CompareDiffRoundRectAndRoundRect) { ASSERT_TRUE(OpenPlaygroundHere(builder.Build())); } +TEST_P(AiksTest, CanRenderLinesWithCapsAnglesAndAlphas) { + DisplayListBuilder builder; + builder.Scale(GetContentScale().x, GetContentScale().y); + builder.DrawColor(DlColor::kBlack(), DlBlendMode::kSrc); + + const float line_length = 30.0f; + const float stroke_width = 10.0f; + const float spacing = 50.0f; + + const std::vector caps = { + flutter::DlStrokeCap::kButt, + flutter::DlStrokeCap::kSquare, + flutter::DlStrokeCap::kRound, + }; + const std::vector alphas = {1.0f, 0.75f, 0.5f, 0.25f}; + const std::vector angles = { + Degrees(0.0f), Degrees(3.0f), Degrees(30.0f), Degrees(45.0f), + Degrees(60.0f), Degrees(87.0f), Degrees(90.0f), + }; + + DlPaint paint; + paint.setDrawStyle(DlDrawStyle::kStroke); + paint.setStrokeWidth(stroke_width); + + for (const DlStrokeCap cap : caps) { + builder.Translate(spacing, 0.0f); + paint.setStrokeCap(cap); + + for (const float alpha : alphas) { + builder.Translate(spacing, 0.0f); + paint.setColor(DlColor::kWhite().withAlphaF(alpha)); + + builder.Save(); + for (const Radians angle : angles) { + builder.Translate(0.0f, spacing); + builder.DrawLine(DlPoint(0.0f, 0.0f), + DlPoint(cos(angle.radians) * line_length, + sin(angle.radians) * line_length), + paint); + } + builder.Restore(); + } + } + + ASSERT_TRUE(OpenPlaygroundHere(builder.Build())); +} + } // namespace testing } // namespace impeller diff --git a/engine/src/flutter/impeller/display_list/aiks_dl_path_unittests.cc b/engine/src/flutter/impeller/display_list/aiks_dl_path_unittests.cc index e5a969360f646..0aef34a1439d0 100644 --- a/engine/src/flutter/impeller/display_list/aiks_dl_path_unittests.cc +++ b/engine/src/flutter/impeller/display_list/aiks_dl_path_unittests.cc @@ -261,88 +261,133 @@ TEST_P(AiksTest, CanRenderStrokedConicPaths) { ASSERT_TRUE(OpenPlaygroundHere(builder.Build())); } -TEST_P(AiksTest, HairlinePath) { +namespace { +using DrawLinesCallback = std::function; + +static void DrawLinesTest(AiksTest* test, const DrawLinesCallback& draw_fn) { Scalar scale = 1.f; Scalar rotation = 0.f; Scalar offset = 0.f; - Scalar width = 0.f; - auto callback = [&]() -> sk_sp { + Scalar width_adjustment = 0.f; + bool invert_colors = false; + + auto callback = [=]() mutable -> sk_sp { if (AiksTest::ImGuiBegin("Controls", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { ImGui::SliderFloat("Scale", &scale, 0, 6); ImGui::SliderFloat("Rotate", &rotation, 0, 90); ImGui::SliderFloat("Offset", &offset, 0, 2); - ImGui::SliderFloat("Width", &width, 0, 2); + ImGui::SliderFloat("Width Adjustment", &width_adjustment, -1, 2); + ImGui::Checkbox("Invert colors", &invert_colors); ImGui::End(); } DisplayListBuilder builder; - builder.Scale(GetContentScale().x, GetContentScale().y); - builder.DrawPaint(DlPaint(DlColor(0xff111111))); - DlPaint paint; - paint.setStrokeWidth(width); - paint.setColor(DlColor::kWhite()); - paint.setStrokeCap(DlStrokeCap::kRound); - paint.setStrokeJoin(DlStrokeJoin::kRound); - paint.setDrawStyle(DlDrawStyle::kStroke); - - builder.Translate(512, 384); - builder.Scale(scale, scale); - builder.Rotate(rotation); - builder.Translate(-512, -384 + offset); + // Most tests scale the builder with + // builder.Scale(GetContentScale().x, GetContentScale().y) in order to + // maintain the same visual size on different screens. + // These DrawLines tests explicitly don't invoke this scaling, because they + // are testing specific line widths relative to a device pixel size. - for (int i = 0; i < 5; ++i) { - Scalar yoffset = i * 25.25f + 300.f; - DlPathBuilder path_builder; + builder.DrawPaint( + DlPaint(invert_colors ? DlColor(0xffeeeeee) : DlColor(0xff111111))); - path_builder.MoveTo(DlPoint(100, yoffset)); - path_builder.LineTo(DlPoint(924, yoffset)); - builder.DrawPath(path_builder.TakePath(), paint); + DlPaint paint; + paint.setColor(invert_colors ? DlColor::kBlack() : DlColor::kWhite()); + + std::vector widths = {0.0f, 0.3f, 1.0f}; + std::vector angles = {0.0f, 3.0f, 45.0f, 87.0f, 90.0f}; + + const Scalar column_spacing = 1024.0f / widths.size(); + const Scalar row_spacing = 768.0f / angles.size(); + + for (size_t col = 0; col < widths.size(); ++col) { + Scalar col_x = (col + 0.5f) * column_spacing; + Scalar width = std::max(0.0f, widths[col] + width_adjustment); + for (size_t row = 0; row < angles.size(); ++row) { + Scalar row_y = (row + 0.5f) * row_spacing; + builder.Save(); + builder.Translate(col_x, row_y); + builder.Scale(scale, scale); + builder.Rotate(angles[row] + rotation); + + // Draw 4 parallel lines centered around y = 0. Each line is spaced 8px + // apart (-12, -4, +4, +12), plus a 0.25px increment per line to test + // fractional subpixel alignment (0.0, 0.25, 0.5, 0.75). + for (int i = 0; i < 4; ++i) { + Scalar base_y = i * 8.0f - 12.0f; + Scalar subpixel_y = i * 0.25f; + Scalar line_y = base_y + subpixel_y + offset; + Point p0 = Point(-60.0f, line_y); + Point p1 = Point(60.0f, line_y); + draw_fn(builder, paint, p0, p1, width); + } + + builder.Restore(); + } } return builder.Build(); }; - ASSERT_TRUE(OpenPlaygroundHere(callback)); + ASSERT_TRUE(test->OpenPlaygroundHere(callback)); +} +} // namespace + +TEST_P(AiksTest, DrawLinesWithPath) { + DrawLinesTest(this, [](DisplayListBuilder& builder, const DlPaint& paint, + Point p0, Point p1, Scalar width) { + DlPaint stroke_paint = paint; + stroke_paint.setDrawStyle(DlDrawStyle::kStroke); + stroke_paint.setStrokeWidth(width); + DlPathBuilder path_builder; + path_builder.MoveTo(p0); + path_builder.LineTo(p1); + builder.DrawPath(path_builder.TakePath(), stroke_paint); + }); } -TEST_P(AiksTest, HairlineDrawLine) { - Scalar scale = 1.f; - Scalar rotation = 0.f; - Scalar offset = 0.f; - auto callback = [&]() -> sk_sp { - if (AiksTest::ImGuiBegin("Controls", nullptr, - ImGuiWindowFlags_AlwaysAutoResize)) { - ImGui::SliderFloat("Scale", &scale, 0, 6); - ImGui::SliderFloat("Rotate", &rotation, 0, 90); - ImGui::SliderFloat("Offset", &offset, 0, 2); - ImGui::End(); - } - - DisplayListBuilder builder; - builder.Scale(GetContentScale().x, GetContentScale().y); - builder.DrawPaint(DlPaint(DlColor(0xff111111))); - - DlPaint paint; - paint.setStrokeWidth(0.f); - paint.setColor(DlColor::kWhite()); - - builder.Translate(512, 384); - builder.Scale(scale, scale); - builder.Rotate(rotation); - builder.Translate(-512, -384 + offset); - - for (int i = 0; i < 5; ++i) { - Scalar yoffset = i * 25.25f + 300.f; - - builder.DrawLine(DlPoint(100, yoffset), DlPoint(924, yoffset), paint); - } +TEST_P(AiksTest, DrawLinesWithDrawLine) { + DrawLinesTest(this, [](DisplayListBuilder& builder, const DlPaint& paint, + Point p0, Point p1, Scalar width) { + DlPaint stroke_paint = paint; + stroke_paint.setDrawStyle(DlDrawStyle::kStroke); + stroke_paint.setStrokeWidth(width); + builder.DrawLine(p0, p1, stroke_paint); + }); +} - return builder.Build(); - }; +TEST_P(AiksTest, DrawLinesWithFilledRects) { + DrawLinesTest(this, [](DisplayListBuilder& builder, const DlPaint& paint, + Point p0, Point p1, Scalar width) { + DlPaint fill_paint = paint; + fill_paint.setDrawStyle(DlDrawStyle::kFill); + Scalar length = p0.GetDistance(p1); + Point center = {(p0.x + p1.x) * 0.5f, (p0.y + p1.y) * 0.5f}; + builder.DrawRect( + DlRect::MakeEllipseBounds(center, Size(length, width) * 0.5f), + fill_paint); + }); +} - ASSERT_TRUE(OpenPlaygroundHere(callback)); +TEST_P(AiksTest, DrawLinesWithFilledRoundRects) { + DrawLinesTest(this, [](DisplayListBuilder& builder, const DlPaint& paint, + Point p0, Point p1, Scalar width) { + DlPaint fill_paint = paint; + fill_paint.setDrawStyle(DlDrawStyle::kFill); + Scalar length = p0.GetDistance(p1); + Point center = {(p0.x + p1.x) * 0.5f, (p0.y + p1.y) * 0.5f}; + builder.DrawRoundRect( + DlRoundRect::MakeRectRadius( + DlRect::MakeEllipseBounds(center, Size(length, width) * 0.5f), + width * 0.5f), + fill_paint); + }); } TEST_P(AiksTest, CanRenderTightConicPath) { diff --git a/engine/src/flutter/impeller/display_list/canvas.cc b/engine/src/flutter/impeller/display_list/canvas.cc index 0b2e35926bf85..e4496d8a4de71 100644 --- a/engine/src/flutter/impeller/display_list/canvas.cc +++ b/engine/src/flutter/impeller/display_list/canvas.cc @@ -193,6 +193,38 @@ static std::unique_ptr CreateRenderTarget( ); } +/// @brief Expands the rectangle to satisfy a 1-device-pixel minimum size using +/// the given transform and scales alpha based on the ratio of local +/// areas when `scale_alpha` is true. +/// +/// If the shape is scaled to zero under the transform or if its alpha +/// scales close to zero, an empty `Rect` is returned to indicate that +/// the shape is effectively invisible. +static std::pair ExpandRectToPixelMinimum(const Rect& rect, + const Color& color, + const Matrix& transform, + bool scale_alpha) { + std::optional expanded = + rect.ExpandToMinTransformedSize({1.0f, 1.0f}, transform); + if (!expanded) { + // Rect is scaled to 0. + return {Rect(), color}; + } + + // No alpha scaling needed. + if (!scale_alpha) { + return {expanded.value(), color}; + } + + // Scale alpha based on expanded ratio. + Scalar alpha_scaling = rect.Area() / expanded->Area(); + if (alpha_scaling < kEhCloseEnough) { + // Rect is effectively invisible. + return {Rect(), color}; + } + return {expanded.value(), color.WithAlpha(color.alpha * alpha_scaling)}; +} + } // namespace class Canvas::RRectBlurShape : public BlurShape { @@ -774,27 +806,110 @@ bool Canvas::AttemptDrawBlur(BlurShape& shape, const Paint& paint) { return true; } +bool Canvas::AttemptDrawLineSDF(const Point& p0, + const Point& p1, + const Paint& paint, + bool reuse_depth) { + if (!(renderer_.GetContext()->GetFlags().use_sdfs || + renderer_.GetContext()->GetFlags().antialiased_lines) || + !IsCompatibleWithSDFRendering(paint)) { + return false; + } + // Draw the line as a filled rectangle with width=line_length and + // height=stroke_width. + + Paint rect_paint = paint; + rect_paint.style = Paint::Style::kFill; + + Scalar line_length = p0.GetDistance(p1); + if (line_length == 0.0f && paint.stroke.cap == Cap::kButt) { + // 0 length line with butt caps is invisible. + return true; + } + Scalar half_stroke_width = paint.stroke.width * 0.5f; + Scalar half_length = line_length * 0.5f; + + // For Butt stroke caps, the rect width is line_length. For Square and Round + // stroke caps, the rect extends past the line's endpoints by + // half_stroke_width at each end. + if (paint.stroke.cap != Cap::kButt) { + half_length += half_stroke_width; + } + + // The axis-aligned origin-centered rect which the line will be drawn as. + Rect rect = Rect::MakeEllipseBounds(Point(0.0f, 0.0f), + Point(half_length, half_stroke_width)); + + // A transform matrix is used to rotate and translate the rect to match the + // position of the input line. + + // Unit vector along the line. Fallback to (1, 0) if length is 0. + Vector2 u = + line_length > 0.0f ? ((p1 - p0) / line_length) : Point(1.0f, 0.0f); + Vector2 perp = u.PerpendicularRight(); + Point center = (p0 + p1) * 0.5f; + Matrix rect_to_line_transform = Matrix::MakeColumn( + // X basis: unit vector along the line + u.x, u.y, 0.0f, 0.0f, + // Y basis: unit vector perpendicular to the line + perp.x, perp.y, 0.0f, 0.0f, + // Z basis: unchanged + 0.0f, 0.0f, 1.0f, 0.0f, + // Translation: to line center + center.x, center.y, 0.0f, 1.0f); + + // Expand rect to 1 pixel minimum dimensions if applicable. + if (!GetCurrentTransform().HasPerspective2D()) { + auto [expanded, alpha_scaled_color] = ExpandRectToPixelMinimum( + rect, paint.color, GetCurrentTransform() * rect_to_line_transform, + // Don't scale alpha when stroke width is 0. This draws a hairline that + // is always 1 pixel regardless of the transform. + /*scale_alpha=*/paint.stroke.width != 0.0f); + + if (expanded.IsEmpty()) { + // Line is invisible due to transform scaling or alpha scaling. + return true; + } + + rect_paint.color = alpha_scaled_color; + rect = expanded; + } + + UberSDFParameters params; + if (paint.stroke.cap == Cap::kRound) { + params = UberSDFParameters::MakeRoundedRect( + /*color=*/rect_paint.color, + /*rect=*/rect, + /*radii=*/ + RoundingRadii::MakeRadius(rect.GetHeight() * 0.5f), + /*stroke=*/std::nullopt); + } else { + params = UberSDFParameters::MakeRect( + /*color=*/rect_paint.color, + /*rect=*/rect, + /*stroke=*/std::nullopt); + } + AddRenderSDFEntityToCurrentPass(paint, params, reuse_depth, + /*shape_transform=*/rect_to_line_transform); + return true; +} + void Canvas::DrawLine(const Point& p0, const Point& p1, const Paint& paint, bool reuse_depth) { + if (AttemptDrawLineSDF(p0, p1, paint, reuse_depth)) { + return; + } + Entity entity; entity.SetTransform(GetCurrentTransform()); entity.SetBlendMode(paint.blend_mode); auto geometry = std::make_unique(p0, p1, paint.stroke); - if ((renderer_.GetContext()->GetFlags().antialiased_lines || - renderer_.GetContext()->GetFlags().use_sdfs) && - !paint.color_filter && !paint.invert_colors && !paint.image_filter && - !paint.mask_blur_descriptor.has_value() && !paint.color_source) { - auto contents = LineContents::Make(std::move(geometry), paint.color); - entity.SetContents(std::move(contents)); - AddRenderEntityToCurrentPass(entity, reuse_depth); - } else { - AddRenderEntityWithFiltersToCurrentPass(entity, geometry.get(), paint, - /*reuse_depth=*/reuse_depth); - } + AddRenderEntityWithFiltersToCurrentPass(entity, geometry.get(), paint, + reuse_depth); } void Canvas::DrawDashedLine(const Point& p0, @@ -824,6 +939,10 @@ void Canvas::DrawDashedLine(const Point& p0, } void Canvas::DrawRect(const Rect& rect, const Paint& paint) { + if (paint.style == Paint::Style::kFill && rect.IsEmpty()) { + return; + } + if (IsShadowBlurDrawOperation(paint)) { RRectBlurShape shape(rect, 0.0f); if (AttemptDrawBlur(shape, paint)) { @@ -833,9 +952,27 @@ void Canvas::DrawRect(const Rect& rect, const Paint& paint) { if (renderer_.GetContext()->GetFlags().use_sdfs && IsCompatibleWithSDFRendering(paint)) { + Rect effective_rect = rect; + Color effective_color = paint.color; + + // Expand rect to 1 pixel minimum dimensions if applicable. + if (paint.style == Paint::Style::kFill && + !GetCurrentTransform().HasPerspective2D()) { + auto [expanded, alpha_scaled_color] = ExpandRectToPixelMinimum( + rect, paint.color, GetCurrentTransform(), /*scale_alpha=*/true); + + if (expanded.IsEmpty()) { + // Rect is invisible due to transform scaling or alpha scaling. + return; + } + + effective_rect = expanded; + effective_color = alpha_scaled_color; + } + auto params = UberSDFParameters::MakeRect( - /*color=*/paint.color, - /*rect=*/rect, + /*color=*/effective_color, + /*rect=*/effective_rect, /*stroke=*/paint.GetStroke()); AddRenderSDFEntityToCurrentPass(paint, params); return; @@ -962,6 +1099,10 @@ void Canvas::DrawArc(const Arc& arc, const Paint& paint) { } void Canvas::DrawRoundRect(const RoundRect& round_rect, const Paint& paint) { + if (paint.style == Paint::Style::kFill && round_rect.IsEmpty()) { + return; + } + if (IsShadowBlurDrawOperation(paint)) { if (AttemptDrawBlurredRRect(round_rect, paint)) { return; @@ -972,9 +1113,27 @@ void Canvas::DrawRoundRect(const RoundRect& round_rect, const Paint& paint) { if (renderer_.GetContext()->GetFlags().use_sdfs && IsCompatibleWithSDFRendering(paint) && radii.AreAllCornersCircular()) { + Color effective_color = paint.color; + Rect bounds = round_rect.GetBounds(); + + // Expand rrect bounds to 1 pixel minimum dimensions if applicable. + if (paint.style == Paint::Style::kFill && + !GetCurrentTransform().HasPerspective2D()) { + auto [expanded, alpha_scaled_color] = ExpandRectToPixelMinimum( + bounds, paint.color, GetCurrentTransform(), /*scale_alpha=*/true); + + if (expanded.IsEmpty()) { + // RRect is invisible due to transform scaling or alpha scaling. + return; + } + + bounds = expanded; + effective_color = alpha_scaled_color; + } + auto params = UberSDFParameters::MakeRoundedRect( - /*color=*/paint.color, - /*rect=*/round_rect.GetBounds(), + /*color=*/effective_color, + /*rect=*/bounds, /*radii=*/radii, /*stroke=*/paint.style == Paint::Style::kStroke ? std::make_optional(paint.stroke) @@ -2009,10 +2168,18 @@ void Canvas::DrawTextFrame(const std::shared_ptr& text_frame, AddRenderEntityToCurrentPass(entity, false); } -void Canvas::AddRenderSDFEntityToCurrentPass(const Paint& paint, - UberSDFParameters params) { +void Canvas::AddRenderSDFEntityToCurrentPass( + const Paint& paint, + UberSDFParameters params, + bool reuse_depth, + const std::optional& shape_transform) { + Matrix transform = GetCurrentTransform(); + if (shape_transform.has_value()) { + transform = transform * shape_transform.value(); + } + Entity entity; - entity.SetTransform(GetCurrentTransform()); + entity.SetTransform(transform); entity.SetBlendMode(paint.blend_mode); if (paint.color_source) { @@ -2028,8 +2195,8 @@ void Canvas::AddRenderSDFEntityToCurrentPass(const Paint& paint, if (paint.color_source) { // UberSDF doesn't perform things like gradients so we blend the SDF // with the color source. - std::shared_ptr color_source_contents = - paint.CreateContents(renderer_, geom); + std::shared_ptr color_source_contents = + paint.CreateContents(renderer_, geom, shape_transform); std::shared_ptr final_contents = ColorFilterContents::MakeBlend( BlendMode::kSrcIn, {FilterInput::Make(std::move(contents)), FilterInput::Make(color_source_contents)}); @@ -2037,12 +2204,11 @@ void Canvas::AddRenderSDFEntityToCurrentPass(const Paint& paint, Paint new_paint = paint; new_paint.color_source = nullptr; AddRenderEntityWithFiltersToCurrentPass(entity, geom, new_paint, - /*reuse_depth=*/false, + reuse_depth, /*override_contents=*/ std::move(final_contents)); } else { - AddRenderEntityWithFiltersToCurrentPass(entity, geom, paint, - /*reuse_depth=*/false, + AddRenderEntityWithFiltersToCurrentPass(entity, geom, paint, reuse_depth, /*override_contents=*/ std::move(contents)); } diff --git a/engine/src/flutter/impeller/display_list/canvas.h b/engine/src/flutter/impeller/display_list/canvas.h index edfe0f4798349..5647921032b12 100644 --- a/engine/src/flutter/impeller/display_list/canvas.h +++ b/engine/src/flutter/impeller/display_list/canvas.h @@ -382,8 +382,20 @@ class Canvas { bool reuse_depth = false, std::shared_ptr override_contents = nullptr); - void AddRenderSDFEntityToCurrentPass(const Paint& paint, - UberSDFParameters params); + /// @brief Adds a rendering entity using the UberSDF pipeline + /// to the current render pass. + /// + /// @param paint The paint style to apply. + /// @param params The SDF parameters for the shape. + /// @param reuse_depth Whether to reuse the current depth value or + /// allocate a new depth layer. + /// @param shape_transform An optional transform applied to the shape + /// relative to the current canvas transform. + void AddRenderSDFEntityToCurrentPass( + const Paint& paint, + UberSDFParameters params, + bool reuse_depth = false, + const std::optional& shape_transform = std::nullopt); void AddRenderEntityToCurrentPass(Entity& entity, bool reuse_depth = false); @@ -391,6 +403,11 @@ class Canvas { /// operation. static bool IsShadowBlurDrawOperation(const Paint& paint); + bool AttemptDrawLineSDF(const Point& p0, + const Point& p1, + const Paint& paint, + bool reuse_depth); + bool AttemptDrawAntialiasedCircle(const Point& center, Scalar radius, const Paint& paint); diff --git a/engine/src/flutter/impeller/display_list/paint.cc b/engine/src/flutter/impeller/display_list/paint.cc index c46b2de697747..24b0afdc715ed 100644 --- a/engine/src/flutter/impeller/display_list/paint.cc +++ b/engine/src/flutter/impeller/display_list/paint.cc @@ -63,13 +63,17 @@ void Paint::ConvertStops(const flutter::DlGradientColorSourceBase* gradient, std::shared_ptr Paint::CreateContents( const ContentContext& renderer, - const Geometry* geometry) const { + const Geometry* geometry, + const std::optional& geometry_transform) const { if (color_source == nullptr) { auto contents = std::make_shared(geometry); contents->SetColor(color); return contents; } + Matrix inverted_geometry_transform = + geometry_transform.has_value() ? geometry_transform->Invert() : Matrix(); + switch (color_source->type()) { case flutter::DlColorSourceType::kLinearGradient: { const flutter::DlLinearGradientColorSource* linear = @@ -90,7 +94,8 @@ std::shared_ptr Paint::CreateContents( contents->SetStops(std::move(stops)); contents->SetEndPoints(start_point, end_point); contents->SetTileMode(tile_mode); - contents->SetEffectTransform(effect_transform); + contents->SetEffectTransform(inverted_geometry_transform * + effect_transform); std::array bounds{start_point, end_point}; auto intrinsic_size = Rect::MakePointBounds(bounds.begin(), bounds.end()); @@ -119,7 +124,8 @@ std::shared_ptr Paint::CreateContents( contents->SetStops(std::move(stops)); contents->SetCenterAndRadius(center, radius); contents->SetTileMode(tile_mode); - contents->SetEffectTransform(effect_transform); + contents->SetEffectTransform(inverted_geometry_transform * + effect_transform); auto intrinsic_size = Rect::MakeCircleBounds(center, std::abs(radius)); contents->SetColorSourceSize(intrinsic_size.GetSize().Max({1, 1})); @@ -147,7 +153,8 @@ std::shared_ptr Paint::CreateContents( contents->SetStops(std::move(stops)); contents->SetCenterAndRadius(center, radius); contents->SetTileMode(tile_mode); - contents->SetEffectTransform(effect_transform); + contents->SetEffectTransform(inverted_geometry_transform * + effect_transform); contents->SetFocus(focus_center, focus_radius); auto intrinsic_size = Rect::MakeCircleBounds(center, std::abs(radius)); @@ -176,7 +183,8 @@ std::shared_ptr Paint::CreateContents( contents->SetColors(std::move(colors)); contents->SetStops(std::move(stops)); contents->SetTileMode(tile_mode); - contents->SetEffectTransform(effect_transform); + contents->SetEffectTransform(inverted_geometry_transform * + effect_transform); return contents; } @@ -202,7 +210,8 @@ std::shared_ptr Paint::CreateContents( contents->SetTexture(texture); contents->SetTileModes(x_tile_mode, y_tile_mode); contents->SetSamplerDescriptor(sampler_descriptor); - contents->SetEffectTransform(effect_transform); + contents->SetEffectTransform(inverted_geometry_transform * + effect_transform); if (color_filter || invert_colors) { TiledTextureContents::ColorFilterProc filter_proc = [color_filter = color_filter, diff --git a/engine/src/flutter/impeller/display_list/paint.h b/engine/src/flutter/impeller/display_list/paint.h index 099cdf01f7f01..55a3c878f7f13 100644 --- a/engine/src/flutter/impeller/display_list/paint.h +++ b/engine/src/flutter/impeller/display_list/paint.h @@ -120,9 +120,22 @@ struct Paint { /// @brief Whether this paint has a color filter that can apply opacity bool HasColorFilter() const; + /// @brief Create a `ColorSourceContents` representing this paint's + /// shader/colors. + /// @param[in] renderer The content context renderer. + /// @param[in] geometry The geometry the color source is drawn + /// onto. + /// @param[in] geometry_transform An optional transform representing a + /// layout/positioning transform applied to + /// the geometry. If provided, this is used to + /// adjust the color source's effect transform + /// to align it with the geometry's coordinate + /// space. + /// @return The generated color source contents. std::shared_ptr CreateContents( const ContentContext& renderer, - const Geometry* geometry) const; + const Geometry* geometry, + const std::optional& geometry_transform = std::nullopt) const; std::shared_ptr WithMaskBlur(std::shared_ptr input, bool is_solid_color, diff --git a/engine/src/flutter/impeller/entity/contents/color_source_contents.h b/engine/src/flutter/impeller/entity/contents/color_source_contents.h index 27ff860425204..956548d385abf 100644 --- a/engine/src/flutter/impeller/entity/contents/color_source_contents.h +++ b/engine/src/flutter/impeller/entity/contents/color_source_contents.h @@ -68,7 +68,7 @@ class ColorSourceContents : public Contents { void SetEffectTransform(Matrix matrix); //---------------------------------------------------------------------------- - /// @brief Set the inverted effect transform for this color source. + /// @brief Get the inverted effect transform for this color source. /// /// When the effect transform is set via `SetEffectTransform`, the /// value is inverted upon storage. The reason for this is that most diff --git a/engine/src/flutter/impeller/entity/contents/content_context.cc b/engine/src/flutter/impeller/entity/contents/content_context.cc index d9f51c018d503..f16ddc2e1f35a 100644 --- a/engine/src/flutter/impeller/entity/contents/content_context.cc +++ b/engine/src/flutter/impeller/entity/contents/content_context.cc @@ -637,7 +637,8 @@ ContentContext::ContentContext( pipelines_->fast_gradient.CreateDefault(*context_, options); pipelines_->line.CreateDefault(*context_, options); pipelines_->circle.CreateDefault(*context_, options); - if (context_->GetFlags().use_sdfs) { + if (context_->GetFlags().use_sdfs || + context_->GetFlags().antialiased_lines) { pipelines_->uber_sdf.CreateDefault(*context_, options); pipelines_->complex_rse.CreateDefault(*context_, options); } diff --git a/engine/src/flutter/impeller/entity/shaders/uber_sdf.frag b/engine/src/flutter/impeller/entity/shaders/uber_sdf.frag index d15ea608803e8..97a5f5b715928 100644 --- a/engine/src/flutter/impeller/entity/shaders/uber_sdf.frag +++ b/engine/src/flutter/impeller/entity/shaders/uber_sdf.frag @@ -135,31 +135,96 @@ float distanceFromRoundedSuperellipse(vec2 p, return sdSuperellipse(p_oct / axis_length, se_degree) * axis_length; } +// Special case pixel size calculation for rectangles. The standard `pixelSize` +// function uses SDF derivatives, which gives invalid results for very small +// shapes, where adjacent device pixels span across opposing edges of the shape. +// This function calculates pixel size for rectangles without using SDF +// derivatives. +float rectPixelSize(vec2 p) { + // The change in local coordinates per horizontal device pixel (device_dx) + // and vertical device pixel (device_dy). + vec2 device_dx = dFdx(v_position); + vec2 device_dy = dFdy(v_position); + // The size of a device pixel in terms of local coordinates. + vec2 device_pixel_size = vec2(length(vec2(device_dx.x, device_dy.x)), + length(vec2(device_dx.y, device_dy.y))); + + // Get pixel size in the direction perpendicular to the closest edge of the + // rectangle: device_pixel_size.x when closer to a vertical edge, and + // pixel_size.y when closer to a horizontal edge. + vec2 distance = abs(abs(p) - frag_info.size); + return (distance.x < distance.y) ? device_pixel_size.x : device_pixel_size.y; +} + +// Special case pixel size calculation for rounded rectangles, similar to +// `rectPixelSize` for regular rectangles. +float roundRectPixelSize(vec2 p) { + // The change in local coordinates per horizontal device pixel (device_dx) + // and vertical device pixel (device_dy). + vec2 device_dx = dFdx(v_position); + vec2 device_dy = dFdy(v_position); + // The size of a device pixel in terms of local coordinates. + vec2 device_pixel_size = vec2(length(vec2(device_dx.x, device_dy.x)), + length(vec2(device_dx.y, device_dy.y))); + + // Select the corner radius for the quadrant of p. + vec4 r = frag_info.radii; + r.xy = (p.x > 0.0) ? r.xy : r.zw; + float radius = (p.y > 0.0) ? r.x : r.y; + + // Vector from corner circle center to abs(p). + vec2 corner_center = frag_info.size - radius; + vec2 q = abs(p) - corner_center; + + float pixel_size; + // If in the rounded corner arc, blend X and Y pixel sizes along the normal. + if (q.x > 0.0 && q.y > 0.0) { + pixel_size = length(normalize(q) * device_pixel_size); + } else { + // Otherwise, we are closer to a straight edge. Get pixel size in the + // direction perpendicular to the closer edge. + pixel_size = (q.x > q.y) ? device_pixel_size.x : device_pixel_size.y; + } + return pixel_size; +} + float pixelSize(float sdf) { vec2 gradient = vec2(dFdx(sdf), dFdy(sdf)); return length(gradient); } +// Evaluates the SDF for the shape selected by frag_info.type. +// Returns vec2(sdf, pixel_size). vec2 filledSDF(vec2 p) { float sdf; + float pixel_size; if (frag_info.type < 0.5) { // Circle sdf = distanceFromCircle(p, frag_info.size.x); + pixel_size = pixelSize(sdf); } else if (frag_info.type < 1.5) { // Rect sdf = distanceFromRect(p, frag_info.size); + // Rect has its own separate logic for calculating pixel size. + pixel_size = rectPixelSize(p); } else if (frag_info.type < 2.5) { // Oval sdf = distanceFromOval(p, frag_info.size); + pixel_size = pixelSize(sdf); } else if (frag_info.type < 3.5) { // Rounded Rect sdf = distanceFromRoundedRect(p, frag_info.size, frag_info.radii); + // RoundRect has its own separate logic for calculating pixel size. + pixel_size = roundRectPixelSize(p); } else { // Symmetric Rounded Superellipse sdf = distanceFromRoundedSuperellipse( p, frag_info.superellipse_degree, frag_info.superellipse_semi_axis, frag_info.radii.xy, frag_info.angle_span, frag_info.circle_center_top, frag_info.circle_center_right, frag_info.octant_offset_c, frag_info.superellipse_scale); + pixel_size = pixelSize(sdf); } - return vec2(sdf, pixelSize(sdf)); + return vec2(sdf, pixel_size); } +// Evaluates the stroked SDF for the shape selected by frag_info.type. +// Returns vec2(sdf, pixel_size). vec2 strokedSDF(vec2 p) { vec2 base_sdf_and_pixel_size = filledSDF(p); float base_sdf = base_sdf_and_pixel_size.x; @@ -167,23 +232,47 @@ vec2 strokedSDF(vec2 p) { float half_stroke = max(frag_info.stroke_width, base_pixel_size) * 0.5; - if (frag_info.type >= 0.5 && frag_info.type < 1.5) { // Rect - - if (frag_info.stroke_join < 0.5) { // Miter - float outer = distanceFromRect(p, frag_info.size + half_stroke); - float inner = base_sdf + half_stroke; - float sdf = max(outer, -inner); - return vec2(sdf, pixelSize(sdf)); - } else if (frag_info.stroke_join < 1.5) { // Bevel - float outer = - distanceFromChamferRect(p, frag_info.size + half_stroke, half_stroke); - float inner = base_sdf + half_stroke; - float sdf = max(outer, -inner); - return vec2(sdf, pixelSize(sdf)); - } + float sdf; + float pixel_size; + if (frag_info.type >= 0.5 && frag_info.type < 1.5 && + frag_info.stroke_join < 0.5) { + // Rect with Miter join + float outer = distanceFromRect(p, frag_info.size + half_stroke); + float inner = base_sdf + half_stroke; + sdf = max(outer, -inner); + pixel_size = pixelSize(sdf); + } else if (frag_info.type >= 0.5 && frag_info.type < 1.5 && + frag_info.stroke_join >= 0.5 && frag_info.stroke_join < 1.5) { + // Rect with Bevel join + float outer = + distanceFromChamferRect(p, frag_info.size + half_stroke, half_stroke); + float inner = base_sdf + half_stroke; + sdf = max(outer, -inner); + pixel_size = pixelSize(sdf); + } else { + // All other shapes + vec2 sdf_and_pixel_size = + SDFStroke(base_sdf, base_pixel_size, frag_info.stroke_width); + sdf = sdf_and_pixel_size.x; + pixel_size = sdf_and_pixel_size.y; } + return vec2(sdf, pixel_size); +} + +// Converts linear coverage alpha to perceptual alpha. +float gammaCorrectedAlpha(float alpha, vec3 foreground_rgb) { + // Gamma corrected alpha used for dark colors. + // Fast approximation for `1.0 - pow(1.0 - alpha, 1.0 / 2.2)`. + float alpha_dark = 1.0 - sqrt(1.0 - alpha); + + // Gamma corrected alpha used for light colors. + // Fast approximation for `pow(alpha, 1.0 / 2.2)`. + float alpha_light = sqrt(alpha); - return SDFStroke(base_sdf, base_pixel_size, frag_info.stroke_width); + // Interpolate between the dark and light gamma corrected alphas based on the + // foreground luma. + float luma = dot(foreground_rgb, vec3(0.2126, 0.7152, 0.0722)); + return mix(alpha_dark, alpha_light, luma); } void main() { @@ -195,6 +284,10 @@ void main() { float pixel_size = sdf_and_pixel_size.y; float alpha = SDFAlpha(sdf, pixel_size, frag_info.aa_pixels); + // Clamp alpha in case floating point precision errors cause it to be outside + // [0.0, 1.0]. + alpha = clamp(alpha, 0.0, 1.0); + alpha = gammaCorrectedAlpha(alpha, frag_info.color.rgb); frag_color = vec4(frag_info.color.rgb, frag_info.color.a * alpha); frag_color = IPPremultiply(frag_color); diff --git a/engine/src/flutter/impeller/geometry/rect.h b/engine/src/flutter/impeller/geometry/rect.h index 9756130668491..084e4113a374a 100644 --- a/engine/src/flutter/impeller/geometry/rect.h +++ b/engine/src/flutter/impeller/geometry/rect.h @@ -696,6 +696,41 @@ struct TRect { return Expand(amount.width, amount.height); } + /// @brief Returns a rectangle with edges expanded to satisfy a minimum size + /// in a 2D-transformed coordinate space. This function is not valid + /// for a matrix with perspective elements. + /// + /// If the transform has a scaling factor of zero in either dimension, + /// it cannot be expanded to the minimum size. This returns nullopt. + [[nodiscard]] constexpr std::optional> ExpandToMinTransformedSize( + TSize min_transformed_size, + const Matrix& transform) const { + FML_DCHECK(!transform.HasPerspective2D()); + + Vector2 transform_scaling = transform.GetBasisScaleXY(); + if (transform_scaling.x == 0.0f || transform_scaling.y == 0.0f) { + return std::nullopt; + } + + Size min_local_size = + Size(min_transformed_size.width / transform_scaling.x, + min_transformed_size.height / transform_scaling.y); + if (!min_local_size.IsFinite()) { + return std::nullopt; + } + Size current_size = GetSize(); + + if (current_size.width >= min_local_size.width && + current_size.height >= min_local_size.height) { + // No expansion needed. + return *this; + } + + Size expanded_size = current_size.Max(min_local_size); + return MakeEllipseBounds(GetCenter(), Point(expanded_size.width * 0.5f, + expanded_size.height * 0.5f)); + } + /// @brief Returns a new rectangle that represents the projection of the /// source rectangle onto this rectangle. In other words, the source /// rectangle is redefined in terms of the coordinate space of this diff --git a/engine/src/flutter/impeller/geometry/rect_unittests.cc b/engine/src/flutter/impeller/geometry/rect_unittests.cc index cf99cbc20758d..b8362c24eaf97 100644 --- a/engine/src/flutter/impeller/geometry/rect_unittests.cc +++ b/engine/src/flutter/impeller/geometry/rect_unittests.cc @@ -1437,6 +1437,119 @@ TEST(RectTest, IRectExpand) { EXPECT_EQ(rect.Expand(ISize{-10, -10}), IRect::MakeLTRB(110, 110, 190, 190)); } +TEST(RectTest, RectExpandToMinTransformedSizeNullForScaledToZero) { + auto rect = Rect(); + auto transform = Matrix::MakeScale(Vector3(0.0f, 1.0f)); + EXPECT_EQ(rect.ExpandToMinTransformedSize({1.0f, 1.0f}, transform), + std::nullopt); +} + +TEST(RectTest, RectExpandToMinTransformedSizeReturnsUnmodifiedWhenLargeEnough) { + Rect rect = Rect::MakeXYWH(0, 0, 10, 20); + auto transform = Matrix::MakeScale(Vector3(0.5f, 0.5f)); + EXPECT_EQ(rect.ExpandToMinTransformedSize({1.0f, 1.0f}, transform), rect); +} + +TEST( + RectTest, + RectExpandToMinTransformedSizeReturnsUnmodifiedWhenLargeEnoughWithFractionalSize) { + // Regression test for https://github.com/flutter/flutter/issues/189807. + Rect rect = Rect::MakeXYWH(100.0f, 50.0f, 64.2f, 40.0f); + auto transform = Matrix(); + EXPECT_EQ(rect.ExpandToMinTransformedSize({1.0f, 1.0f}, transform), rect); +} + +TEST(RectTest, RectExpandToMinTransformedSizeRectWithIdentityTransform) { + Size size = Size(2.0f, 2.0f); + Rect rect = Rect::MakeEllipseBounds(Point(), size * 0.5f); + + auto transform = Matrix(); + + // Expand to a width and height less than the original size. + { + auto expanded = rect.ExpandToMinTransformedSize({0.5f, 0.75f}, transform); + ASSERT_TRUE(expanded.has_value()); + if (expanded.has_value()) { + EXPECT_EQ(expanded.value().GetSize(), size); + } + } + + // Expand to a minimum width. Minimum height is less than the original size. + { + auto expanded = rect.ExpandToMinTransformedSize({4.0f, 1.5f}, transform); + ASSERT_TRUE(expanded.has_value()); + if (expanded.has_value()) { + EXPECT_EQ(expanded.value().GetSize(), Size(4.0f, 2.0f)); + } + } + + // Expand to a minimum height. Minimum width is less than the original size. + { + auto expanded = rect.ExpandToMinTransformedSize({1.5f, 4.0f}, transform); + ASSERT_TRUE(expanded.has_value()); + if (expanded.has_value()) { + EXPECT_EQ(expanded.value().GetSize(), Size(2.0f, 4.0f)); + } + } + + // Expand to a minimum width and height. + { + auto expanded = rect.ExpandToMinTransformedSize({3.0f, 4.0f}, transform); + ASSERT_TRUE(expanded.has_value()); + if (expanded.has_value()) { + EXPECT_EQ(expanded.value().GetSize(), Size(3.0f, 4.0f)); + } + } +} + +TEST(RectTest, RectExpandToMinTransformedSizeRectWithScalingTransform) { + Size size = Size(2.0f, 2.0f); + Rect rect = Rect::MakeEllipseBounds(Point(), size * 0.5f); + + // Scale by 2x in the X direction and 3x in the Y direction. + // Transformed rect size is (4.0, 6.0). + auto transform = Matrix::MakeScale(Vector3(2.0f, 3.0f)); + + // Expand to a transformed width and height less than the transformed size of + // the original rectangle. + { + auto expanded = rect.ExpandToMinTransformedSize({3.0f, 4.0f}, transform); + ASSERT_TRUE(expanded.has_value()); + if (expanded.has_value()) { + EXPECT_EQ(expanded.value().GetSize(), size); + } + } + + // Expand to 5.0 transformed width. + // This is equal to 5.0 / 2.0 = 2.5 local width. + { + auto expanded = rect.ExpandToMinTransformedSize({5.0f, 4.0f}, transform); + ASSERT_TRUE(expanded.has_value()); + if (expanded.has_value()) { + EXPECT_EQ(expanded.value().GetSize(), Size(2.5f, 2.0f)); + } + } + + // Expand to 9.0 transformed height. + // This is equal to 9.0 / 3.0 = 3.0 local height. + { + auto expanded = rect.ExpandToMinTransformedSize({3.0f, 9.0f}, transform); + ASSERT_TRUE(expanded.has_value()); + if (expanded.has_value()) { + EXPECT_EQ(expanded.value().GetSize(), Size(2.0f, 3.0)); + } + } + + // Expand both width and height. + { + auto expanded = rect.ExpandToMinTransformedSize({5.0f, 9.0f}, transform); + ASSERT_TRUE(expanded.has_value()); + if (expanded.has_value()) { + EXPECT_EQ(expanded.value().GetSize(), Size(2.5f, 3.0f)); + } + } +} + TEST(RectTest, ContainsFloatingPoint) { auto rect1 = Rect::MakeXYWH(472.599945f, 440.999969f, 1102.80005f, 654.000061f); diff --git a/engine/src/flutter/impeller/renderer/backend/vulkan/driver_info_vk.cc b/engine/src/flutter/impeller/renderer/backend/vulkan/driver_info_vk.cc index 18106b62a735a..075829863408a 100644 --- a/engine/src/flutter/impeller/renderer/backend/vulkan/driver_info_vk.cc +++ b/engine/src/flutter/impeller/renderer/backend/vulkan/driver_info_vk.cc @@ -422,7 +422,7 @@ bool DriverInfoVK::IsKnownBadDriver() const { // https://github.com/flutter/flutter/issues/160866 // https://github.com/flutter/flutter/issues/160804 // https://github.com/flutter/flutter/issues/160406 - if (powervr_gpu_.has_value() && powervr_gpu_.value() < PowerVRGPU::kBXE) { + if (powervr_gpu_.has_value() && powervr_gpu_.value() < PowerVRGPU::kCXT) { return true; } return false; diff --git a/engine/src/flutter/impeller/renderer/backend/vulkan/driver_info_vk_unittests.cc b/engine/src/flutter/impeller/renderer/backend/vulkan/driver_info_vk_unittests.cc index 41f4ee1977356..5bdf8ac8388e3 100644 --- a/engine/src/flutter/impeller/renderer/backend/vulkan/driver_info_vk_unittests.cc +++ b/engine/src/flutter/impeller/renderer/backend/vulkan/driver_info_vk_unittests.cc @@ -299,7 +299,7 @@ TEST(DriverInfoVKTest, PowerVRBSeries) { }) .Build(); - EXPECT_FALSE(context->GetDriverInfo()->IsKnownBadDriver()); + EXPECT_TRUE(context->GetDriverInfo()->IsKnownBadDriver()); EXPECT_EQ(context->GetDriverInfo()->GetPowerVRGPUInfo(), std::optional(PowerVRGPU::kBXM)); } diff --git a/engine/src/flutter/impeller/tools/malioc.json b/engine/src/flutter/impeller/tools/malioc.json index 4b1bd6f3bb1df..9f04cbbf47e12 100644 --- a/engine/src/flutter/impeller/tools/malioc.json +++ b/engine/src/flutter/impeller/tools/malioc.json @@ -8869,7 +8869,7 @@ "uses_late_zs_update": false, "variants": { "Main": { - "fp16_arithmetic": 34, + "fp16_arithmetic": 32, "has_stack_spilling": false, "performance": { "longest_path_bound_pipelines": [ @@ -8877,10 +8877,10 @@ "arith_sfu" ], "longest_path_cycles": [ + 4.0, 3.875, - 3.78125, - 1.59375, - 3.875, + 1.65625, + 4.0, 0.0, 0.25, 0.0 @@ -8899,10 +8899,10 @@ "arith_fma" ], "shortest_path_cycles": [ - 0.4375, - 0.4375, - 0.09375, - 0.375, + 0.53125, + 0.53125, + 0.15625, + 0.5, 0.0, 0.25, 0.0 @@ -8912,10 +8912,10 @@ "arith_fma" ], "total_cycles": [ - 11.8125, - 11.8125, - 3.9375, - 10.25, + 13.625, + 13.625, + 4.53125, + 13.4375, 0.0, 0.25, 0.0 @@ -8923,7 +8923,7 @@ }, "stack_spill_bytes": 0, "thread_occupancy": 100, - "uniform_registers_used": 42, + "uniform_registers_used": 46, "work_registers_used": 32 } } @@ -8931,7 +8931,7 @@ "Mali-T880": { "core": "Mali-T880", "filename": "flutter/impeller/entity/gles/uber_sdf.frag.gles", - "has_uniform_computation": false, + "has_uniform_computation": true, "type": "Fragment", "variants": { "Main": { @@ -8941,7 +8941,7 @@ "arithmetic" ], "longest_path_cycles": [ - 43.560001373291016, + 43.88999938964844, 3.0, 4.0 ], @@ -8954,7 +8954,7 @@ "arithmetic" ], "shortest_path_cycles": [ - 5.28000020980835, + 6.269999980926514, 1.0, 2.0 ], @@ -8962,13 +8962,13 @@ "arithmetic" ], "total_cycles": [ - 87.33333587646484, + 105.66666412353516, 3.0, - 8.0 + 24.0 ] }, "thread_occupancy": 100, - "uniform_registers_used": 5, + "uniform_registers_used": 7, "work_registers_used": 4 } } @@ -12277,7 +12277,7 @@ "uses_late_zs_update": false, "variants": { "Main": { - "fp16_arithmetic": 38, + "fp16_arithmetic": 35, "has_stack_spilling": false, "performance": { "longest_path_bound_pipelines": [ @@ -12285,10 +12285,10 @@ "arith_sfu" ], "longest_path_cycles": [ - 3.875, - 3.299999952316284, - 1.8624999523162842, - 3.875, + 4.0, + 3.387500047683716, + 1.9375, + 4.0, 0.0, 0.25, 0.0 @@ -12307,23 +12307,23 @@ "arith_sfu" ], "shortest_path_cycles": [ - 0.375, - 0.359375, - 0.109375, - 0.375, + 0.5, + 0.453125, + 0.171875, + 0.5, 0.0, 0.25, 0.0 ], "total_bound_pipelines": [ "arith_total", - "arith_fma" + "arith_sfu" ], "total_cycles": [ - 10.9375, - 10.9375, - 4.5, - 10.25, + 13.4375, + 12.4375, + 4.875, + 13.4375, 0.0, 0.25, 0.0 @@ -12331,7 +12331,7 @@ }, "stack_spill_bytes": 0, "thread_occupancy": 100, - "uniform_registers_used": 46, + "uniform_registers_used": 48, "work_registers_used": 32 } } @@ -12743,4 +12743,4 @@ } } } -} +} \ No newline at end of file diff --git a/engine/src/flutter/lib/ui/painting/display_list_deferred_image_gpu_impeller.cc b/engine/src/flutter/lib/ui/painting/display_list_deferred_image_gpu_impeller.cc index b78af43b2bd76..51e1b0d2a113b 100644 --- a/engine/src/flutter/lib/ui/painting/display_list_deferred_image_gpu_impeller.cc +++ b/engine/src/flutter/lib/ui/painting/display_list_deferred_image_gpu_impeller.cc @@ -8,6 +8,7 @@ #include #include "flutter/fml/make_copyable.h" +#include "impeller/display_list/aiks_context.h" // Disable a warning on Windows about use of deprecated atomic operations // on std::shared_ptr. These functions are used because libcxx does not @@ -175,9 +176,11 @@ void DlDeferredImageGPUImpeller::ImageWrapper::SnapshotDisplayList( content)) { std::unique_ptr layer_tree = std::get>(std::move(content)); + auto aiks_context = snapshot_delegate->GetAiksContext(); display_list = layer_tree->Flatten( DlRect::MakeWH(wrapper->size_.width, wrapper->size_.height), - snapshot_delegate->GetTextureRegistry()); + snapshot_delegate->GetTextureRegistry(), + /*gr_context=*/nullptr, aiks_context.get()); } auto texture = snapshot_delegate->MakeImpellerSnapshotSync( diff --git a/engine/src/flutter/lib/ui/painting/picture.cc b/engine/src/flutter/lib/ui/painting/picture.cc index 400cccf019f7d..4afb2dca2d9ac 100644 --- a/engine/src/flutter/lib/ui/painting/picture.cc +++ b/engine/src/flutter/lib/ui/painting/picture.cc @@ -251,10 +251,14 @@ Dart_Handle Picture::DoRasterizeToImage(const sk_sp& display_list, sk_sp snapshot_display_list = display_list; if (layer_tree) { FML_DCHECK(picture_bounds == layer_tree->frame_size()); - snapshot_display_list = - layer_tree->Flatten(DlRect::MakeWH(width, height), - snapshot_delegate->GetTextureRegistry(), - snapshot_delegate->GetGrContext()); + auto aiks_context = is_impeller_enabled + ? snapshot_delegate->GetAiksContext() + : nullptr; + snapshot_display_list = layer_tree->Flatten( + DlRect::MakeWH(width, height), + snapshot_delegate->GetTextureRegistry(), + is_impeller_enabled ? nullptr : snapshot_delegate->GetGrContext(), + aiks_context.get()); } if (is_impeller_enabled) { #if IMPELLER_SUPPORTS_RENDERING diff --git a/engine/src/flutter/lib/ui/painting/testing/mocks.h b/engine/src/flutter/lib/ui/painting/testing/mocks.h index a4be65c63a55e..d28460407062c 100644 --- a/engine/src/flutter/lib/ui/painting/testing/mocks.h +++ b/engine/src/flutter/lib/ui/painting/testing/mocks.h @@ -39,6 +39,10 @@ class MockSnapshotDelegate : public SnapshotDelegate { (), (override)); MOCK_METHOD(GrDirectContext*, GetGrContext, (), (override)); + MOCK_METHOD(std::shared_ptr, + GetAiksContext, + (), + (const, override)); MOCK_METHOD(void, MakeSkiaSnapshot, (sk_sp, diff --git a/engine/src/flutter/lib/ui/snapshot_delegate.h b/engine/src/flutter/lib/ui/snapshot_delegate.h index 631fbcea9c6e6..2e834472bc9ac 100644 --- a/engine/src/flutter/lib/ui/snapshot_delegate.h +++ b/engine/src/flutter/lib/ui/snapshot_delegate.h @@ -6,6 +6,7 @@ #define FLUTTER_LIB_UI_SNAPSHOT_DELEGATE_H_ #include +#include #include #include "flutter/common/graphics/texture.h" @@ -19,6 +20,7 @@ namespace impeller { class Texture; class RuntimeStage; +class AiksContext; } // namespace impeller namespace flutter { @@ -83,6 +85,8 @@ class SnapshotDelegate { virtual GrDirectContext* GetGrContext() = 0; + virtual std::shared_ptr GetAiksContext() const = 0; + virtual void MakeSkiaSnapshot(sk_sp display_list, DlISize picture_size, std::function)> callback, diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart index ac296f15b6433..489581eaaea57 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart @@ -1997,6 +1997,19 @@ extension type DomTouchEvent._(JSObject _) implements DomUIEvent { @JS('changedTouches') external _DomList get _changedTouches; Iterable get changedTouches => _createDomListWrapper(_changedTouches); + + @JS('touches') + external _DomList get _touches; + + /// All touch points currently in contact with the surface. + /// + /// On iOS WebKit this stays accurate even where the pointer events do not: + /// WebKit can stop dispatching pointer events for a touch it has taken over + /// for a native gesture, but it still drops that touch from this list once + /// the finger leaves, which is what makes an abandoned touch detectable. This + /// is observed WebKit behavior, not a cross-browser guarantee. + /// See: https://github.com/flutter/flutter/issues/188781 + Iterable get touches => _createDomListWrapper(_touches); } @JS('Touch') 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 d0b8e14d2a29e..411338fdb11cf 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 @@ -482,6 +482,20 @@ class ClickDebouncer { EnginePlatformDispatcher.instance.invokeOnPointerDataPacket(packet); } + /// Flushes any in-progress debounce, then forwards [data] to the framework. + /// + /// For synthetic events that must not be queued or dropped by an in-progress + /// debounce, such as cancels that repair a pointer the browser abandoned. + /// Flushing first keeps the stream ordered: a queued `pointerdown` has to + /// reach the framework before the cancel that closes it out, otherwise the + /// framework is left holding a down it can never match. + void flushAndSend(List data) { + if (isDebouncing) { + _flush(); + } + _sendToFramework(null, data); + } + /// Cancels any pending debounce process and forgets anything that happened so /// far. /// @@ -1007,6 +1021,13 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin { final Map _sanitizers = {}; + /// Touch devices that went down and have not been released yet. + /// + /// A device leaves this set when the browser reports `pointerup` or + /// `pointercancel`, or when [_cancelAbandonedTouches] gives up on it. It is + /// what that method reconciles against the touches actually on the surface. + final Set _downTouchDevices = {}; + @visibleForTesting Iterable debugTrackedDevices() => _sanitizers.keys; @@ -1025,7 +1046,9 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin { void _removePointerIfUnhoverable(DomPointerEvent event) { if (event.pointerType == 'touch') { - _sanitizers.remove(event.pointerId); + final int device = _getPointerId(event); + _sanitizers.remove(device); + _downTouchDevices.remove(device); } } @@ -1071,6 +1094,9 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin { buttons: event.buttons!.toInt(), ); _convertEventsToPointerData(data: pointerData, event: event, details: down); + if (event.pointerType == 'touch') { + _downTouchDevices.add(device); + } _callback(event, pointerData); if (event.target == _viewTarget) { @@ -1177,11 +1203,80 @@ class _PointerAdapter extends _BaseAdapter with _WheelEventListenerMixin { } }, checkModifiers: false); + // Safety net for touches the browser abandons without a `pointerup` or a + // `pointercancel`. See [_cancelAbandonedTouches]. + addEventListener(_globalTarget, 'touchend', (DomEvent event) { + _cancelAbandonedTouches(event as DomTouchEvent); + }); + addEventListener(_globalTarget, 'touchcancel', (DomEvent event) { + _cancelAbandonedTouches(event as DomTouchEvent); + }); + _addWheelEventListener((DomEvent event) { _handleWheelEvent(event); }); } + /// Cancels touch pointers that the browser stopped reporting mid-gesture. + /// + /// iOS WebKit stops dispatching pointer events for a touch once it promotes + /// that touch to a native gesture, such as dragging the caret inside a text + /// field. It delivers neither `pointerup` nor `pointercancel`, so the + /// framework is left with a pointer that never lifts, and any gesture + /// recognizer tracking it is wedged forever. + /// + /// Observed on iOS 27; iOS 26 does not do it. Gated to iOS because the + /// reconciliation below relies on WebKit behavior that other engines do not + /// guarantee, in particular that a `Touch.identifier` equals its pointer + /// event's `pointerId`. Despite its name, [isIosSafari] means WebKit on iOS, + /// so the gate covers every browser on iOS, not just Safari. + /// See: https://github.com/flutter/flutter/issues/188781 + /// + /// On iOS WebKit, `touches` still reports the truth throughout: it lists the + /// touches in contact with the surface, and drops an abandoned one once the + /// finger leaves. Only the pointer events go missing. So any touch this class + /// believes is down, but which the browser does not report as being on the + /// surface, has been abandoned and must be cancelled. + /// + /// The cancel does not join the click debouncer's queue: it repairs an older + /// pointer and must not be dropped by debouncing of a concurrent tap. Any + /// queued events are flushed ahead of it, so the framework still sees the + /// abandoned pointer's `down` before its `cancel`. + void _cancelAbandonedTouches(DomTouchEvent event) { + if (!isIosSafari || _downTouchDevices.isEmpty) { + return; + } + + final onSurface = { + for (final DomTouch touch in event.touches) + if (touch.identifier?.toInt() case final int device) device, + }; + final Set stale = _downTouchDevices.difference(onSurface); + if (stale.isEmpty) { + return; + } + + final pointerData = []; + final Duration timeStamp = _BaseAdapter._eventTimeStampToDuration(event.timeStamp!); + for (final device in stale) { + _sanitizers.remove(device); + // `convert` defaults `change` to `PointerChange.cancel`. It also replaces + // a cancel's coordinates with the pointer's last known location, so no + // position is supplied here. + _pointerDataConverter.convert( + pointerData, + viewId: _view.viewId, + timeStamp: timeStamp, + signalKind: ui.PointerSignalKind.none, + device: device, + pressureMax: 1.0, + ); + } + _downTouchDevices.removeAll(stale); + + PointerBinding.clickDebouncer.flushAndSend(pointerData); + } + // For each event that is de-coalesced from `event` and described in // `details`, convert it to pointer data and store in `data`. void _convertEventsToPointerData({ 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 528c8ee35354a..2a387b23672fd 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 @@ -20,6 +20,42 @@ List _allPointerData(List packets) { return packets.expand((ui.PointerDataPacket packet) => packet.data).toList(); } +/// Whether this browser exposes the `Touch` and `TouchEvent` constructors that +/// [_createTouchEvent] needs. +/// +/// Desktop Safari and Firefox only define them on devices with a touchscreen, +/// so the touch tests below cannot build their input there. +bool get _canConstructTouchEvents => + domWindow.hasProperty('Touch'.toJS).toDart && domWindow.hasProperty('TouchEvent'.toJS).toDart; + +/// Builds a `touchend` or `touchcancel` reporting that the touches in [lifted] +/// left the surface, while those in [remaining] are still on it. +/// +/// A touch identifier is the same value as its pointer event's `pointerId`, so +/// both lists hold device ids. The touch points carry no meaningful coordinates, +/// because the engine only reads their identifiers. +DomTouchEvent _createTouchEvent( + String type, + List lifted, { + List remaining = const [], +}) { + JSAny touch(int device) => DomTouch( + JSObject() + ..setProperty('identifier'.toJS, device.toJS) + ..setProperty('target'.toJS, rootElement as JSAny) + ..setProperty('clientX'.toJS, 0.toJS) + ..setProperty('clientY'.toJS, 0.toJS), + ); + return DomTouchEvent( + type, + JSObject() + ..setProperty('bubbles'.toJS, true.toJS) + ..setProperty('cancelable'.toJS, true.toJS) + ..setProperty('touches'.toJS, [for (final int d in remaining) touch(d)].toJS) + ..setProperty('changedTouches'.toJS, [for (final int d in lifted) touch(d)].toJS), + ); +} + void main() { internalBootstrapBrowserTest(() => testMain); } @@ -2539,6 +2575,311 @@ void testMain() { packets.clear(); }); + // WebKit stops dispatching pointer events for a touch once it promotes that + // touch to a native gesture, such as dragging the caret in a text field. It + // sends neither `pointerup` nor `pointercancel`, which used to leave the + // pointer down forever and wedge any gesture recognizer tracking it. + // Regression test for https://github.com/flutter/flutter/issues/188781 + test('cancels a touch the browser abandons without pointerup', () { + final context = _PointerEventContext(); + // This workaround is gated to iOS WebKit. + debugEmulateIosSafari = true; + addTearDown(() { + debugEmulateIosSafari = false; + }); + final packets = []; + ui.PlatformDispatcher.instance.onPointerDataPacket = (ui.PointerDataPacket packet) { + packets.add(packet); + }; + + context + .multiTouchDown(const <_TouchDetails>[ + _TouchDetails(pointer: 2, clientX: 100, clientY: 101), + ]) + .forEach(rootElement.dispatchEvent); + packets.clear(); + + // The finger lifts, but the browser only reports `touchend`. No `pointerup` + // and no `pointercancel` ever arrive. + // + // `touches` is not empty here: it can run ahead of the event stream, and + // already lists touch 3, whose `pointerdown` has not been dispatched yet. + // Touch 2 is absent from it though, which is what marks it as abandoned. + rootElement.dispatchEvent(_createTouchEvent('touchend', [2], remaining: [3])); + + // A cancelled touch is also removed, so exactly two events are emitted. + expect(packets, hasLength(1)); + expect(packets[0].data, hasLength(2)); + expect(packets[0].data[0].change, equals(ui.PointerChange.cancel)); + expect(packets[0].data[0].device, equals(2)); + expect(packets[0].data[0].buttons, equals(0)); + // The cancel is reported at the pointer's last known location. + expect(packets[0].data[0].physicalX, equals(100 * dpi)); + expect(packets[0].data[0].physicalY, equals(101 * dpi)); + expect(packets[0].data[1].change, equals(ui.PointerChange.remove)); + expect(packets[0].data[1].device, equals(2)); + }, skip: !_canConstructTouchEvents); + + test('does not cancel a touch that was released normally', () { + final context = _PointerEventContext(); + // This workaround is gated to iOS WebKit. + debugEmulateIosSafari = true; + addTearDown(() { + debugEmulateIosSafari = false; + }); + final packets = []; + ui.PlatformDispatcher.instance.onPointerDataPacket = (ui.PointerDataPacket packet) { + packets.add(packet); + }; + + context + .multiTouchDown(const <_TouchDetails>[ + _TouchDetails(pointer: 2, clientX: 100, clientY: 101), + ]) + .forEach(rootElement.dispatchEvent); + context + .multiTouchUp(const <_TouchDetails>[_TouchDetails(pointer: 2, clientX: 100, clientY: 101)]) + .forEach(rootElement.dispatchEvent); + packets.clear(); + + // `pointerup` precedes `touchend` in a healthy sequence, so by now the + // pointer is already released and the trailing `touchend` must be a no-op. + rootElement.dispatchEvent(_createTouchEvent('touchend', [2])); + + expect(packets, isEmpty); + }, skip: !_canConstructTouchEvents); + + // WebKit sometimes drops the `touchend` for an abandoned touch entirely, so + // no event ever announces it. It is still caught because `touches` stops + // listing it, so reconciling against a later touch finds it missing. + test('cancels an abandoned touch whose touchend never arrives', () { + final context = _PointerEventContext(); + // This workaround is gated to iOS WebKit. + debugEmulateIosSafari = true; + addTearDown(() { + debugEmulateIosSafari = false; + }); + final packets = []; + ui.PlatformDispatcher.instance.onPointerDataPacket = (ui.PointerDataPacket packet) { + packets.add(packet); + }; + + // Pointer 2 is abandoned: no pointerup, and no touchend naming it either. + context + .multiTouchDown(const <_TouchDetails>[ + _TouchDetails(pointer: 2, clientX: 100, clientY: 101), + ]) + .forEach(rootElement.dispatchEvent); + + // A later, unrelated touch completes normally. + context + .multiTouchDown(const <_TouchDetails>[ + _TouchDetails(pointer: 3, clientX: 200, clientY: 201), + ]) + .forEach(rootElement.dispatchEvent); + context + .multiTouchUp(const <_TouchDetails>[_TouchDetails(pointer: 3, clientX: 200, clientY: 201)]) + .forEach(rootElement.dispatchEvent); + packets.clear(); + + // This is pointer 3's touchend, and `touches` is now empty. Pointer 2 is + // absent from it, so it is stale and must be cancelled. + rootElement.dispatchEvent(_createTouchEvent('touchend', [3])); + + expect(packets, hasLength(1)); + expect(packets[0].data[0].change, equals(ui.PointerChange.cancel)); + expect(packets[0].data[0].device, equals(2)); + }, skip: !_canConstructTouchEvents); + + // The abandoned touch is neither named by this event nor the last finger on + // the surface, so it is only detectable by being absent from `touches`. + test('cancels an abandoned touch while other fingers are still down', () { + final context = _PointerEventContext(); + // This workaround is gated to iOS WebKit. + debugEmulateIosSafari = true; + addTearDown(() { + debugEmulateIosSafari = false; + }); + final packets = []; + ui.PlatformDispatcher.instance.onPointerDataPacket = (ui.PointerDataPacket packet) { + packets.add(packet); + }; + + // Touch 2 is abandoned: no pointerup, and no touchend naming it either. + context + .multiTouchDown(const <_TouchDetails>[ + _TouchDetails(pointer: 2, clientX: 100, clientY: 101), + ]) + .forEach(rootElement.dispatchEvent); + + // Two more fingers go down, and one of them lifts normally. + context + .multiTouchDown(const <_TouchDetails>[ + _TouchDetails(pointer: 3, clientX: 200, clientY: 201), + _TouchDetails(pointer: 4, clientX: 300, clientY: 301), + ]) + .forEach(rootElement.dispatchEvent); + context + .multiTouchUp(const <_TouchDetails>[_TouchDetails(pointer: 3, clientX: 200, clientY: 201)]) + .forEach(rootElement.dispatchEvent); + packets.clear(); + + // Pointer 4 is still on the surface, so this is not the last finger up. + // Pointer 2 must still be cancelled, and pointer 4 must be left alone. + rootElement.dispatchEvent(_createTouchEvent('touchend', [3], remaining: [4])); + + // A cancelled touch is also removed, so two events are emitted, both for + // pointer 2. Nothing at all is emitted for pointer 4. + expect(packets, hasLength(1)); + expect(packets[0].data, hasLength(2)); + expect(packets[0].data[0].change, equals(ui.PointerChange.cancel)); + expect(packets[0].data[0].device, equals(2)); + expect(packets[0].data[1].change, equals(ui.PointerChange.remove)); + expect(packets[0].data[1].device, equals(2)); + expect( + packets[0].data.every((ui.PointerData data) => data.device != 4), + isTrue, + reason: 'pointer 4 is still down and must not be cancelled', + ); + }, skip: !_canConstructTouchEvents); + + // An aborted touch is normally cleaned up by the `pointercancel` that + // accompanies `touchcancel`. This covers the case where the browser drops that + // `pointercancel`, which is the same class of defect as the missing + // `pointerup` this whole safety net exists for. + test('cancels an abandoned touch on touchcancel', () { + final context = _PointerEventContext(); + // This workaround is gated to iOS WebKit. + debugEmulateIosSafari = true; + addTearDown(() { + debugEmulateIosSafari = false; + }); + final packets = []; + ui.PlatformDispatcher.instance.onPointerDataPacket = (ui.PointerDataPacket packet) { + packets.add(packet); + }; + + context + .multiTouchDown(const <_TouchDetails>[ + _TouchDetails(pointer: 2, clientX: 100, clientY: 101), + ]) + .forEach(rootElement.dispatchEvent); + packets.clear(); + + // The touch is aborted, and no `pointercancel` arrives to release it. + rootElement.dispatchEvent(_createTouchEvent('touchcancel', [2])); + + expect(packets, hasLength(1)); + expect(packets[0].data[0].change, equals(ui.PointerChange.cancel)); + expect(packets[0].data[0].device, equals(2)); + }, skip: !_canConstructTouchEvents); + + // The reconciliation is gated to iOS WebKit, because it relies on WebKit + // behavior other engines do not guarantee (notably that `Touch.identifier` + // equals `pointerId`). Off iOS it must never cancel a live pointer. + test('does not cancel abandoned touches on non-iOS browsers', () { + // Deliberately does NOT emulate iOS. + final context = _PointerEventContext(); + final packets = []; + ui.PlatformDispatcher.instance.onPointerDataPacket = (ui.PointerDataPacket packet) { + packets.add(packet); + }; + + context + .multiTouchDown(const <_TouchDetails>[ + _TouchDetails(pointer: 2, clientX: 100, clientY: 101), + ]) + .forEach(rootElement.dispatchEvent); + packets.clear(); + + // The same abandonment that would be cancelled on iOS. + rootElement.dispatchEvent(_createTouchEvent('touchend', [2])); + + expect(packets, isEmpty); + }, skip: !_canConstructTouchEvents); + + // A semantics-enabled text field is itself a tappable element, so the touch + // WebKit abandons is usually the very one being debounced: its `down` is still + // queued when the repair runs. Sending the cancel straight through would hand + // the framework a cancel for a pointer it has not seen go down, and the `down` + // would arrive afterwards with nothing left to close it out. That is the same + // stuck state this workaround exists to repair, so the queue has to be flushed + // ahead of the cancel. + test('flushes the debounce queue before delivering the cancel', () { + debugEmulateIosSafari = true; + EngineSemantics.instance.semanticsEnabled = true; + addTearDown(() { + debugEmulateIosSafari = false; + EngineSemantics.instance.semanticsEnabled = false; + }); + + final context = _PointerEventContext(); + final events = <(ui.PointerChange, int)>[]; + ui.PlatformDispatcher.instance.onPointerDataPacket = (ui.PointerDataPacket packet) { + for (final ui.PointerData datum in packet.data) { + events.add((datum.change, datum.device)); + } + }; + + // The touch lands on a tappable element, so it starts click debouncing and + // its `down` sits in the queue instead of going to the framework. + final DomElement tappable = createDomElement('flutter-tappable') + ..setAttribute('flt-tappable', ''); + rootElement.append(tappable); + addTearDown(() { + tappable.remove(); + }); + tappable.dispatchEvent( + context.multiTouchDown(const <_TouchDetails>[ + _TouchDetails(pointer: 2, clientX: 100, clientY: 101), + ]).single, + ); + expect(PointerBinding.clickDebouncer.isDebouncing, isTrue); + expect(events, isEmpty, reason: 'the debounced down must still be queued'); + + // WebKit abandons that same touch: no pointerup or pointercancel arrives, + // and the finger is gone from the surface by the time touchend fires. + rootElement.dispatchEvent(_createTouchEvent('touchend', [2])); + + expect(events, <(ui.PointerChange, int)>[ + (ui.PointerChange.add, 2), + (ui.PointerChange.down, 2), + (ui.PointerChange.cancel, 2), + (ui.PointerChange.remove, 2), + ], reason: 'the queued down must be flushed ahead of the cancel that closes it out'); + }, skip: !_canConstructTouchEvents); + + // The abandoned pointer's `down` must already have reached the framework + // before its synthesized `cancel`, otherwise the framework sees cancel first. + test('sends the abandoned pointer down before its cancel', () { + debugEmulateIosSafari = true; + addTearDown(() { + debugEmulateIosSafari = false; + }); + + final context = _PointerEventContext(); + final events = <(ui.PointerChange, int)>[]; + ui.PlatformDispatcher.instance.onPointerDataPacket = (ui.PointerDataPacket packet) { + for (final ui.PointerData d in packet.data) { + events.add((d.change, d.device)); + } + }; + + // Not cleared: we want the whole ordered stream, down then cancel. + context + .multiTouchDown(const <_TouchDetails>[ + _TouchDetails(pointer: 2, clientX: 100, clientY: 101), + ]) + .forEach(rootElement.dispatchEvent); + rootElement.dispatchEvent(_createTouchEvent('touchend', [2])); + + final int downIndex = events.indexOf((ui.PointerChange.down, 2)); + final int cancelIndex = events.indexOf((ui.PointerChange.cancel, 2)); + expect(downIndex, greaterThanOrEqualTo(0)); + expect(cancelIndex, greaterThanOrEqualTo(0)); + expect(downIndex, lessThan(cancelIndex)); + }, skip: !_canConstructTouchEvents); + test('does not synthesize pointer up if from different device', () { final context = _PointerEventContext(); final packets = []; diff --git a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterEngine.mm b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterEngine.mm index d60fa92743a69..1f9ffee62b2ca 100644 --- a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterEngine.mm +++ b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterEngine.mm @@ -1654,11 +1654,18 @@ - (void)setApplicationState:(flutter::AppLifecycleState)state { */ - (void)handleWillBecomeActive:(NSNotification*)notification { _active = YES; - if (!_visible) { - [self setApplicationState:flutter::AppLifecycleState::kHidden]; - } else { - [self setApplicationState:flutter::AppLifecycleState::kResumed]; + // occlusionState can latch stale on an occlusion->visible transition (same-screen + // Cmd-Tab / Mission Control), so `_visible` is unreliable here. Resume from + // NSWindow.isVisible instead — NO for a minimized window, so it won't resume hidden. + // https://github.com/flutter/flutter/issues/155977 + for (NSWindow* window in [NSApplication sharedApplication].windows) { + if (window.isVisible) { + _visible = YES; + break; + } } + [self setApplicationState:_visible ? flutter::AppLifecycleState::kResumed + : flutter::AppLifecycleState::kHidden]; } /** @@ -1675,8 +1682,8 @@ - (void)handleWillResignActive:(NSNotification*)notification { } /** - * Called when the |FlutterAppDelegate| gets the applicationDidUnhide - * notification. + * Called when the application's occlusion state changes + * (NSApplicationDidChangeOcclusionStateNotification). */ - (void)handleDidChangeOcclusionState:(NSNotification*)notification { NSApplicationOcclusionState occlusionState = [[NSApplication sharedApplication] occlusionState]; diff --git a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterEngineTest.mm b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterEngineTest.mm index b984005085afd..b8a659211c0ae 100644 --- a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterEngineTest.mm +++ b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterEngineTest.mm @@ -1199,6 +1199,16 @@ @implementation MockableFlutterEngine [invocation setReturnValue:&visibility]; }); + // handleWillBecomeActive derives visibility from the windows (not from the + // occlusion state, which can latch stale), so provide a window whose visibility + // we can toggle independently. + __block BOOL windowVisible = YES; + id mockWindow = OCMClassMock([NSWindow class]); + OCMStub([mockWindow isVisible]).andDo(^(NSInvocation* invocation) { + [invocation setReturnValue:&windowVisible]; + }); + OCMStub([mockApplication windows]).andReturn(@[ mockWindow ]); + NSNotification* willBecomeActive = [[NSNotification alloc] initWithName:NSApplicationWillBecomeActiveNotification object:nil @@ -1227,12 +1237,30 @@ @implementation MockableFlutterEngine [engineMock handleDidChangeOcclusionState:didChangeOcclusionState]; EXPECT_EQ(sentState, flutter::AppLifecycleState::kHidden); + // Becoming active with an on-screen window resumes even though occlusionState still + // reads not-visible (the stale-latch case). Regression test for #155977. [engineMock handleWillBecomeActive:willBecomeActive]; - EXPECT_EQ(sentState, flutter::AppLifecycleState::kHidden); + EXPECT_EQ(sentState, flutter::AppLifecycleState::kResumed); + // The app is now active and considered visible, so resigning active makes it + // inactive (not hidden) until a real occlusion notification hides it. [engineMock handleWillResignActive:willResignActive]; + EXPECT_EQ(sentState, flutter::AppLifecycleState::kInactive); + + // Occlusion stays authoritative after a becomeActive resume: a genuine + // not-visible occlusion notification still hides the app. + visibility = 0; + [engineMock handleDidChangeOcclusionState:didChangeOcclusionState]; + EXPECT_EQ(sentState, flutter::AppLifecycleState::kHidden); + + // Becoming active while no window is visible (e.g. activated with all windows + // minimized, which does not deminiaturize) must not resume: visibility is + // derived from the windows, not from the activation itself. + windowVisible = NO; + [engineMock handleWillBecomeActive:willBecomeActive]; EXPECT_EQ(sentState, flutter::AppLifecycleState::kHidden); + [mockWindow stopMocking]; [mockApplication stopMocking]; } diff --git a/engine/src/flutter/shell/platform/embedder/embedder_external_texture_gl.cc b/engine/src/flutter/shell/platform/embedder/embedder_external_texture_gl.cc index b533f4394483f..61fc5de62c3b7 100644 --- a/engine/src/flutter/shell/platform/embedder/embedder_external_texture_gl.cc +++ b/engine/src/flutter/shell/platform/embedder/embedder_external_texture_gl.cc @@ -71,15 +71,19 @@ sk_sp EmbedderExternalTextureGL::ResolveTexture( const SkISize& size) { if (!!aiks_context) { return ResolveTextureImpeller(texture_id, aiks_context, size); - } else { + } else if (context) { return ResolveTextureSkia(texture_id, context, size); } + return nullptr; } sk_sp EmbedderExternalTextureGL::ResolveTextureSkia( int64_t texture_id, GrDirectContext* context, const SkISize& size) { + if (!context) { + return nullptr; + } context->flushAndSubmit(); context->resetContext(kAll_GrBackendState); std::unique_ptr texture = diff --git a/engine/src/flutter/shell/platform/embedder/embedder_external_texture_gl.h b/engine/src/flutter/shell/platform/embedder/embedder_external_texture_gl.h index 7dbc631d56ad1..3ea22c255de9c 100644 --- a/engine/src/flutter/shell/platform/embedder/embedder_external_texture_gl.h +++ b/engine/src/flutter/shell/platform/embedder/embedder_external_texture_gl.h @@ -22,23 +22,6 @@ class EmbedderExternalTextureGL : public flutter::Texture { ~EmbedderExternalTextureGL(); - private: - const ExternalTextureCallback& external_texture_callback_; - sk_sp last_image_; - - sk_sp ResolveTexture(int64_t texture_id, - GrDirectContext* context, - impeller::AiksContext* aiks_context, - const SkISize& size); - - sk_sp ResolveTextureSkia(int64_t texture_id, - GrDirectContext* context, - const SkISize& size); - - sk_sp ResolveTextureImpeller(int64_t texture_id, - impeller::AiksContext* aiks_context, - const SkISize& size); - // |flutter::Texture| void Paint(PaintContext& context, const DlRect& bounds, @@ -57,6 +40,23 @@ class EmbedderExternalTextureGL : public flutter::Texture { // |flutter::Texture| void OnTextureUnregistered() override; + private: + const ExternalTextureCallback& external_texture_callback_; + sk_sp last_image_; + + sk_sp ResolveTexture(int64_t texture_id, + GrDirectContext* context, + impeller::AiksContext* aiks_context, + const SkISize& size); + + sk_sp ResolveTextureSkia(int64_t texture_id, + GrDirectContext* context, + const SkISize& size); + + sk_sp ResolveTextureImpeller(int64_t texture_id, + impeller::AiksContext* aiks_context, + const SkISize& size); + FML_DISALLOW_COPY_AND_ASSIGN(EmbedderExternalTextureGL); }; diff --git a/engine/src/flutter/shell/platform/embedder/embedder_external_texture_metal.h b/engine/src/flutter/shell/platform/embedder/embedder_external_texture_metal.h index c5fc1a594efd8..c7a57c9fdf614 100644 --- a/engine/src/flutter/shell/platform/embedder/embedder_external_texture_metal.h +++ b/engine/src/flutter/shell/platform/embedder/embedder_external_texture_metal.h @@ -24,15 +24,6 @@ class EmbedderExternalTextureMetal : public flutter::Texture { ~EmbedderExternalTextureMetal(); - private: - const ExternalTextureCallback& external_texture_callback_; - sk_sp last_image_; - - sk_sp ResolveTexture(int64_t texture_id, - GrDirectContext* context, - impeller::AiksContext* aiks_context, - const SkISize& size); - // |flutter::Texture| void Paint(PaintContext& context, const DlRect& bounds, @@ -51,6 +42,15 @@ class EmbedderExternalTextureMetal : public flutter::Texture { // |flutter::Texture| void OnTextureUnregistered() override; + private: + const ExternalTextureCallback& external_texture_callback_; + sk_sp last_image_; + + sk_sp ResolveTexture(int64_t texture_id, + GrDirectContext* context, + impeller::AiksContext* aiks_context, + const SkISize& size); + FML_DISALLOW_COPY_AND_ASSIGN(EmbedderExternalTextureMetal); }; diff --git a/engine/src/flutter/shell/platform/embedder/tests/embedder_gl_unittests.cc b/engine/src/flutter/shell/platform/embedder/tests/embedder_gl_unittests.cc index 1273b23c6c788..493ff735e3182 100644 --- a/engine/src/flutter/shell/platform/embedder/tests/embedder_gl_unittests.cc +++ b/engine/src/flutter/shell/platform/embedder/tests/embedder_gl_unittests.cc @@ -4622,6 +4622,31 @@ TEST_F(EmbedderTest, ExternalTextureGLRefreshedTooOften) { glFinish(); } +TEST_F(EmbedderTest, ExternalTextureGLNullContextDoesNotCrash) { + EmbedderExternalTextureGL::ExternalTextureCallback callback( + [](int64_t, size_t, size_t) { + auto res = std::make_unique(); + res->target = GL_TEXTURE_2D; + res->name = 1; + res->format = GL_RGBA8; + res->user_data = nullptr; + res->destruction_callback = [](void*) {}; + res->width = res->height = 100; + return res; + }); + EmbedderExternalTextureGL texture(1, callback); + + DisplayListBuilder builder; + Texture::PaintContext ctx{ + .canvas = &builder, + .gr_context = nullptr, + .aiks_context = nullptr, + }; + // Should not crash even when last_image_ is null and contexts are null. + texture.Paint(ctx, DlRect::MakeXYWH(0, 0, 100, 100), false, + DlImageSampling::kLinear); +} + TEST_F( EmbedderTest, PresentInfoReceivesFullScreenDamageWhenPopulateExistingDamageIsNotProvided) { diff --git a/engine/src/flutter/shell/platform/linux/fl_accessible_node.cc b/engine/src/flutter/shell/platform/linux/fl_accessible_node.cc index bcd13ba074df4..52d86cef4447b 100644 --- a/engine/src/flutter/shell/platform/linux/fl_accessible_node.cc +++ b/engine/src/flutter/shell/platform/linux/fl_accessible_node.cc @@ -72,6 +72,7 @@ enum { PROP_0, PROP_ENGINE, PROP_VIEW_ID, PROP_ID, PROP_LAST }; static void fl_accessible_node_component_interface_init( AtkComponentIface* iface); static void fl_accessible_node_action_interface_init(AtkActionIface* iface); +static void fl_accessible_node_text_interface_init(AtkTextIface* iface); G_DEFINE_TYPE_WITH_CODE( FlAccessibleNode, @@ -81,7 +82,9 @@ G_DEFINE_TYPE_WITH_CODE( G_IMPLEMENT_INTERFACE(ATK_TYPE_COMPONENT, fl_accessible_node_component_interface_init) G_IMPLEMENT_INTERFACE(ATK_TYPE_ACTION, - fl_accessible_node_action_interface_init)) + fl_accessible_node_action_interface_init) + G_IMPLEMENT_INTERFACE(ATK_TYPE_TEXT, + fl_accessible_node_text_interface_init)) // Returns TRUE if [flags] indicate this element is checkable. static gboolean is_checkable(FlutterSemanticsFlags flags) { @@ -240,7 +243,7 @@ static AtkRole fl_accessible_node_get_role(AtkObject* accessible) { return ATK_ROLE_TEXT; } if (priv->flags.is_header) { - return ATK_ROLE_HEADER; + return ATK_ROLE_HEADING; } if (priv->flags.is_link) { return ATK_ROLE_LINK; @@ -547,6 +550,167 @@ static void fl_accessible_node_action_interface_init(AtkActionIface* iface) { iface->get_name = fl_accessible_node_get_name; } +// Returns the number of UTF-8 characters in this node's text content. A +// Flutter semantics node's text content is its accessibility name. +static gint fl_accessible_node_get_text_length(FlAccessibleNodePrivate* priv) { + return priv->name != nullptr + ? static_cast(g_utf8_strlen(priv->name, -1)) + : 0; +} + +// Implements AtkText::get_character_count. +static gint fl_accessible_node_get_character_count(AtkText* text) { + FlAccessibleNodePrivate* priv = FL_ACCESSIBLE_NODE_GET_PRIVATE(text); + return fl_accessible_node_get_text_length(priv); +} + +// Implements AtkText::get_text. +static gchar* fl_accessible_node_get_text(AtkText* text, + gint start_offset, + gint end_offset) { + FlAccessibleNodePrivate* priv = FL_ACCESSIBLE_NODE_GET_PRIVATE(text); + if (priv->name == nullptr) { + return g_strdup(""); + } + + gint count = fl_accessible_node_get_text_length(priv); + start_offset = CLAMP(start_offset, 0, count); + if (end_offset < 0) { + end_offset = count; + } + end_offset = CLAMP(end_offset, start_offset, count); + + return g_utf8_substring(priv->name, start_offset, end_offset); +} + +// Implements AtkText::get_character_at_offset. +static gunichar fl_accessible_node_get_character_at_offset(AtkText* text, + gint offset) { + FlAccessibleNodePrivate* priv = FL_ACCESSIBLE_NODE_GET_PRIVATE(text); + if (priv->name == nullptr || offset < 0 || + offset >= fl_accessible_node_get_text_length(priv)) { + return 0; + } + + return g_utf8_get_char(g_utf8_offset_to_pointer(priv->name, offset)); +} + +// Implements AtkText::get_caret_offset. +// Static text such as a heading has no caret. +static gint fl_accessible_node_get_caret_offset(AtkText* text) { + (void)text; + return -1; +} + +// Implements AtkText::get_n_selections. +// Static text such as a heading cannot be selected, so report no selections. +static gint fl_accessible_node_get_n_selections(AtkText* text) { + (void)text; + return 0; +} + +// Implements AtkText::get_selection. +static gchar* fl_accessible_node_get_selection(AtkText* text, + gint selection_num, + gint* start_offset, + gint* end_offset) { + (void)text; + (void)selection_num; + if (start_offset != nullptr) { + *start_offset = 0; + } + if (end_offset != nullptr) { + *end_offset = 0; + } + return nullptr; +} + +// Implements AtkText::get_string_at_offset. +static gchar* fl_accessible_node_get_string_at_offset( + AtkText* text, + gint offset, + AtkTextGranularity granularity, + gint* start_offset, + gint* end_offset) { + FlAccessibleNodePrivate* priv = FL_ACCESSIBLE_NODE_GET_PRIVATE(text); + gint count = fl_accessible_node_get_text_length(priv); + if (offset < 0 || offset >= count) { + if (start_offset != nullptr) { + *start_offset = 0; + } + if (end_offset != nullptr) { + *end_offset = 0; + } + return nullptr; + } + + switch (granularity) { + case ATK_TEXT_GRANULARITY_CHAR: + if (start_offset != nullptr) { + *start_offset = offset; + } + if (end_offset != nullptr) { + *end_offset = offset + 1; + } + return g_utf8_substring(priv->name, offset, offset + 1); + case ATK_TEXT_GRANULARITY_WORD: + case ATK_TEXT_GRANULARITY_SENTENCE: + case ATK_TEXT_GRANULARITY_LINE: + case ATK_TEXT_GRANULARITY_PARAGRAPH: + if (start_offset != nullptr) { + *start_offset = 0; + } + if (end_offset != nullptr) { + *end_offset = count; + } + return g_strdup(priv->name != nullptr ? priv->name : ""); + default: + return nullptr; + } +} + +// Implements AtkText::get_text_at_offset (deprecated, but still used by some +// ATK clients to read the text content of non-editable objects such as +// headings). +static gchar* fl_accessible_node_get_text_at_offset( + AtkText* text, + gint offset, + AtkTextBoundary boundary_type, + gint* start_offset, + gint* end_offset) { + switch (boundary_type) { + case ATK_TEXT_BOUNDARY_CHAR: + return fl_accessible_node_get_string_at_offset( + text, offset, ATK_TEXT_GRANULARITY_CHAR, start_offset, end_offset); + case ATK_TEXT_BOUNDARY_WORD_START: + case ATK_TEXT_BOUNDARY_WORD_END: + return fl_accessible_node_get_string_at_offset( + text, offset, ATK_TEXT_GRANULARITY_WORD, start_offset, end_offset); + case ATK_TEXT_BOUNDARY_SENTENCE_START: + case ATK_TEXT_BOUNDARY_SENTENCE_END: + return fl_accessible_node_get_string_at_offset( + text, offset, ATK_TEXT_GRANULARITY_SENTENCE, start_offset, + end_offset); + case ATK_TEXT_BOUNDARY_LINE_START: + case ATK_TEXT_BOUNDARY_LINE_END: + return fl_accessible_node_get_string_at_offset( + text, offset, ATK_TEXT_GRANULARITY_LINE, start_offset, end_offset); + default: + return nullptr; + } +} + +static void fl_accessible_node_text_interface_init(AtkTextIface* iface) { + iface->get_character_count = fl_accessible_node_get_character_count; + iface->get_text = fl_accessible_node_get_text; + iface->get_character_at_offset = fl_accessible_node_get_character_at_offset; + iface->get_caret_offset = fl_accessible_node_get_caret_offset; + iface->get_n_selections = fl_accessible_node_get_n_selections; + iface->get_selection = fl_accessible_node_get_selection; + iface->get_text_at_offset = fl_accessible_node_get_text_at_offset; + iface->get_string_at_offset = fl_accessible_node_get_string_at_offset; +} + static void fl_accessible_node_init(FlAccessibleNode* self) { FlAccessibleNodePrivate* priv = FL_ACCESSIBLE_NODE_GET_PRIVATE(self); g_weak_ref_init(&priv->engine, nullptr); diff --git a/engine/src/flutter/shell/platform/linux/fl_accessible_node_test.cc b/engine/src/flutter/shell/platform/linux/fl_accessible_node_test.cc index 7f25e0b9277ff..3c2af7655fc97 100644 --- a/engine/src/flutter/shell/platform/linux/fl_accessible_node_test.cc +++ b/engine/src/flutter/shell/platform/linux/fl_accessible_node_test.cc @@ -336,6 +336,64 @@ TEST_F(FlAccessibleNodeTest, GetRole) { flags6.is_obscured = true; fl_accessible_node_set_flags(node, &flags6); EXPECT_EQ(atk_object_get_role(ATK_OBJECT(node)), ATK_ROLE_PASSWORD_TEXT); + + FlutterSemanticsFlags flags7 = {}; + flags7.is_header = true; + fl_accessible_node_set_flags(node, &flags7); + EXPECT_EQ(atk_object_get_role(ATK_OBJECT(node)), ATK_ROLE_HEADING); +} + +// Checks the node's name is exposed through the AtkText interface so that +// screen readers can read the text content of objects such as headings. +TEST_F(FlAccessibleNodeTest, ExposesTextViaAtkText) { + g_autoptr(FlAccessibleNode) node = fl_accessible_node_new(engine, 123, 0); + fl_accessible_node_set_name(node, "Section One"); + + EXPECT_EQ(atk_text_get_character_count(ATK_TEXT(node)), 11); + + g_autofree gchar* text = atk_text_get_text(ATK_TEXT(node), 0, -1); + EXPECT_STREQ(text, "Section One"); + + g_autofree gchar* substring = atk_text_get_text(ATK_TEXT(node), 0, 7); + EXPECT_STREQ(substring, "Section"); + + EXPECT_EQ(atk_text_get_character_at_offset(ATK_TEXT(node), 0), gunichar('S')); + + EXPECT_EQ(atk_text_get_caret_offset(ATK_TEXT(node)), -1); + EXPECT_EQ(atk_text_get_n_selections(ATK_TEXT(node)), 0); + + gint start_offset = -1; + gint end_offset = -1; + g_autofree gchar* selection = + atk_text_get_selection(ATK_TEXT(node), 0, &start_offset, &end_offset); + EXPECT_EQ(selection, nullptr); + EXPECT_EQ(start_offset, 0); + EXPECT_EQ(end_offset, 0); + + start_offset = -1; + end_offset = -1; + g_autofree gchar* text_at_offset = atk_text_get_string_at_offset( + ATK_TEXT(node), 0, ATK_TEXT_GRANULARITY_CHAR, &start_offset, &end_offset); + EXPECT_STREQ(text_at_offset, "S"); + EXPECT_EQ(start_offset, 0); + EXPECT_EQ(end_offset, 1); + + start_offset = -1; + end_offset = -1; + g_autofree gchar* text_at_line_offset = atk_text_get_string_at_offset( + ATK_TEXT(node), 0, ATK_TEXT_GRANULARITY_LINE, &start_offset, &end_offset); + EXPECT_STREQ(text_at_line_offset, "Section One"); + EXPECT_EQ(start_offset, 0); + EXPECT_EQ(end_offset, 11); + + start_offset = -1; + end_offset = -1; + g_autofree gchar* text_out_of_bounds = atk_text_get_string_at_offset( + ATK_TEXT(node), 100, ATK_TEXT_GRANULARITY_CHAR, &start_offset, + &end_offset); + EXPECT_EQ(text_out_of_bounds, nullptr); + EXPECT_EQ(start_offset, 0); + EXPECT_EQ(end_offset, 0); } // Checks Flutter actions are mapped to the appropriate ATK actions. diff --git a/engine/src/flutter/shell/platform/linux/fl_dart_project.cc b/engine/src/flutter/shell/platform/linux/fl_dart_project.cc index 43d98070a8906..6c626c61105a1 100644 --- a/engine/src/flutter/shell/platform/linux/fl_dart_project.cc +++ b/engine/src/flutter/shell/platform/linux/fl_dart_project.cc @@ -16,6 +16,7 @@ struct _FlDartProject { FlUIThreadPolicy ui_thread_policy; gboolean enable_impeller; + gboolean enable_flutter_gpu; }; G_DEFINE_TYPE(FlDartProject, fl_dart_project, G_TYPE_OBJECT) @@ -145,3 +146,16 @@ gboolean fl_dart_project_get_enable_impeller(FlDartProject* project) { g_return_val_if_fail(FL_IS_DART_PROJECT(project), FALSE); return project->enable_impeller; } + +G_MODULE_EXPORT +void fl_dart_project_set_enable_flutter_gpu(FlDartProject* project, + gboolean enable_flutter_gpu) { + g_return_if_fail(FL_IS_DART_PROJECT(project)); + project->enable_flutter_gpu = enable_flutter_gpu; +} + +G_MODULE_EXPORT +gboolean fl_dart_project_get_enable_flutter_gpu(FlDartProject* project) { + g_return_val_if_fail(FL_IS_DART_PROJECT(project), FALSE); + return project->enable_flutter_gpu; +} diff --git a/engine/src/flutter/shell/platform/linux/fl_dart_project_test.cc b/engine/src/flutter/shell/platform/linux/fl_dart_project_test.cc index dc0d1d2a9498b..93e570e535e79 100644 --- a/engine/src/flutter/shell/platform/linux/fl_dart_project_test.cc +++ b/engine/src/flutter/shell/platform/linux/fl_dart_project_test.cc @@ -80,3 +80,13 @@ TEST_F(FlDartProjectTest, EnableImpeller) { fl_dart_project_set_enable_impeller(project, TRUE); EXPECT_TRUE(fl_dart_project_get_enable_impeller(project)); } + +TEST_F(FlDartProjectTest, EnableFlutterGpu) { + EXPECT_FALSE(fl_dart_project_get_enable_flutter_gpu(project)); + + fl_dart_project_set_enable_flutter_gpu(project, TRUE); + EXPECT_TRUE(fl_dart_project_get_enable_flutter_gpu(project)); + + fl_dart_project_set_enable_flutter_gpu(project, FALSE); + EXPECT_FALSE(fl_dart_project_get_enable_flutter_gpu(project)); +} diff --git a/engine/src/flutter/shell/platform/linux/fl_engine.cc b/engine/src/flutter/shell/platform/linux/fl_engine.cc index 6c7b6d2ae3ccf..f88d08cb05f47 100644 --- a/engine/src/flutter/shell/platform/linux/fl_engine.cc +++ b/engine/src/flutter/shell/platform/linux/fl_engine.cc @@ -812,9 +812,12 @@ gboolean fl_engine_start(FlEngine* self, GError** error) { break; } + const std::vector env_switches = + flutter::GetSwitchesFromEnvironment(); + gboolean enable_impeller = fl_dart_project_get_enable_impeller(self->project); gboolean has_enable_impeller = FALSE; - for (const auto& env_switch : flutter::GetSwitchesFromEnvironment()) { + for (const auto& env_switch : env_switches) { if (env_switch == "--enable-impeller" || env_switch == "--enable-impeller=true") { enable_impeller = TRUE; @@ -828,7 +831,7 @@ gboolean fl_engine_start(FlEngine* self, GError** error) { g_autoptr(GPtrArray) command_line_args = g_ptr_array_new_with_free_func(g_free); g_ptr_array_insert(command_line_args, 0, g_strdup("flutter")); - for (const auto& env_switch : flutter::GetSwitchesFromEnvironment()) { + for (const auto& env_switch : env_switches) { g_ptr_array_add(command_line_args, g_strdup(env_switch.c_str())); } // Linux (and other desktop platforms) always uses SDFs. @@ -838,6 +841,23 @@ gboolean fl_engine_start(FlEngine* self, GError** error) { g_ptr_array_add(command_line_args, g_strdup("--enable-impeller")); } + // Forward the project's Flutter GPU setting unless an environment switch + // already carries it (the switch is presence based, so it is only ever + // added, never negated). + if (fl_dart_project_get_enable_flutter_gpu(self->project)) { + gboolean has_enable_flutter_gpu = FALSE; + for (const auto& env_switch : env_switches) { + if (env_switch == "--enable-flutter-gpu" || + env_switch == "--enable-flutter-gpu=true") { + has_enable_flutter_gpu = TRUE; + break; + } + } + if (!has_enable_flutter_gpu) { + g_ptr_array_add(command_line_args, g_strdup("--enable-flutter-gpu")); + } + } + gchar** dart_entrypoint_args = fl_dart_project_get_dart_entrypoint_arguments(self->project); diff --git a/engine/src/flutter/shell/platform/linux/fl_engine_test.cc b/engine/src/flutter/shell/platform/linux/fl_engine_test.cc index 2e32e301a84bc..dd2528e248223 100644 --- a/engine/src/flutter/shell/platform/linux/fl_engine_test.cc +++ b/engine/src/flutter/shell/platform/linux/fl_engine_test.cc @@ -1008,6 +1008,58 @@ TEST_F(FlEngineTest, DisableImpeller) { EXPECT_TRUE(called); } +TEST_F(FlEngineTest, EnableFlutterGpuDefault) { + bool called = false; + fl_engine_get_embedder_api(engine)->Initialize = MOCK_ENGINE_PROC( + Initialize, + ([&called](size_t version, const FlutterRendererConfig* config, + const FlutterProjectArgs* args, void* user_data, + FLUTTER_API_SYMBOL(FlutterEngine) * engine_out) { + called = true; + bool has_flutter_gpu_switch = false; + for (int i = 0; i < args->command_line_argc; i++) { + if (strcmp(args->command_line_argv[i], "--enable-flutter-gpu") == 0) { + has_flutter_gpu_switch = true; + } + } + EXPECT_FALSE(has_flutter_gpu_switch); + return kSuccess; + })); + fl_engine_get_embedder_api(engine)->RunInitialized = + MOCK_ENGINE_PROC(RunInitialized, ([](auto engine) { return kSuccess; })); + + StartEngine(); + EXPECT_TRUE(called); +} + +TEST_F(FlEngineTest, EnableFlutterGpu) { + fl_dart_project_set_enable_flutter_gpu(project, TRUE); + + bool called = false; + fl_engine_get_embedder_api(engine)->Initialize = MOCK_ENGINE_PROC( + Initialize, + ([&called](size_t version, const FlutterRendererConfig* config, + const FlutterProjectArgs* args, void* user_data, + FLUTTER_API_SYMBOL(FlutterEngine) * engine_out) { + called = true; + bool has_flutter_gpu_switch = false; + for (int i = 0; i < args->command_line_argc; i++) { + if (strcmp(args->command_line_argv[i], "--enable-flutter-gpu") == 0) { + has_flutter_gpu_switch = true; + } + } + EXPECT_TRUE(has_flutter_gpu_switch); + return kSuccess; + })); + fl_engine_get_embedder_api(engine)->RunInitialized = + MOCK_ENGINE_PROC(RunInitialized, ([](auto engine) { return kSuccess; })); + + g_autoptr(GError) error = nullptr; + EXPECT_TRUE(fl_engine_start(engine, &error)); + EXPECT_EQ(error, nullptr); + EXPECT_TRUE(called); +} + TEST_F(FlEngineTest, ChildObjects) { // Check objects exist before engine started. EXPECT_NE(fl_engine_get_binary_messenger(engine), nullptr); diff --git a/engine/src/flutter/shell/platform/linux/fl_touch_manager.cc b/engine/src/flutter/shell/platform/linux/fl_touch_manager.cc index eca5576c2a863..31918575cd6ab 100644 --- a/engine/src/flutter/shell/platform/linux/fl_touch_manager.cc +++ b/engine/src/flutter/shell/platform/linux/fl_touch_manager.cc @@ -102,7 +102,7 @@ static uint32_t get_generated_id(_FlTouchManager* self, uint32_t number) { } return id; } - auto values = g_hash_table_get_values(self->number_to_id); + g_autoptr(GList) values = g_hash_table_get_values(self->number_to_id); while (values != nullptr && g_list_find(values, GUINT_TO_POINTER(self->min_touch_device_id)) != nullptr && diff --git a/engine/src/flutter/shell/platform/linux/public/flutter_linux/fl_dart_project.h b/engine/src/flutter/shell/platform/linux/public/flutter_linux/fl_dart_project.h index 0b50eac76024b..4b83173a3557d 100644 --- a/engine/src/flutter/shell/platform/linux/public/flutter_linux/fl_dart_project.h +++ b/engine/src/flutter/shell/platform/linux/public/flutter_linux/fl_dart_project.h @@ -178,6 +178,25 @@ void fl_dart_project_set_enable_impeller(FlDartProject* project, */ gboolean fl_dart_project_get_enable_impeller(FlDartProject* project); +/** + * fl_dart_project_set_enable_flutter_gpu: + * @project: an #FlDartProject. + * @enable_flutter_gpu: whether to enable the Flutter GPU API. + * + * Sets whether the Flutter GPU API (package:flutter_gpu) should be enabled. + * Flutter GPU requires the Impeller renderer. + */ +void fl_dart_project_set_enable_flutter_gpu(FlDartProject* project, + gboolean enable_flutter_gpu); + +/** + * fl_dart_project_get_enable_flutter_gpu: + * @project: an #FlDartProject. + * + * Returns: %TRUE if the Flutter GPU API is enabled. + */ +gboolean fl_dart_project_get_enable_flutter_gpu(FlDartProject* project); + G_END_DECLS #endif // FLUTTER_SHELL_PLATFORM_LINUX_PUBLIC_FLUTTER_LINUX_FL_DART_PROJECT_H_ diff --git a/engine/src/flutter/shell/platform/windows/client_wrapper/dart_project_unittests.cc b/engine/src/flutter/shell/platform/windows/client_wrapper/dart_project_unittests.cc index bfc151947f673..e2a8746bb20a4 100644 --- a/engine/src/flutter/shell/platform/windows/client_wrapper/dart_project_unittests.cc +++ b/engine/src/flutter/shell/platform/windows/client_wrapper/dart_project_unittests.cc @@ -55,4 +55,15 @@ TEST_F(DartProjectTest, DartEntrypointArguments) { EXPECT_EQ(returned_arguments[2], "arg3"); } +TEST_F(DartProjectTest, EnableFlutterGpu) { + DartProject project(L"test"); + EXPECT_FALSE(project.enable_flutter_gpu()); + + project.set_enable_flutter_gpu(true); + EXPECT_TRUE(project.enable_flutter_gpu()); + + project.set_enable_flutter_gpu(false); + EXPECT_FALSE(project.enable_flutter_gpu()); +} + } // namespace flutter diff --git a/engine/src/flutter/shell/platform/windows/client_wrapper/flutter_engine.cc b/engine/src/flutter/shell/platform/windows/client_wrapper/flutter_engine.cc index f5340fc558ea8..fb2a31ddf5761 100644 --- a/engine/src/flutter/shell/platform/windows/client_wrapper/flutter_engine.cc +++ b/engine/src/flutter/shell/platform/windows/client_wrapper/flutter_engine.cc @@ -27,6 +27,7 @@ FlutterEngine::FlutterEngine(const DartProject& project) { project.accessibility_mode()); c_engine_properties.impeller_switch = static_cast(project.impeller_switch()); + c_engine_properties.enable_flutter_gpu = project.enable_flutter_gpu(); const std::vector& entrypoint_args = project.dart_entrypoint_arguments(); diff --git a/engine/src/flutter/shell/platform/windows/client_wrapper/include/flutter/dart_project.h b/engine/src/flutter/shell/platform/windows/client_wrapper/include/flutter/dart_project.h index 4a2e830cafb57..2dc37d201fbc9 100644 --- a/engine/src/flutter/shell/platform/windows/client_wrapper/include/flutter/dart_project.h +++ b/engine/src/flutter/shell/platform/windows/client_wrapper/include/flutter/dart_project.h @@ -153,6 +153,16 @@ class DartProject { // Defaults to ImpellerSwitch::Default. ImpellerSwitch impeller_switch() const { return impeller_switch_; } + // Sets whether the Flutter GPU API (package:flutter_gpu) is enabled. + // Flutter GPU requires the Impeller renderer. + void set_enable_flutter_gpu(bool enable_flutter_gpu) { + enable_flutter_gpu_ = enable_flutter_gpu; + } + + // Returns whether the Flutter GPU API is enabled. + // Defaults to false. + bool enable_flutter_gpu() const { return enable_flutter_gpu_; } + private: // Accessors for internals are private, so that they can be changed if more // flexible options for project structures are needed later without it @@ -185,6 +195,8 @@ class DartProject { AccessibilityMode accessibility_mode_ = AccessibilityMode::Default; // The Impeller enablement switch. ImpellerSwitch impeller_switch_ = ImpellerSwitch::Default; + // Whether the Flutter GPU API is enabled. + bool enable_flutter_gpu_ = false; }; } // namespace flutter diff --git a/engine/src/flutter/shell/platform/windows/compositor_opengl.cc b/engine/src/flutter/shell/platform/windows/compositor_opengl.cc index 33f87711668e2..d1b1f5bc71a9f 100644 --- a/engine/src/flutter/shell/platform/windows/compositor_opengl.cc +++ b/engine/src/flutter/shell/platform/windows/compositor_opengl.cc @@ -5,6 +5,7 @@ #include "flutter/shell/platform/windows/compositor_opengl.h" #include "GLES3/gl3.h" +#include "flutter/fml/logging.h" #include "flutter/shell/platform/windows/flutter_windows_engine.h" #include "flutter/shell/platform/windows/flutter_windows_view.h" @@ -18,6 +19,7 @@ constexpr uint32_t kWindowFrameBufferId = 0; struct FramebufferBackingStore { uint32_t framebuffer_id = 0; uint32_t texture_id = 0; + uint32_t color_renderbuffer_id = 0; uint32_t depth_stencil_id = 0; }; @@ -51,48 +53,94 @@ bool CompositorOpenGL::CreateBackingStore( auto store = std::make_unique(); - gl_->GenTextures(1, &store->texture_id); gl_->GenFramebuffers(1, &store->framebuffer_id); - gl_->BindFramebuffer(GL_FRAMEBUFFER, store->framebuffer_id); - gl_->BindTexture(GL_TEXTURE_2D, store->texture_id); - gl_->TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); - gl_->TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); - gl_->TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); - gl_->TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); - gl_->TexImage2D(GL_TEXTURE_2D, 0, format_.general_format, config.size.width, - config.size.height, 0, format_.general_format, - GL_UNSIGNED_BYTE, nullptr); - gl_->BindTexture(GL_TEXTURE_2D, 0); - if (enable_impeller_) { if (supports_implicit_msaa_) { + gl_->GenTextures(1, &store->texture_id); + gl_->BindTexture(GL_TEXTURE_2D, store->texture_id); + gl_->TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + gl_->TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + gl_->TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + gl_->TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + gl_->TexImage2D(GL_TEXTURE_2D, 0, format_.sized_format, config.size.width, + config.size.height, 0, format_.general_format, + GL_UNSIGNED_BYTE, nullptr); + gl_->BindTexture(GL_TEXTURE_2D, 0); + // MSAA color attachment gl_->FramebufferTexture2DMultisampleEXT( GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, store->texture_id, 0, 4); - } else { - gl_->FramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, - GL_TEXTURE_2D, store->texture_id, 0); - } - // Impeller always requires depth/stencil attachment. - gl_->GenRenderbuffers(1, &store->depth_stencil_id); - gl_->BindRenderbuffer(GL_RENDERBUFFER, store->depth_stencil_id); - if (supports_implicit_msaa_) { + // Impeller always requires depth/stencil attachment. + gl_->GenRenderbuffers(1, &store->depth_stencil_id); + gl_->BindRenderbuffer(GL_RENDERBUFFER, store->depth_stencil_id); gl_->RenderbufferStorageMultisampleEXT( GL_RENDERBUFFER, 4, GL_DEPTH24_STENCIL8, config.size.width, config.size.height); + gl_->FramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, + GL_RENDERBUFFER, store->depth_stencil_id); + gl_->FramebufferRenderbuffer(GL_FRAMEBUFFER, GL_STENCIL_ATTACHMENT, + GL_RENDERBUFFER, store->depth_stencil_id); + } else if (supports_offscreen_msaa_) { + // MSAA color renderbuffer attachment + gl_->GenRenderbuffers(1, &store->color_renderbuffer_id); + gl_->BindRenderbuffer(GL_RENDERBUFFER, store->color_renderbuffer_id); + gl_->RenderbufferStorageMultisample( + GL_RENDERBUFFER, 4, format_.sized_format, config.size.width, + config.size.height); + gl_->FramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, + GL_RENDERBUFFER, + store->color_renderbuffer_id); + + // MSAA depth/stencil attachment + gl_->GenRenderbuffers(1, &store->depth_stencil_id); + gl_->BindRenderbuffer(GL_RENDERBUFFER, store->depth_stencil_id); + gl_->RenderbufferStorageMultisample( + GL_RENDERBUFFER, 4, GL_DEPTH24_STENCIL8, config.size.width, + config.size.height); + gl_->FramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, + GL_RENDERBUFFER, store->depth_stencil_id); + gl_->FramebufferRenderbuffer(GL_FRAMEBUFFER, GL_STENCIL_ATTACHMENT, + GL_RENDERBUFFER, store->depth_stencil_id); } else { + gl_->GenTextures(1, &store->texture_id); + gl_->BindTexture(GL_TEXTURE_2D, store->texture_id); + gl_->TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + gl_->TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + gl_->TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + gl_->TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + gl_->TexImage2D(GL_TEXTURE_2D, 0, format_.sized_format, config.size.width, + config.size.height, 0, format_.general_format, + GL_UNSIGNED_BYTE, nullptr); + gl_->BindTexture(GL_TEXTURE_2D, 0); + + gl_->FramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, + GL_TEXTURE_2D, store->texture_id, 0); + + gl_->GenRenderbuffers(1, &store->depth_stencil_id); + gl_->BindRenderbuffer(GL_RENDERBUFFER, store->depth_stencil_id); gl_->RenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH24_STENCIL8, config.size.width, config.size.height); + gl_->FramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, + GL_RENDERBUFFER, store->depth_stencil_id); + gl_->FramebufferRenderbuffer(GL_FRAMEBUFFER, GL_STENCIL_ATTACHMENT, + GL_RENDERBUFFER, store->depth_stencil_id); } - gl_->FramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, - GL_RENDERBUFFER, store->depth_stencil_id); - gl_->FramebufferRenderbuffer(GL_FRAMEBUFFER, GL_STENCIL_ATTACHMENT, - GL_RENDERBUFFER, store->depth_stencil_id); } else { + gl_->GenTextures(1, &store->texture_id); + gl_->BindTexture(GL_TEXTURE_2D, store->texture_id); + gl_->TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + gl_->TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + gl_->TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + gl_->TexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + gl_->TexImage2D(GL_TEXTURE_2D, 0, format_.sized_format, config.size.width, + config.size.height, 0, format_.general_format, + GL_UNSIGNED_BYTE, nullptr); + gl_->BindTexture(GL_TEXTURE_2D, 0); + gl_->FramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, store->texture_id, 0); } @@ -118,8 +166,12 @@ bool CompositorOpenGL::CollectBackingStore(const FlutterBackingStore* store) { store->open_gl.framebuffer.user_data); gl_->DeleteFramebuffers(1, &user_data->framebuffer_id); - gl_->DeleteTextures(1, &user_data->texture_id); - + if (user_data->texture_id != 0) { + gl_->DeleteTextures(1, &user_data->texture_id); + } + if (user_data->color_renderbuffer_id != 0) { + gl_->DeleteRenderbuffers(1, &user_data->color_renderbuffer_id); + } if (user_data->depth_stencil_id != 0) { gl_->DeleteRenderbuffers(1, &user_data->depth_stencil_id); } @@ -237,6 +289,15 @@ bool CompositorOpenGL::Initialize() { supports_implicit_msaa_ = gl_->GetCapabilities()->SupportsImplicitResolvingMSAA(); + supports_offscreen_msaa_ = gl_->GetCapabilities()->SupportsOffscreenMSAA(); + + if (enable_impeller_ && !supports_implicit_msaa_ && + !supports_offscreen_msaa_) { + // TODO(190362): I suspect this branch is never taken since ANGLE will + // support offscreen MSAA. We should investigate removing this branch and + // the implicit MSAA branch in the rendering functions. + FML_LOG(WARNING) << "Rendering without MSAA."; + } is_initialized_ = true; return true; diff --git a/engine/src/flutter/shell/platform/windows/compositor_opengl.h b/engine/src/flutter/shell/platform/windows/compositor_opengl.h index eaf702c2065b4..0304d168d4287 100644 --- a/engine/src/flutter/shell/platform/windows/compositor_opengl.h +++ b/engine/src/flutter/shell/platform/windows/compositor_opengl.h @@ -66,6 +66,9 @@ class CompositorOpenGL : public Compositor { // Whether the OpenGL context supports implicit MSAA. bool supports_implicit_msaa_ = false; + // Whether the OpenGL context supports offscreen MSAA. + bool supports_offscreen_msaa_ = false; + // Initialize the compositor. This must run on the raster thread. bool Initialize(); diff --git a/engine/src/flutter/shell/platform/windows/compositor_opengl_unittests.cc b/engine/src/flutter/shell/platform/windows/compositor_opengl_unittests.cc index 97fb7bd934b48..14c2d9adcbcf7 100644 --- a/engine/src/flutter/shell/platform/windows/compositor_opengl_unittests.cc +++ b/engine/src/flutter/shell/platform/windows/compositor_opengl_unittests.cc @@ -104,6 +104,39 @@ const impeller::ProcTableGLES::Resolver kMockResolverWithMSAA = return kMockResolver(name); }; +void MockGetIntegervWithOffscreenMSAA(GLenum name, int* value) { + if (name == GL_NUM_EXTENSIONS) { + *value = 1; + } else if (name == GL_MAX_SAMPLES) { + *value = 4; + } else { + *value = 0; + } +} + +const unsigned char* MockGetStringWithOffscreenMSAA(GLenum name) { + switch (name) { + case GL_VERSION: + return reinterpret_cast("OpenGL ES 3.0"); + case GL_SHADING_LANGUAGE_VERSION: + return reinterpret_cast("OpenGL ES GLSL ES 3.0"); + default: + return reinterpret_cast(""); + } +} + +const impeller::ProcTableGLES::Resolver kMockResolverWithOffscreenMSAA = + [](const char* name) -> void* { + std::string_view function_name{name}; + + if (function_name == "glGetString") { + return reinterpret_cast(&MockGetStringWithOffscreenMSAA); + } else if (function_name == "glGetIntegerv") { + return reinterpret_cast(&MockGetIntegervWithOffscreenMSAA); + } + return kMockResolver(name); +}; + class CompositorOpenGLTest : public WindowsTest { public: CompositorOpenGLTest() = default; @@ -254,6 +287,90 @@ TEST_F(CompositorOpenGLTest, CreateBackingStoreImpellerMSAA) { ASSERT_TRUE(compositor.CollectBackingStore(&backing_store)); } +// Verifies that when implicit MSAA is unsupported but offscreen MSAA is +// supported (OpenGL ES 3.0+ with GL_MAX_SAMPLES >= 4), CompositorOpenGL +// creates 4x MSAA color and depth/stencil renderbuffers. +TEST_F(CompositorOpenGLTest, CreateBackingStoreImpellerOffscreenMSAA) { + UseHeadlessEngine(); + + static int framebuffer_texture2d_calls = 0; + static int framebuffer_texture2d_multisample_calls = 0; + static int renderbuffer_storage_multisample_calls = 0; + static int renderbuffer_storage_multisample_samples = 0; + static int framebuffer_renderbuffer_calls = 0; + static int delete_renderbuffers_calls = 0; + static int delete_textures_calls = 0; + + framebuffer_texture2d_calls = 0; + framebuffer_texture2d_multisample_calls = 0; + renderbuffer_storage_multisample_calls = 0; + renderbuffer_storage_multisample_samples = 0; + framebuffer_renderbuffer_calls = 0; + delete_renderbuffers_calls = 0; + delete_textures_calls = 0; + + const impeller::ProcTableGLES::Resolver resolver = + [](const char* name) -> void* { + std::string_view function_name{name}; + if (function_name == "glFramebufferTexture2D") { + return reinterpret_cast( + +[](GLenum, GLenum, GLenum, GLuint, GLint) { + framebuffer_texture2d_calls++; + }); + } else if (function_name == "glFramebufferTexture2DMultisampleEXT") { + return reinterpret_cast( + +[](GLenum, GLenum, GLenum, GLuint, GLint, GLsizei) { + framebuffer_texture2d_multisample_calls++; + }); + } else if (function_name == "glGenRenderbuffers") { + return reinterpret_cast(+[](GLsizei n, GLuint* renderbuffers) { + static GLuint next_id = 1; + for (GLsizei i = 0; i < n; i++) { + renderbuffers[i] = next_id++; + } + }); + } else if (function_name == "glRenderbufferStorageMultisample") { + return reinterpret_cast(+[](GLenum target, GLsizei samples, + GLenum internalformat, GLsizei width, + GLsizei height) { + renderbuffer_storage_multisample_calls++; + renderbuffer_storage_multisample_samples = samples; + }); + } else if (function_name == "glFramebufferRenderbuffer") { + return reinterpret_cast(+[](GLenum, GLenum, GLenum, GLuint) { + framebuffer_renderbuffer_calls++; + }); + } else if (function_name == "glDeleteRenderbuffers") { + return reinterpret_cast( + +[](GLsizei n, const GLuint* renderbuffers) { + delete_renderbuffers_calls += n; + }); + } else if (function_name == "glDeleteTextures") { + return reinterpret_cast(+[](GLsizei n, const GLuint* textures) { + delete_textures_calls += n; + }); + } + return kMockResolverWithOffscreenMSAA(name); + }; + + auto compositor = + CompositorOpenGL{engine(), resolver, /*enable_impeller=*/true}; + FlutterBackingStoreConfig config = {}; + FlutterBackingStore backing_store = {}; + + EXPECT_CALL(*render_context(), MakeCurrent).WillOnce(Return(true)); + ASSERT_TRUE(compositor.CreateBackingStore(config, &backing_store)); + EXPECT_EQ(framebuffer_texture2d_calls, 0); + EXPECT_EQ(framebuffer_texture2d_multisample_calls, 0); + EXPECT_EQ(renderbuffer_storage_multisample_calls, 2); + EXPECT_EQ(renderbuffer_storage_multisample_samples, 4); + EXPECT_EQ(framebuffer_renderbuffer_calls, 3); + + ASSERT_TRUE(compositor.CollectBackingStore(&backing_store)); + EXPECT_EQ(delete_renderbuffers_calls, 2); + EXPECT_EQ(delete_textures_calls, 0); +} + TEST_F(CompositorOpenGLTest, InitializationFailure) { UseHeadlessEngine(); diff --git a/engine/src/flutter/shell/platform/windows/flutter_project_bundle.cc b/engine/src/flutter/shell/platform/windows/flutter_project_bundle.cc index 03f20fb586976..63cfead0e0d64 100644 --- a/engine/src/flutter/shell/platform/windows/flutter_project_bundle.cc +++ b/engine/src/flutter/shell/platform/windows/flutter_project_bundle.cc @@ -42,6 +42,8 @@ FlutterProjectBundle::FlutterProjectBundle( impeller_switch_ = static_cast(properties.impeller_switch); + enable_flutter_gpu_ = properties.enable_flutter_gpu; + // Resolve any relative paths. if (assets_path_.is_relative() || icu_path_.is_relative() || (!aot_library_path_.empty() && aot_library_path_.is_relative())) { diff --git a/engine/src/flutter/shell/platform/windows/flutter_project_bundle.h b/engine/src/flutter/shell/platform/windows/flutter_project_bundle.h index 59f7041d39da3..f504ea8555687 100644 --- a/engine/src/flutter/shell/platform/windows/flutter_project_bundle.h +++ b/engine/src/flutter/shell/platform/windows/flutter_project_bundle.h @@ -97,6 +97,9 @@ class FlutterProjectBundle { // Returns the Impeller enablement switch. FlutterImpellerSwitch impeller_switch() const { return impeller_switch_; } + // Returns whether the Flutter GPU API is enabled. + bool enable_flutter_gpu() const { return enable_flutter_gpu_; } + private: std::filesystem::path assets_path_; std::filesystem::path icu_path_; @@ -124,6 +127,9 @@ class FlutterProjectBundle { // The Impeller enablement switch. FlutterImpellerSwitch impeller_switch_ = FlutterImpellerSwitch::Default; + + // Whether the Flutter GPU API is enabled. + bool enable_flutter_gpu_ = false; }; } // namespace flutter diff --git a/engine/src/flutter/shell/platform/windows/flutter_project_bundle_unittests.cc b/engine/src/flutter/shell/platform/windows/flutter_project_bundle_unittests.cc index 607d1cc707d99..c30d7f1ee8a4d 100644 --- a/engine/src/flutter/shell/platform/windows/flutter_project_bundle_unittests.cc +++ b/engine/src/flutter/shell/platform/windows/flutter_project_bundle_unittests.cc @@ -34,6 +34,19 @@ TEST(FlutterProjectBundle, BasicPropertiesRelativePaths) { EXPECT_EQ(project.icu_path().filename().string(), "icudtl.dat"); } +TEST(FlutterProjectBundle, EnableFlutterGpu) { + FlutterDesktopEngineProperties properties = {}; + properties.assets_path = L"foo\\flutter_assets"; + properties.icu_data_path = L"foo\\icudtl.dat"; + + FlutterProjectBundle default_project(properties); + EXPECT_FALSE(default_project.enable_flutter_gpu()); + + properties.enable_flutter_gpu = true; + FlutterProjectBundle project(properties); + EXPECT_TRUE(project.enable_flutter_gpu()); +} + TEST(FlutterProjectBundle, SwitchesEmpty) { FlutterDesktopEngineProperties properties = {}; properties.assets_path = L"foo\\flutter_assets"; diff --git a/engine/src/flutter/shell/platform/windows/flutter_windows_engine.cc b/engine/src/flutter/shell/platform/windows/flutter_windows_engine.cc index 5275e5b612424..d9f797ca257ba 100644 --- a/engine/src/flutter/shell/platform/windows/flutter_windows_engine.cc +++ b/engine/src/flutter/shell/platform/windows/flutter_windows_engine.cc @@ -326,6 +326,16 @@ bool FlutterWindowsEngine::Run(std::string_view entrypoint) { switches.push_back("--enable-impeller=false"); } } + if (project_->enable_flutter_gpu()) { + if (std::find(switches.begin(), switches.end(), "--enable-flutter-gpu") == + switches.end() && + std::find(switches.begin(), switches.end(), + "--enable-flutter-gpu=true") == switches.end()) { + // Flutter GPU was enabled programmatically, so forward the switch to + // the engine. + switches.push_back("--enable-flutter-gpu"); + } + } std::transform( switches.begin(), switches.end(), std::back_inserter(argv), [](const std::string& arg) -> const char* { return arg.c_str(); }); diff --git a/engine/src/flutter/shell/platform/windows/flutter_windows_engine_unittests.cc b/engine/src/flutter/shell/platform/windows/flutter_windows_engine_unittests.cc index 23daedc82ed14..23e127cbc3dfa 100644 --- a/engine/src/flutter/shell/platform/windows/flutter_windows_engine_unittests.cc +++ b/engine/src/flutter/shell/platform/windows/flutter_windows_engine_unittests.cc @@ -534,6 +534,115 @@ TEST_F(FlutterWindowsEngineTest, RunWithProjectFlagEnableImpeller) { modifier.ReleaseEGLManager(); } +TEST_F(FlutterWindowsEngineTest, RunWithProjectFlagEnableFlutterGpu) { + FlutterWindowsEngineBuilder builder{GetContext()}; + builder.SetEnableFlutterGpu(true); + std::unique_ptr engine = builder.Build(); + EngineModifier modifier(engine.get()); + + modifier.embedder_api().NotifyDisplayUpdate = + MOCK_ENGINE_PROC(NotifyDisplayUpdate, + ([](FLUTTER_API_SYMBOL(FlutterEngine) raw_engine, + const FlutterEngineDisplaysUpdateType update_type, + const FlutterEngineDisplay* embedder_displays, + size_t display_count) { return kSuccess; })); + + modifier.embedder_api().UpdateAccessibilityFeatures = MOCK_ENGINE_PROC( + UpdateAccessibilityFeatures, + [](FLUTTER_API_SYMBOL(FlutterEngine) engine, + FlutterAccessibilityFeature flags) { return kSuccess; }); + + modifier.embedder_api().UpdateLocales = MOCK_ENGINE_PROC( + UpdateLocales, ([](auto engine, const FlutterLocale** locales, + size_t locales_count) { return kSuccess; })); + + modifier.embedder_api().SendPlatformMessage = + MOCK_ENGINE_PROC(SendPlatformMessage, + ([](auto engine, auto message) { return kSuccess; })); + + bool run_called = false; + modifier.embedder_api().Run = MOCK_ENGINE_PROC( + Run, ([&run_called](size_t version, const FlutterRendererConfig* config, + const FlutterProjectArgs* args, void* user_data, + FLUTTER_API_SYMBOL(FlutterEngine) * engine_out) { + run_called = true; + *engine_out = reinterpret_cast(1); + + bool has_flutter_gpu_switch = false; + for (int i = 0; i < args->command_line_argc; ++i) { + if (strcmp(args->command_line_argv[i], "--enable-flutter-gpu") == 0) { + has_flutter_gpu_switch = true; + } + } + EXPECT_TRUE(has_flutter_gpu_switch); + return kSuccess; + })); + + // Set the EGL manager to !nullptr to test ANGLE rendering. + modifier.SetEGLManager(std::make_unique()); + + engine->Run(); + + EXPECT_TRUE(run_called); + + modifier.embedder_api().Shutdown = [](auto engine) { return kSuccess; }; + modifier.ReleaseEGLManager(); +} + +TEST_F(FlutterWindowsEngineTest, RunWithoutProjectFlagEnableFlutterGpu) { + FlutterWindowsEngineBuilder builder{GetContext()}; + std::unique_ptr engine = builder.Build(); + EngineModifier modifier(engine.get()); + + modifier.embedder_api().NotifyDisplayUpdate = + MOCK_ENGINE_PROC(NotifyDisplayUpdate, + ([](FLUTTER_API_SYMBOL(FlutterEngine) raw_engine, + const FlutterEngineDisplaysUpdateType update_type, + const FlutterEngineDisplay* embedder_displays, + size_t display_count) { return kSuccess; })); + + modifier.embedder_api().UpdateAccessibilityFeatures = MOCK_ENGINE_PROC( + UpdateAccessibilityFeatures, + [](FLUTTER_API_SYMBOL(FlutterEngine) engine, + FlutterAccessibilityFeature flags) { return kSuccess; }); + + modifier.embedder_api().UpdateLocales = MOCK_ENGINE_PROC( + UpdateLocales, ([](auto engine, const FlutterLocale** locales, + size_t locales_count) { return kSuccess; })); + + modifier.embedder_api().SendPlatformMessage = + MOCK_ENGINE_PROC(SendPlatformMessage, + ([](auto engine, auto message) { return kSuccess; })); + + bool run_called = false; + modifier.embedder_api().Run = MOCK_ENGINE_PROC( + Run, ([&run_called](size_t version, const FlutterRendererConfig* config, + const FlutterProjectArgs* args, void* user_data, + FLUTTER_API_SYMBOL(FlutterEngine) * engine_out) { + run_called = true; + *engine_out = reinterpret_cast(1); + + bool has_flutter_gpu_switch = false; + for (int i = 0; i < args->command_line_argc; ++i) { + if (strcmp(args->command_line_argv[i], "--enable-flutter-gpu") == 0) { + has_flutter_gpu_switch = true; + } + } + EXPECT_FALSE(has_flutter_gpu_switch); + return kSuccess; + })); + + // Set the EGL manager to !nullptr to test ANGLE rendering. + modifier.SetEGLManager(std::make_unique()); + + engine->Run(); + + EXPECT_TRUE(run_called); + + modifier.embedder_api().Shutdown = [](auto engine) { return kSuccess; }; + modifier.ReleaseEGLManager(); +} + TEST_F(FlutterWindowsEngineTest, RunWithProjectFlagDisableImpeller) { FlutterWindowsEngineBuilder builder{GetContext()}; builder.SetImpellerSwitch(DisabledImpeller); diff --git a/engine/src/flutter/shell/platform/windows/public/flutter_windows.h b/engine/src/flutter/shell/platform/windows/public/flutter_windows.h index 54954042f47cd..2194cca17201b 100644 --- a/engine/src/flutter/shell/platform/windows/public/flutter_windows.h +++ b/engine/src/flutter/shell/platform/windows/public/flutter_windows.h @@ -128,6 +128,11 @@ typedef struct { // Policy for enabling the Impeller renderer. FlutterDesktopImpellerSwitch impeller_switch; + + // Whether to enable the Flutter GPU API (package:flutter_gpu). + // Flutter GPU requires the Impeller renderer. + // If not set defaults to false. + bool enable_flutter_gpu; } FlutterDesktopEngineProperties; // ========== View Controller ========== diff --git a/engine/src/flutter/shell/platform/windows/testing/flutter_windows_engine_builder.cc b/engine/src/flutter/shell/platform/windows/testing/flutter_windows_engine_builder.cc index 62c1a407ff2e8..28a302a2d6e82 100644 --- a/engine/src/flutter/shell/platform/windows/testing/flutter_windows_engine_builder.cc +++ b/engine/src/flutter/shell/platform/windows/testing/flutter_windows_engine_builder.cc @@ -75,6 +75,10 @@ void FlutterWindowsEngineBuilder::SetImpellerSwitch( properties_.impeller_switch = impeller_switch; } +void FlutterWindowsEngineBuilder::SetEnableFlutterGpu(bool enable_flutter_gpu) { + properties_.enable_flutter_gpu = enable_flutter_gpu; +} + void FlutterWindowsEngineBuilder::SetCreateKeyboardHandlerCallbacks( KeyboardKeyEmbedderHandler::GetKeyStateHandler get_key_state, KeyboardKeyEmbedderHandler::MapVirtualKeyToScanCode map_vk_to_scan) { diff --git a/engine/src/flutter/shell/platform/windows/testing/flutter_windows_engine_builder.h b/engine/src/flutter/shell/platform/windows/testing/flutter_windows_engine_builder.h index a10d11394d7ae..2f2ef89ea7df1 100644 --- a/engine/src/flutter/shell/platform/windows/testing/flutter_windows_engine_builder.h +++ b/engine/src/flutter/shell/platform/windows/testing/flutter_windows_engine_builder.h @@ -33,6 +33,8 @@ class FlutterWindowsEngineBuilder { void SetImpellerSwitch(FlutterDesktopImpellerSwitch impeller_switch); + void SetEnableFlutterGpu(bool enable_flutter_gpu); + void SetWindowsProcTable( std::shared_ptr windows_proc_table); diff --git a/engine/src/flutter/tools/android_lint/baseline.xml b/engine/src/flutter/tools/android_lint/baseline.xml index a374b1cd795a1..74693cb45f5d9 100644 --- a/engine/src/flutter/tools/android_lint/baseline.xml +++ b/engine/src/flutter/tools/android_lint/baseline.xml @@ -1,26 +1,17 @@ - - - + + id="OldTargetApi" + message="Not targeting the latest versions of Android; compatibility modes apply. Consider testing and updating this version. Consult the android.os.Build.VERSION_CODES javadoc for details." + errorLine1=" <uses-sdk android:minSdkVersion="24" android:targetSdkVersion="36" />" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + file="../../../flutter/shell/platform/android/AndroidManifest.xml" + line="8" + column="42"/> diff --git a/engine/src/flutter/tools/android_sdk/packages.txt b/engine/src/flutter/tools/android_sdk/packages.txt index 96dd5a5dba6e6..88fe7f94aa424 100644 --- a/engine/src/flutter/tools/android_sdk/packages.txt +++ b/engine/src/flutter/tools/android_sdk/packages.txt @@ -2,6 +2,5 @@ platforms;android-37.0,platforms;android-36,platforms;android-35,platforms;andro cmdline-tools;latest:cmdline-tools build-tools;37.0.0,build-tools;36.1.0,build-tools;36.0.0,build-tools;35.0.0,build-tools;34.0.0,build-tools;33.0.1:build-tools platform-tools:platform-tools -tools:tools cmake;3.22.1:cmake ndk;28.2.13676358:ndk diff --git a/packages/flutter/lib/fix_data/fix_cupertino.yaml b/packages/flutter/lib/fix_data/fix_cupertino.yaml index 424b098399ff8..2990856dabcf6 100644 --- a/packages/flutter/lib/fix_data/fix_cupertino.yaml +++ b/packages/flutter/lib/fix_data/fix_cupertino.yaml @@ -18,6 +18,14 @@ # * Fixes in this file are from the Cupertino library. * version: 1 transforms: + # TODO(Piinks): Link eventual deprecation PR, add testing support once available. + - title: 'Migrate from flutter/cupertino.dart to cupertino_ui/cupertino_ui.dart.' + date: 2026-07-08 + library: 'package:flutter/cupertino.dart' + changes: + - kind: 'replacedBy' + newLibrary: 'package:cupertino_ui/cupertino_ui.dart' + # Change made in https://github.com/flutter/flutter/pull/20649 # TODO(Piinks): Add tests when `bulkApply:false` testing is supported, https://github.com/dart-lang/sdk/issues/44639 - title: "Replace with 'CupertinoPopupSurface'" diff --git a/packages/flutter/lib/fix_data/fix_material/fix_material.yaml b/packages/flutter/lib/fix_data/fix_material/fix_material.yaml index bf3919c9ceffb..d40aa36ca96eb 100644 --- a/packages/flutter/lib/fix_data/fix_material/fix_material.yaml +++ b/packages/flutter/lib/fix_data/fix_material/fix_material.yaml @@ -26,6 +26,14 @@ # * WidgetState: fix_widget_state.yaml version: 1 transforms: + # TODO(Piinks): Link eventual deprecation PR, add testing support once available. + - title: 'Migrate from flutter/material.dart to material_ui/material_ui.dart.' + date: 2026-07-08 + library: 'package:flutter/material.dart' + changes: + - kind: 'replacedBy' + newLibrary: 'package:material_ui/material_ui.dart' + # Changes made in https://github.com/flutter/flutter/pull/179776 - title: 'Replaced by cupertino CupertinoPageTransitionsBuilder' date: 2026-01-15 diff --git a/packages/flutter/lib/src/widgets/actions.dart b/packages/flutter/lib/src/widgets/actions.dart index 6cd66fc1cb0ce..82ee58e849564 100644 --- a/packages/flutter/lib/src/widgets/actions.dart +++ b/packages/flutter/lib/src/widgets/actions.dart @@ -809,7 +809,7 @@ class Actions extends StatefulWidget { /// returned callback is called. If the return value is needed, consider using /// [Actions.invoke] instead. static VoidCallback? handler(BuildContext context, T intent) { - final Action? action = Actions.maybeFind(context); + final Action? action = Actions.maybeFind(context, intent: intent); if (action != null && action._isEnabled(intent, context)) { return () { // Could be that the action was enabled when the closure was created, diff --git a/packages/flutter/test/widgets/actions_test.dart b/packages/flutter/test/widgets/actions_test.dart index f83ee52210054..49cc31b6f3c4f 100644 --- a/packages/flutter/test/widgets/actions_test.dart +++ b/packages/flutter/test/widgets/actions_test.dart @@ -317,6 +317,64 @@ void main() { expect(Actions.maybeFind(containerKey.currentContext!), isNull); }); + testWidgets('Actions.handler forwards the intent type and finds the bound action', ( + WidgetTester tester, + ) async { + // Regression test for https://github.com/flutter/flutter/issues/191045. + final GlobalKey containerKey = GlobalKey(); + var invoked = false; + final testAction = TestAction( + onInvoke: (Intent intent) { + invoked = true; + return invoked; + }, + ); + await tester.pumpWidget( + Actions( + actions: >{TestIntent: testAction}, + child: Container(key: containerKey), + ), + ); + + final VoidCallback? handler = Actions.handler( + containerKey.currentContext!, + const TestIntent(), + ); + expect(handler, isNotNull); + + handler!(); + expect(invoked, isTrue); + }); + + testWidgets('Actions.handler returns null when the action is disabled', ( + WidgetTester tester, + ) async { + final GlobalKey containerKey = GlobalKey(); + final testAction = TestAction(onInvoke: (Intent intent) => null)..enabled = false; + await tester.pumpWidget( + Actions( + actions: >{TestIntent: testAction}, + child: Container(key: containerKey), + ), + ); + + expect(Actions.handler(containerKey.currentContext!, const TestIntent()), isNull); + }); + + testWidgets('Actions.handler returns null when no matching action is found', ( + WidgetTester tester, + ) async { + final GlobalKey containerKey = GlobalKey(); + await tester.pumpWidget( + Actions( + actions: const >{}, + child: Container(key: containerKey), + ), + ); + + expect(Actions.handler(containerKey.currentContext!, const TestIntent()), isNull); + }); + testWidgets('FocusableActionDetector keeps track of focus and hover even when disabled.', ( WidgetTester tester, ) async { diff --git a/packages/flutter_tools/bin/podhelper.rb b/packages/flutter_tools/bin/podhelper.rb index e09c48dfddf6a..f7308a6f86cbd 100644 --- a/packages/flutter_tools/bin/podhelper.rb +++ b/packages/flutter_tools/bin/podhelper.rb @@ -82,6 +82,13 @@ def flutter_additional_ios_build_settings(target) # ARC code targeting iOS 8 does not build on Xcode 14.3. Force to at least iOS 9. build_configuration.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '9.0' if force_to_arc_supported_min + # Suppress warning/error when pod supports a version lower than the minimum supported by Xcode. + # Xcode 27+ produces an error while previous versions produced a warning. + # When it's just a warning, it's harmless but confusing--it's not a bad thing for dependencies to support a lower version. + # When deleted, the deployment version will inherit from the higher version derived from the 'Runner' target. + # If the pod only supports a higher version, do not delete to correctly produce an error. + build_configuration.build_settings.delete 'IPHONEOS_DEPLOYMENT_TARGET' if inherit_deployment_target + # Skip other updates if it does not depend on Flutter (including transitive dependency) next unless depends_on_flutter(target, 'Flutter') @@ -103,11 +110,6 @@ def flutter_additional_ios_build_settings(target) build_configuration.build_settings['OTHER_LDFLAGS'] = '$(inherited) -framework Flutter' build_configuration.build_settings['CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER'] = 'NO' - # Suppress warning when pod supports a version lower than the minimum supported by Xcode (Xcode 12 - iOS 9). - # This warning is harmless but confusing--it's not a bad thing for dependencies to support a lower version. - # When deleted, the deployment version will inherit from the higher version derived from the 'Runner' target. - # If the pod only supports a higher version, do not delete to correctly produce an error. - build_configuration.build_settings.delete 'IPHONEOS_DEPLOYMENT_TARGET' if inherit_deployment_target # Override legacy Xcode 11 style VALID_ARCHS[sdk=iphonesimulator*]=x86_64 and prefer Xcode 12 EXCLUDED_ARCHS. build_configuration.build_settings['VALID_ARCHS[sdk=iphonesimulator*]'] = '$(ARCHS_STANDARD)' @@ -128,8 +130,9 @@ def flutter_additional_macos_build_settings(target) (deployment_target_major.to_i < 10) || (deployment_target_major.to_i == 10 && deployment_target_minor.to_i < 11) - # Suppress warning when pod supports a version lower than the minimum supported by the latest stable version of Xcode (currently 12.0). - # This warning is harmless but confusing--it's not a bad thing for dependencies to support a lower version. + # Suppress warning/error when pod supports a version lower than the minimum supported by the latest stable version of Xcode (currently 12.0). + # Xcode 27+ produces an error while previous versions produced a warning. + # When it's just a warning, it's harmless but confusing--it's not a bad thing for dependencies to support a lower version. inherit_deployment_target = !target.deployment_target.blank? && (deployment_target_major.to_i < 12) @@ -152,6 +155,10 @@ def flutter_additional_macos_build_settings(target) # ARC code targeting macOS 10.10 does not build on Xcode 14.3. Force to at least macOS 10.11. build_configuration.build_settings['MACOSX_DEPLOYMENT_TARGET'] = '10.11' if force_to_arc_supported_min + # When deleted, the deployment version will inherit from the higher version derived from the 'Runner' target. + # If the pod only supports a higher version, do not delete to correctly produce an error. + build_configuration.build_settings.delete 'MACOSX_DEPLOYMENT_TARGET' if inherit_deployment_target + # Skip other updates if it does not depend on Flutter (including transitive dependency) next unless depends_on_flutter(target, 'FlutterMacOS') @@ -167,10 +174,6 @@ def flutter_additional_macos_build_settings(target) end end - # When deleted, the deployment version will inherit from the higher version derived from the 'Runner' target. - # If the pod only supports a higher version, do not delete to correctly produce an error. - build_configuration.build_settings.delete 'MACOSX_DEPLOYMENT_TARGET' if inherit_deployment_target - # Avoid error about Pods-Runner not supporting provisioning profiles. # Framework signing is handled at the app layer, not per framework, so disallow individual signing. build_configuration.build_settings.delete 'EXPANDED_CODE_SIGN_IDENTITY' diff --git a/packages/flutter_tools/bin/tool_backend.dart b/packages/flutter_tools/bin/tool_backend.dart index fb62e172c07a8..c2173d660bb4f 100644 --- a/packages/flutter_tools/bin/tool_backend.dart +++ b/packages/flutter_tools/bin/tool_backend.dart @@ -26,6 +26,8 @@ Future main(List arguments) async { final String? localEngineHost = Platform.environment['LOCAL_ENGINE_HOST']; final String? projectDirectory = Platform.environment['PROJECT_DIR']; final String? splitDebugInfo = Platform.environment['SPLIT_DEBUG_INFO']; + final String? buildName = Platform.environment['BUILD_NAME']; + final String? buildNumber = Platform.environment['BUILD_NUMBER']; final trackWidgetCreation = Platform.environment['TRACK_WIDGET_CREATION'] == 'true'; final treeShakeIcons = Platform.environment['TREE_SHAKE_ICONS'] == 'true'; final verbose = Platform.environment['VERBOSE_SCRIPT_LOGGING'] == 'true'; @@ -98,6 +100,8 @@ or if (codeSizeDirectory != null) '-dCodeSizeDirectory=$codeSizeDirectory', if (flavor != null && flavor.isNotEmpty) '-dFlavor=$flavor', if (splitDebugInfo != null) '-dSplitDebugInfo=$splitDebugInfo', + if (buildName != null) '-dBuildName=$buildName', + if (buildNumber != null) '-dBuildNumber=$buildNumber', if (dartDefines != null) '--DartDefines=$dartDefines', if (extraGenSnapshotOptions != null) '--ExtraGenSnapshotOptions=$extraGenSnapshotOptions', if (frontendServerStarterPath != null) '-dFrontendServerStarterPath=$frontendServerStarterPath', diff --git a/packages/flutter_tools/gradle/src/main/kotlin/DependencyVersionChecker.kt b/packages/flutter_tools/gradle/src/main/kotlin/DependencyVersionChecker.kt index 23186ac7e0f12..28bd4c01cbed9 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/DependencyVersionChecker.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/DependencyVersionChecker.kt @@ -91,22 +91,22 @@ object DependencyVersionChecker { // Advice for maintainers for other areas of code that are impacted are documented // in packages/flutter_tools/lib/src/android/README.md. - @VisibleForTesting internal val warnGradleVersion: Version = Version(8, 14, 0) + @VisibleForTesting internal val warnGradleVersion: Version = Version(9, 1, 0) - @VisibleForTesting internal val errorGradleVersion: Version = Version(8, 7, 0) + @VisibleForTesting internal val errorGradleVersion: Version = Version(8, 14, 0) // Java error and warn should align with packages/flutter_tools/lib/src/android/gradle_utils.dart. @VisibleForTesting internal val warnJavaVersion: JavaVersion = JavaVersion.VERSION_17 @VisibleForTesting internal val errorJavaVersion: JavaVersion = JavaVersion.VERSION_17 - @VisibleForTesting internal val warnAGPVersion: AndroidPluginVersion = AndroidPluginVersion(8, 11, 1) + @VisibleForTesting internal val warnAGPVersion: AndroidPluginVersion = AndroidPluginVersion(9, 0, 1) - @VisibleForTesting internal val errorAGPVersion: AndroidPluginVersion = AndroidPluginVersion(8, 6, 0) + @VisibleForTesting internal val errorAGPVersion: AndroidPluginVersion = AndroidPluginVersion(8, 11, 1) - @VisibleForTesting internal val warnKGPVersion: Version = Version(2, 2, 20) + @VisibleForTesting internal val warnKGPVersion: Version = Version(2, 3, 20) - @VisibleForTesting internal val errorKGPVersion: Version = Version(2, 0, 0) + @VisibleForTesting internal val errorKGPVersion: Version = Version(2, 2, 20) // If this value is changed, then make sure to change the documentation on https://docs.flutter.dev/reference/supported-platforms // Non inclusive. diff --git a/packages/flutter_tools/gradle/src/test/kotlin/DependencyVersionCheckerTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/DependencyVersionCheckerTest.kt index c787783c6fa2c..99c7b6291c44d 100644 --- a/packages/flutter_tools/gradle/src/test/kotlin/DependencyVersionCheckerTest.kt +++ b/packages/flutter_tools/gradle/src/test/kotlin/DependencyVersionCheckerTest.kt @@ -52,12 +52,10 @@ private const val FAKE_PROJECT_ROOT_DIR = "/fake/root/dir" // The following values will need to be modified when the corresponding "warn$DepName" versions // are updated in DependencyVersionChecker.kt -// These values should also match the flutter create template values. -// In //packages/flutter_tools/lib/src/android/gradle_utils.dart -private const val SUPPORTED_GRADLE_VERSION: String = "9.3.1" +private const val SUPPORTED_GRADLE_VERSION: String = "9.1.0" private val SUPPORTED_JAVA_VERSION: JavaVersion = JavaVersion.VERSION_17 -private val SUPPORTED_AGP_VERSION: AndroidPluginVersion = AndroidPluginVersion(9, 1, 0) -private const val SUPPORTED_KGP_VERSION: String = "2.4.0" +private val SUPPORTED_AGP_VERSION: AndroidPluginVersion = AndroidPluginVersion(9, 0, 1) +private const val SUPPORTED_KGP_VERSION: String = "2.3.20" private val SUPPORTED_SDK_VERSION: MinSdkVersion = MinSdkVersion("release", 30) class DependencyVersionCheckerTest { @@ -82,7 +80,7 @@ class DependencyVersionCheckerTest { @Test fun `AGP version in error range results in DependencyValidationException`() { - val exampleErrorAgpVersion = AndroidPluginVersion(8, 5, 0) + val exampleErrorAgpVersion = AndroidPluginVersion(8, 11, 0) val mockProject = MockProjectFactory.createMockProjectWithSpecifiedDependencyVersions(agpVersion = exampleErrorAgpVersion) val mockExtraPropertiesExtension = mockProject.extra @@ -104,7 +102,7 @@ class DependencyVersionCheckerTest { @Test fun `AGP version in warn range results in warning logs`() { - val exampleWarnAgpVersion = AndroidPluginVersion(8, 10, 0) + val exampleWarnAgpVersion = AndroidPluginVersion(8, 11, 1) val mockProject = MockProjectFactory.createMockProjectWithSpecifiedDependencyVersions(agpVersion = exampleWarnAgpVersion) val mockExtraPropertiesExtension = mockProject.extra @@ -128,7 +126,7 @@ class DependencyVersionCheckerTest { @Test fun `KGP version in error range results in DependencyValidationException`() { - val exampleErrorKgpVersion = "1.9.20" + val exampleErrorKgpVersion = "2.0.0" val mockProject = MockProjectFactory.createMockProjectWithSpecifiedDependencyVersions(kgpVersion = exampleErrorKgpVersion) val mockExtraPropertiesExtension = mockProject.extra @@ -152,7 +150,7 @@ class DependencyVersionCheckerTest { @Test fun `KGP version in warn range results in warning logs`() { - val exampleWarnKgpVersion = "2.2.0" + val exampleWarnKgpVersion = "2.2.20" val mockProject = MockProjectFactory.createMockProjectWithSpecifiedDependencyVersions(kgpVersion = exampleWarnKgpVersion) val mockExtraPropertiesExtension = mockProject.extra @@ -203,7 +201,7 @@ class DependencyVersionCheckerTest { @Test fun `Gradle version in error range results in DependencyValidationException`() { - val exampleErrorGradleVersion = "8.6.0" + val exampleErrorGradleVersion = "8.13.0" val mockProject = MockProjectFactory.createMockProjectWithSpecifiedDependencyVersions(gradleVersion = exampleErrorGradleVersion) val mockExtraPropertiesExtension = mockProject.extra @@ -226,7 +224,7 @@ class DependencyVersionCheckerTest { @Test fun `Gradle version in warn range results in warning logs`() { - val exampleWarnGradleVersion = "8.12.0" + val exampleWarnGradleVersion = "8.14.0" val mockProject = MockProjectFactory.createMockProjectWithSpecifiedDependencyVersions(gradleVersion = exampleWarnGradleVersion) val mockExtraPropertiesExtension = mockProject.extra diff --git a/packages/flutter_tools/lib/src/android/android_device_discovery.dart b/packages/flutter_tools/lib/src/android/android_device_discovery.dart index c5dfc95e9e65e..f292cdbb33189 100644 --- a/packages/flutter_tools/lib/src/android/android_device_discovery.dart +++ b/packages/flutter_tools/lib/src/android/android_device_discovery.dart @@ -113,15 +113,20 @@ class AndroidDevices extends PollingDeviceDiscovery { // Parses the output of `adb devices -l`. // // The regex is structured as: - // 1. Group 1 (Serial): Lazily matched to allow spaces in the serial (which - // can happen during wireless ADB mDNS name conflicts, e.g., "device (2)"). - // 2. Group 2 (State): Matches "no permissions" explicitly (as it is the only - // known state containing a space) or any other non-whitespace word (\S+). - // 3. Group 3 (Extra Info): Anchors the state match by requiring it to be - // followed by either a space and a key:value pair (e.g., "usb:123") or the - // end of the line. This prevents false positives if the serial contains - // state keywords. - static final _kDeviceRegex = RegExp(r'^(.*?)\s+(no permissions|\S+)(?:\s+(\w+:.*)|$)'); + // 1. Group 1 (Serial): Greedily matched before the state. ADB formats long + // listings as `%-22s %s`, so the width is a minimum: serials longer than + // 22 characters are followed by one space. Serials can also contain + // whitespace, such as a wireless mDNS conflict suffix (`device (2)`). + // 2. Group 2 (State): Matches known ADB device states explicitly, including + // "no permissions" (which contains a space). Explicitly listing states + // lets the greedy serial capture retain state-like serial components. + // 3. Group 3 (Extra Info): Optional trailing details (e.g. key:value pairs + // like "product:mokey model:mokey device:mokey transport_id:1" or "usb:123"). + static final _kDeviceRegex = RegExp( + r'^(.*)\s+' + r'(device|offline|unauthorized|no permissions|bootloader|recovery|sideload|rescue|connecting|authorizing|host|unknown)' + r'(?:\s+(.*)|$)', + ); /// Parse the given `adb devices` output in [text], and fill out the given list /// of devices and possible device issue diagnostics. Either argument can be null, @@ -157,7 +162,9 @@ class AndroidDevices extends PollingDeviceDiscovery { if (_kDeviceRegex.hasMatch(line)) { final Match match = _kDeviceRegex.firstMatch(line)!; - final String deviceID = match[1]!; + // The greedy serial capture includes the optional padding from ADB's + // minimum-width serial field. + final String deviceID = match[1]!.trimRight(); final String deviceState = match[2]!; String? rest = match[3]; diff --git a/packages/flutter_tools/lib/src/android/android_workflow.dart b/packages/flutter_tools/lib/src/android/android_workflow.dart index 15ff20f5f9c0b..91ec78218fd98 100644 --- a/packages/flutter_tools/lib/src/android/android_workflow.dart +++ b/packages/flutter_tools/lib/src/android/android_workflow.dart @@ -32,6 +32,10 @@ enum LicensesAccepted { none, some, all, unknown } final licenseCounts = RegExp(r'(\d+) of (\d+) SDK package licenses? not accepted.'); final licenseNotAccepted = RegExp(r'licenses? not accepted', caseSensitive: false); final licenseAccepted = RegExp(r'All SDK package licenses accepted.'); +final licensesNoLongerNeeded = RegExp( + r'--licenses option is no longer needed', + caseSensitive: false, +); class AndroidWorkflow implements Workflow { AndroidWorkflow({required AndroidSdk? androidSdk, required FeatureFlags featureFlags}) @@ -445,6 +449,7 @@ class AndroidLicenseValidator extends DoctorValidator { Future get licensesAccepted async { LicensesAccepted? status; + var sawNewCliLicensesMessage = false; void handleLine(String line) { if (licenseCounts.hasMatch(line)) { @@ -461,6 +466,8 @@ class AndroidLicenseValidator extends DoctorValidator { status = LicensesAccepted.none; } else if (licenseAccepted.hasMatch(line)) { status ??= LicensesAccepted.all; + } else if (licensesNoLongerNeeded.hasMatch(line)) { + sawNewCliLicensesMessage = true; } } @@ -487,13 +494,37 @@ class AndroidLicenseValidator extends DoctorValidator { .listen(handleLine) .asFuture(); await Future.wait(>[output, errors]); - return status ?? LicensesAccepted.unknown; + if (status != null) { + return status!; + } + if (sawNewCliLicensesMessage) { + return _licensesAcceptedFromDisk(); + } + return LicensesAccepted.unknown; } on IOException catch (e) { _logger.printTrace('Failed to run Android sdk manager: $e'); return LicensesAccepted.unknown; } } + /// Fallback license check for Android cmdline-tools versions where + /// `sdkmanager --licenses` no longer prints a parseable status + /// (see https://github.com/flutter/flutter/issues/191487). + LicensesAccepted _licensesAcceptedFromDisk() { + if (_androidSdk == null || !_androidSdk.licensesAvailable) { + return LicensesAccepted.none; + } + final Directory licensesDir = _androidSdk.directory.childDirectory('licenses'); + try { + final bool hasAcceptedLicense = licensesDir.listSync().whereType().any( + (File file) => !file.basename.startsWith('.') && file.lengthSync() > 0, + ); + return hasAcceptedLicense ? LicensesAccepted.all : LicensesAccepted.none; + } on FileSystemException { + return LicensesAccepted.unknown; + } + } + /// Run the Android SDK manager tool in order to accept SDK licenses. Future runLicenseManager() async { if (_androidSdk == null) { diff --git a/packages/flutter_tools/lib/src/android/gradle.dart b/packages/flutter_tools/lib/src/android/gradle.dart index 3468f2dadea8f..2160418c15eea 100644 --- a/packages/flutter_tools/lib/src/android/gradle.dart +++ b/packages/flutter_tools/lib/src/android/gradle.dart @@ -25,6 +25,7 @@ import '../base/process.dart'; import '../base/project_migrator.dart'; import '../base/terminal.dart'; import '../base/utils.dart'; +import '../base/version.dart'; import '../build_info.dart'; import '../cache.dart'; import '../convert.dart'; @@ -431,6 +432,56 @@ class AndroidGradleBuilder implements AndroidBuilder { return exitCode; } + // Validate Java and Gradle compatibility after Gradle fails. + // This check is done in Dart after a Gradle crash because: + // 1. If Java and Gradle are incompatible, Gradle can crash during build script + // compilation (e.g. Kotlin DSL compilation failing on newer JDKs) before + // the Flutter Gradle Plugin (DependencyVersionChecker) is even applied. + // See https://github.com/flutter/flutter/issues/189780 for context on + // how JDK upgrades lead to cryptic Gradle compilation crashes. + // 2. Checking only after Gradle crashes avoids blocking builds that currently + // succeed despite an unsupported Java/Gradle version pair. + // 3. This also helps address https://github.com/flutter/flutter/issues/167931 + // by providing actionable version recommendations directly in the error. + Future _checkJavaAndGradleCompatibility(FlutterProject project, BuildInfo buildInfo) async { + if (!buildInfo.androidSkipBuildDependencyValidation) { + final Version? javaVersionObj = _java?.version; + final String? javaVersion = javaVersionObj != null + ? '${javaVersionObj.major}.${javaVersionObj.minor}.${javaVersionObj.patch}' + : null; + final String? gradleVersion = await getGradleVersionFromFile( + project.android.hostAppGradleRoot, + _logger, + ); + if (javaVersion != null && gradleVersion != null) { + if (!gradle.validateJavaAndGradle( + _logger, + javaVersion: javaVersion, + gradleVersion: gradleVersion, + )) { + final JavaGradleCompat? compat = gradle.getValidGradleVersionRangeForJavaVersion( + _logger, + javaV: javaVersion, + ); + final gradleRangeMax = compat != null && compat.gradleMax != null + ? ' to ${compat.gradleMax}' + : ''; + final gradleRangeCompatSuggestion = compat != null + ? '${compat.gradleMin}$gradleRangeMax or newer' + : 'unknown'; + final gradleRangeInfo = + 'compatible Gradle versions for Java $javaVersion are $gradleRangeCompatSuggestion'; + throwToolExit(""" +Gradle build failed due to Java/Gradle incompatibility. +The Java version used for the build is $javaVersion, which is incompatible with Gradle $gradleVersion. +To fix this, you can either: + 1. Upgrade your project's Gradle version (typically in gradle-wrapper.properties to a version matching the range: $gradleRangeInfo). + 2. Use a different Java version for Flutter by running `flutter config --jdk-dir=`."""); + } + } + } + } + /// Builds an app. /// /// * [project] is typically [FlutterProject.current()]. @@ -598,6 +649,7 @@ class AndroidGradleBuilder implements AndroidBuilder { ); if (exitCode != 0) { + await _checkJavaAndGradleCompatibility(project, androidBuildInfo.buildInfo); throwToolExit( 'Gradle task $assembleTask failed with exit code $exitCode', exitCode: exitCode, @@ -896,6 +948,7 @@ class AndroidGradleBuilder implements AndroidBuilder { if (result.exitCode != 0) { _logger.printStatus(result.stdout, wrap: false); _logger.printError(result.stderr, wrap: false); + await _checkJavaAndGradleCompatibility(project, androidBuildInfo.buildInfo); throwToolExit( 'Gradle task $aarTask failed with exit code ${result.exitCode}.', exitCode: result.exitCode, diff --git a/packages/flutter_tools/lib/src/android/gradle_utils.dart b/packages/flutter_tools/lib/src/android/gradle_utils.dart index bbb707be8dfa7..d0c2af17a1818 100644 --- a/packages/flutter_tools/lib/src/android/gradle_utils.dart +++ b/packages/flutter_tools/lib/src/android/gradle_utils.dart @@ -331,16 +331,11 @@ String? parseGradleVersionFromDistributionUrl(String? distributionUrl) { return zipParts[1]; } -/// Returns either the gradle-wrapper.properties value from the passed in -/// [directory] or if not present the version available in local path. +/// Returns the gradle-wrapper.properties value from the passed in [directory]. /// -/// If gradle version is not found null is returned. -/// [directory] should be an android directory with a build.gradle file. -Future getGradleVersion( - Directory directory, - Logger logger, - ProcessManager processManager, -) async { +/// If gradle version is not found in the file, null is returned. +/// [directory] should be an android directory. +Future getGradleVersionFromFile(Directory directory, Logger logger) async { final File propertiesFile = getGradleWrapperFile(directory); if (propertiesFile.existsSync()) { @@ -357,14 +352,30 @@ Future getGradleVersion( } } else { // If no distributionUrl log then treat as if there was no propertiesFile. - logger.printTrace( - '$propertiesFile does not provide a Gradle version falling back to system gradle.', - ); + logger.printTrace('$propertiesFile does not provide a Gradle version.'); } } else { // Could not find properties file. - logger.printTrace('$propertiesFile does not exist falling back to system gradle'); + logger.printTrace('$propertiesFile does not exist.'); + } + return null; +} + +/// Returns either the gradle-wrapper.properties value from the passed in +/// [directory] or if not present the version available in local path. +/// +/// If gradle version is not found null is returned. +/// [directory] should be an android directory with a build.gradle file. +Future getGradleVersion( + Directory directory, + Logger logger, + ProcessManager processManager, +) async { + final String? gradleVersion = await getGradleVersionFromFile(directory, logger); + if (gradleVersion != null) { + return gradleVersion; } + logger.printTrace('Falling back to system gradle'); // System installed Gradle version. // TODO(reidbaker): Modify this gradle execution to use gradlew. if (processManager.canRun('gradle')) { @@ -1122,9 +1133,9 @@ String getGradleVersionFor(String agpV) { GradleForAgp(agpMin: '8.8.0', agpMax: '8.8.99', minRequiredGradle: '8.10.2'), GradleForAgp(agpMin: '8.9.0', agpMax: '8.9.99', minRequiredGradle: '8.11.1'), GradleForAgp(agpMin: '8.10.0', agpMax: '8.10.99', minRequiredGradle: '8.11.1'), - GradleForAgp(agpMin: '8.11.0', agpMax: '8.11.99', minRequiredGradle: '8.13'), - GradleForAgp(agpMin: '8.12.0', agpMax: '8.12.99', minRequiredGradle: '8.13'), - GradleForAgp(agpMin: '8.13.0', agpMax: '8.13.99', minRequiredGradle: '8.13'), + GradleForAgp(agpMin: '8.11.0', agpMax: '8.11.99', minRequiredGradle: '8.14'), + GradleForAgp(agpMin: '8.12.0', agpMax: '8.12.99', minRequiredGradle: '8.14'), + GradleForAgp(agpMin: '8.13.0', agpMax: '8.13.99', minRequiredGradle: '8.14'), GradleForAgp(agpMin: '9.0', agpMax: '9.0.99', minRequiredGradle: '9.1.0'), GradleForAgp(agpMin: '9.1.0', agpMax: '9.1.99', minRequiredGradle: '9.3.1'), // Assume if AGP is newer than this code knows about return the highest gradle diff --git a/packages/flutter_tools/lib/src/base/error_handling_io.dart b/packages/flutter_tools/lib/src/base/error_handling_io.dart index 64a1ab547823e..ea63dc88d7ac4 100644 --- a/packages/flutter_tools/lib/src/base/error_handling_io.dart +++ b/packages/flutter_tools/lib/src/base/error_handling_io.dart @@ -1449,6 +1449,9 @@ void _handleWindowsException(Exception e, String? message, int errorCode) { const kAccessDenied = 5; const kFatalDeviceHardwareError = 483; const kDeviceDoesNotExist = 433; + const kApplicationControlPolicyBlocked = 4551; + const kAccessDisabledByPolicy = 1260; + const kSystemIntegrityPolicyViolation = 454; // Catch errors and bail when: final String? errorMessage = switch (errorCode) { @@ -1477,6 +1480,13 @@ void _handleWindowsException(Exception e, String? message, int errorCode) { '$message. The device was not found.' '\n$e\n' 'Verify the device is mounted and try again.', + kApplicationControlPolicyBlocked || + kAccessDisabledByPolicy || + kSystemIntegrityPolicyViolation => + '${message != null ? "$message. " : ""}An Application Control policy or security policy has blocked execution.\n' + '$e\n' + 'Please verify your Windows Security Smart App Control, Application Control policy (WDAC), ' + 'or Group Policy settings, or add an exclusion for the Flutter SDK directory.', _ => null, }; _throwFileSystemException(errorMessage); diff --git a/packages/flutter_tools/lib/src/base/file_system.dart b/packages/flutter_tools/lib/src/base/file_system.dart index 6e1a75fea7101..53724f70a7a72 100644 --- a/packages/flutter_tools/lib/src/base/file_system.dart +++ b/packages/flutter_tools/lib/src/base/file_system.dart @@ -2,15 +2,19 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:async'; + import 'package:file/file.dart'; import 'package:file/local.dart' as local_fs; import 'package:meta/meta.dart'; import 'common.dart'; import 'io.dart'; +import 'logger.dart'; import 'platform.dart'; import 'process.dart'; import 'signals.dart'; +import 'terminal.dart'; // package:file/local.dart must not be exported. This exposes LocalFileSystem, // which we override to ensure that temporary directories are cleaned up when @@ -273,3 +277,67 @@ class LocalFileSystem extends local_fs.LocalFileSystem { @visibleForTesting Directory get superSystemTempDirectory => super.systemTempDirectory; } + +extension FileSystemLocking on FileSystem { + /// Runs [scope] while holding an exclusive file lock on the file at [lockPath]. + /// + /// The lock is released after [scope] completes, even if it throws. + /// + /// If the lock cannot be acquired immediately, it will retry every 50ms. + Future runLocked({ + required String lockPath, + required FutureOr Function() scope, + Logger? logger, + String? traceMessage, + String? warningMessage, + }) async { + final File lockFile = file(lockPath); + var printed = false; + RandomAccessFile? openedFile; + while (true) { + try { + lockFile.parent.createSync(recursive: true); + openedFile = lockFile.openSync(mode: FileMode.write); + openedFile.lockSync(); + break; + } on UnimplementedError { + logger?.printTrace(traceMessage ?? 'Locking not supported (UnimplementedError).'); + break; + } on UnsupportedError { + logger?.printTrace(traceMessage ?? 'Locking not supported (UnsupportedError).'); + break; + } on FileSystemException catch (e) { + final lockFailed = openedFile != null; + if (openedFile != null) { + try { + openedFile.closeSync(); + } on FileSystemException catch (_) {} + openedFile = null; + } + if (!printed) { + final details = lockFailed ? '' : ' (Error: $e)'; + logger?.printTrace( + traceMessage ?? 'Waiting to obtain lock of directory: ${lockFile.path}$details', + ); + logger?.printWarning( + warningMessage ?? 'Waiting for another flutter command to release the lock...', + color: TerminalColor.grey, + fatal: false, + ); + printed = true; + } + await Future.delayed(const Duration(milliseconds: 50)); + } + } + + try { + return await scope(); + } finally { + if (openedFile != null) { + try { + openedFile.closeSync(); + } on FileSystemException {} // ignore: empty_catches + } + } + } +} diff --git a/packages/flutter_tools/lib/src/build_info.dart b/packages/flutter_tools/lib/src/build_info.dart index cae5808e983ce..fab5066e5c29a 100644 --- a/packages/flutter_tools/lib/src/build_info.dart +++ b/packages/flutter_tools/lib/src/build_info.dart @@ -393,6 +393,8 @@ class BuildInfo { 'EXTRA_FRONT_END_OPTIONS': extraFrontEndOptions.join(','), if (extraGenSnapshotOptions.isNotEmpty) 'EXTRA_GEN_SNAPSHOT_OPTIONS': extraGenSnapshotOptions.join(','), + 'BUILD_NAME': ?buildName, + 'BUILD_NUMBER': ?buildNumber, 'SPLIT_DEBUG_INFO': ?splitDebugInfoPath, 'TRACK_WIDGET_CREATION': trackWidgetCreation.toString(), 'TREE_SHAKE_ICONS': treeShakeIcons.toString(), diff --git a/packages/flutter_tools/lib/src/build_system/targets/web.dart b/packages/flutter_tools/lib/src/build_system/targets/web.dart index 94a1f82c2cc97..7d235f60d691c 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/web.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/web.dart @@ -54,10 +54,16 @@ class WebEntrypointTarget extends Target { @override List get inputs => const [ Source.pattern('{FLUTTER_ROOT}/packages/flutter_tools/lib/src/build_system/targets/web.dart'), + Source.pattern('{WORKSPACE_DIR}/.dart_tool/package_config.json'), + Source.pattern('{PROJECT_DIR}/pubspec.yaml'), + Source.pattern('{PROJECT_DIR}/.flutter-plugins-dependencies', optional: true), ]; @override - List get outputs => const [Source.pattern('{BUILD_DIR}/main.dart')]; + List get outputs => const [ + Source.pattern('{BUILD_DIR}/main.dart'), + Source.pattern('{BUILD_DIR}/web_plugin_registrant.dart'), + ]; @override Future build(Environment environment) async { @@ -87,7 +93,7 @@ class WebEntrypointTarget extends Target { // does not have an entry for the user's application or if the main file is // outside of the lib/ directory. final String importedEntrypoint = - packageConfig.toPackageUri(importUri)?.toString() ?? importUri.toString(); + packageConfig.toPackageUriForWorkspace(importUri)?.toString() ?? importUri.toString(); await injectBuildTimePluginFilesForWebPlatform( flutterProject, @@ -192,10 +198,7 @@ class Dart2JSTarget extends Dart2WebTarget { else if (buildMode == BuildMode.release) '-Ddart.vm.product=true', for (final String dartDefine in computeDartDefines(environment)) '-D$dartDefine', - if (featureFlags.isRecordUseEnabled) ...[ - '--write-resources', - '--enable-experiment=record-use', - ], + if (featureFlags.isRecordUseEnabled) '--write-resources', ]; // NOTE: most args should be populated in [toSharedCommandOptions]. @@ -367,10 +370,8 @@ class Dart2WasmTarget extends Dart2WebTarget { ...decodeCommaSeparated(environment.defines, kExtraFrontEndOptions), for (final String dartDefine in dartDefines) '-D$dartDefine', '--extra-compiler-option=--depfile=${depFile.path}', - if (featureFlags.isRecordUseEnabled) ...[ + if (featureFlags.isRecordUseEnabled) '--recorded-uses=${environment.buildDir.childFile(LinkHooks.recordedUsesWasmFileName).path}', - '--enable-experiment=record-use', - ], ...compilerConfig.toCommandOptions(buildMode), '-o', outputWasmFile.path, diff --git a/packages/flutter_tools/lib/src/build_system/tools/shader_compiler.dart b/packages/flutter_tools/lib/src/build_system/tools/shader_compiler.dart index a87a36960bc76..beb18e3776aad 100644 --- a/packages/flutter_tools/lib/src/build_system/tools/shader_compiler.dart +++ b/packages/flutter_tools/lib/src/build_system/tools/shader_compiler.dart @@ -265,7 +265,7 @@ class ShaderCompiler { final List shaderTargets = _shaderTargetsFromTargetPlatform(targetPlatform); final List cmd = makeImpellercCommand(shaderTargets); _logger.printTrace('impellerc command: $cmd'); - final ProcessResult result = await _runCommand(cmd); + ProcessResult result = await _runCommand(cmd); if (result.exitCode != 0) { // Maybe retry impellerc command without --sksl. if (!(shaderTargets.length > 1 && shaderTargets.contains('--sksl'))) { @@ -286,6 +286,7 @@ class ShaderCompiler { if (retryResult.exitCode != 0) { // Retry failed. _logger.printError('impellerc failure: ${retryResult.stderr}'); + result = retryResult; failure = true; } else { // Retry succeeded. Don't fail, but log a warning message and the sksl @@ -302,9 +303,15 @@ class ShaderCompiler { if (failure) { if (fatal) { + final String? hint = _getHelpfulHint(result, input, outputPath, impellerc.path); + final message = + 'Shader compilation of "${input.path}" to "$outputPath" ' + 'failed with exit code ${result.exitCode}.' + '${hint != null ? '\n$hint' : ''}'; throw ShaderCompilerException._( - 'Shader compilation of "${input.path}" to "$outputPath" ' - 'failed with exit code ${result.exitCode}.', + message, + stdout: result.stdout as String?, + stderr: result.stderr as String?, ); } return false; @@ -320,9 +327,46 @@ class ShaderCompiler { return true; } + String? _getHelpfulHint( + ProcessResult result, + File input, + String outputPath, + String impellercPath, + ) { + final int exitCode = result.exitCode; + if (_platform.isMacOS && exitCode == -9) { + return 'The shader compiler (impellerc) may have been blocked by macOS Gatekeeper or run out of memory (OOM).\n' + 'To resolve Gatekeeper issues, try running:\n' + ' xattr -d com.apple.quarantine "$impellercPath"'; + } + + final bool isWindowsAbort = _platform.isWindows && exitCode == 3; + final bool isPosixAbort = (_platform.isMacOS || _platform.isLinux) && exitCode == -6; + + if (isWindowsAbort || isPosixAbort) { + var hint = 'The shader compiler (impellerc) aborted during compilation.'; + if (_platform.isWindows) { + final bool hasNonAscii = + RegExp(r'[^\x00-\x7F]').hasMatch(input.path) || + RegExp(r'[^\x00-\x7F]').hasMatch(outputPath); + if (hasNonAscii) { + hint += + '\nWarning: The path contains non-ASCII characters, which is known to cause crashes on Windows.\n' + 'Try moving your project to a path containing only ASCII characters.'; + } + } + return hint; + } + return null; + } + Future _runCommand(List command) async { try { - return await _processManager.run(command, stderrEncoding: utf8); + return await _processManager.run( + command, + stdoutEncoding: utf8AllowMalformed, + stderrEncoding: utf8AllowMalformed, + ); } on ProcessException catch (e) { if (_isBlockedBySecurityPolicy(e)) { throw _SecurityPolicyBlockException(e); @@ -363,10 +407,26 @@ class _SecurityPolicyBlockException implements Exception { } class ShaderCompilerException implements Exception { - ShaderCompilerException._(this.message); + ShaderCompilerException._(this.message, {this.stdout, this.stderr}); final String message; + final String? stdout; + final String? stderr; @override - String toString() => 'ShaderCompilerException: $message\n\n'; + String toString() { + final buffer = StringBuffer(); + buffer.write('ShaderCompilerException: $message\n'); + final String? stdout = this.stdout; + if (stdout != null && stdout.trim().isNotEmpty) { + buffer.writeln('Stdout:'); + buffer.writeln(stdout.trim()); + } + final String? stderr = this.stderr; + if (stderr != null && stderr.trim().isNotEmpty) { + buffer.writeln('Stderr:'); + buffer.writeln(stderr.trim()); + } + return buffer.toString(); + } } diff --git a/packages/flutter_tools/lib/src/commands/packages.dart b/packages/flutter_tools/lib/src/commands/packages.dart index d901aa96c67bb..a34d124f19a33 100644 --- a/packages/flutter_tools/lib/src/commands/packages.dart +++ b/packages/flutter_tools/lib/src/commands/packages.dart @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:collection'; + import 'package:args/args.dart'; import 'package:package_config/package_config.dart'; import 'package:pool/pool.dart'; @@ -397,12 +399,20 @@ class PackagesGetCommand extends FlutterCommand { const ignoreReleaseModeSinceItsNotABuildAndHopeItWorks = false; // We need to regenerate the platform specific tooling for both the // project itself and example (if present). - await project.regeneratePlatformSpecificTooling( - releaseMode: ignoreReleaseModeSinceItsNotABuildAndHopeItWorks, - pubspecCache: pubspecCache, - packageGraph: graph, - packageConfig: packageConfig, - ); + // + // Workspace packages that do not depend on Flutter (such as a pub + // workspace root that is a plain Dart package) are skipped, so that a + // stray ios/ or android/ directory in one of them is not populated + // with Flutter project files. + // See https://github.com/flutter/flutter/issues/189550. + if (_dependsOnFlutter(graph, workspaceRootName)) { + await project.regeneratePlatformSpecificTooling( + releaseMode: ignoreReleaseModeSinceItsNotABuildAndHopeItWorks, + pubspecCache: pubspecCache, + packageGraph: graph, + packageConfig: packageConfig, + ); + } if (example && project.hasExampleApp && project.example.pubspecFile.existsSync()) { final FlutterProject exampleProject = project.example; // Skip if the example is already a workspace root — it will be @@ -423,6 +433,27 @@ class PackagesGetCommand extends FlutterCommand { return FlutterCommandResult.success(); } + /// Whether [packageName] depends on the `flutter` package, directly or + /// transitively, according to the resolved package [graph]. + static bool _dependsOnFlutter(PackageGraph graph, String packageName) { + final visited = {}; + final toVisit = Queue.of([packageName]); + while (toVisit.isNotEmpty) { + final String current = toVisit.removeFirst(); + if (!visited.add(current)) { + continue; + } + if (current == 'flutter') { + return true; + } + final List? dependencies = graph.dependencies[current]; + if (dependencies != null) { + toVisit.addAll(dependencies); + } + } + return false; + } + late final Future> _pluginsFound = (() async { final FlutterProject? rootProject = _rootProject; if (rootProject == null) { diff --git a/packages/flutter_tools/lib/src/commands/symbolize.dart b/packages/flutter_tools/lib/src/commands/symbolize.dart index 54bc92d51a07d..351412d6021d5 100644 --- a/packages/flutter_tools/lib/src/commands/symbolize.dart +++ b/packages/flutter_tools/lib/src/commands/symbolize.dart @@ -316,12 +316,13 @@ class DwarfSymbolizationService { }, onDone: onDone.complete, onError: onDone.completeError, + cancelOnError: true, ); try { await onDone.future; await output.close(); - } on Exception catch (err) { + } on Object catch (err) { throwToolExit('Failed to symbolize stack trace:\n $err'); } } diff --git a/packages/flutter_tools/lib/src/compile.dart b/packages/flutter_tools/lib/src/compile.dart index d11863c167c5c..32022a2483766 100644 --- a/packages/flutter_tools/lib/src/compile.dart +++ b/packages/flutter_tools/lib/src/compile.dart @@ -23,6 +23,7 @@ import 'base/utils.dart'; import 'build_info.dart'; import 'bundle.dart'; import 'convert.dart'; +import 'dart/package_map.dart'; /// Opt-in changes to the dart compilers. const kDartCompilerExperiments = []; @@ -291,7 +292,7 @@ class KernelCompiler { final File mainFile = _fileSystem.file(mainPath); final Uri mainFileUri = mainFile.uri; if (packagesPath != null) { - mainUri = packageConfig.toPackageUri(mainFileUri)?.toString(); + mainUri = packageConfig.toPackageUriForWorkspace(mainFileUri)?.toString(); } mainUri ??= toMultiRootPath( mainFileUri, @@ -314,7 +315,7 @@ class KernelCompiler { if (dartPluginRegistrant != null && dartPluginRegistrant.existsSync()) { final Uri dartPluginRegistrantFileUri = dartPluginRegistrant.uri; dartPluginRegistrantUri = - packageConfig.toPackageUri(dartPluginRegistrantFileUri)?.toString() ?? + packageConfig.toPackageUriForWorkspace(dartPluginRegistrantFileUri)?.toString() ?? toMultiRootPath( dartPluginRegistrantFileUri, _fileSystemScheme, @@ -853,13 +854,15 @@ class DefaultResidentCompiler implements ResidentCompiler { _stdoutHandler._suppressCompilerMessages = request.suppressErrors; final String mainUri = - request.packageConfig.toPackageUri(request.mainUri)?.toString() ?? + request.packageConfig.toPackageUriForWorkspace(request.mainUri)?.toString() ?? toMultiRootPath(request.mainUri, fileSystemScheme, fileSystemRoots, _platform.isWindows); String? additionalSourceUri; if (request.additionalSourceUri != null) { additionalSourceUri = - request.packageConfig.toPackageUri(request.additionalSourceUri!)?.toString() ?? + request.packageConfig + .toPackageUriForWorkspace(request.additionalSourceUri!) + ?.toString() ?? toMultiRootPath( request.additionalSourceUri!, fileSystemScheme, @@ -898,7 +901,7 @@ class DefaultResidentCompiler implements ResidentCompiler { message = fileUri.toString(); } else { message = - request.packageConfig.toPackageUri(fileUri)?.toString() ?? + request.packageConfig.toPackageUriForWorkspace(fileUri)?.toString() ?? toMultiRootPath(fileUri, fileSystemScheme, fileSystemRoots, _platform.isWindows); } server.stdin.writeln(message); diff --git a/packages/flutter_tools/lib/src/custom_devices/custom_device.dart b/packages/flutter_tools/lib/src/custom_devices/custom_device.dart index 82737e2ae3fe6..1356c44fd9541 100644 --- a/packages/flutter_tools/lib/src/custom_devices/custom_device.dart +++ b/packages/flutter_tools/lib/src/custom_devices/custom_device.dart @@ -490,7 +490,7 @@ class CustomDevice extends Device { /// /// If [timeout] is not null and the process doesn't finish in time, /// it will be killed with a SIGTERM, false will be returned and the timeout - /// will be reported in the log using [Logger.printError]. If [timeout] + /// will be reported in the log using [Logger.printTrace]. If [timeout] /// is null, it's treated as if it's an infinite timeout. Future tryPing({ Duration? timeout, @@ -498,7 +498,13 @@ class CustomDevice extends Device { }) async { final List interpolated = interpolateCommand(_config.pingCommand, replacementValues); - final RunResult result = await _processUtils.run(interpolated, timeout: timeout); + final RunResult result; + try { + result = await _processUtils.run(interpolated, timeout: timeout); + } on ProcessException catch (e) { + _logger.printTrace('Error pinging custom device $id: $e'); + return false; + } if (result.exitCode != 0) { return false; diff --git a/packages/flutter_tools/lib/src/dart/package_map.dart b/packages/flutter_tools/lib/src/dart/package_map.dart index b7ac029532197..78417c24c26cb 100644 --- a/packages/flutter_tools/lib/src/dart/package_map.dart +++ b/packages/flutter_tools/lib/src/dart/package_map.dart @@ -131,3 +131,38 @@ Future loadPackageConfigWithLogging( } return result; } + +extension PackageConfigWorkspaceExtension on PackageConfig { + /// Converts a [fileUri] to a `package:` URI, finding the most specific + /// package whose [Package.packageUriRoot] is a prefix of [fileUri]. + /// + /// The default [PackageConfig.toPackageUri] may match an outer package first + /// when pub workspace member packages are located under the workspace root + /// package's `lib/` directory. + Uri? toPackageUriForWorkspace(Uri fileUri) { + if (fileUri.isScheme('package')) { + return fileUri; + } + final path = fileUri.toString(); + Package? bestMatch; + String? bestMatchRoot; + + for (final Package package in packages) { + final rootPath = package.packageUriRoot.toString(); + final rootPathWithSlash = rootPath.endsWith('/') ? rootPath : '$rootPath/'; + if (path.startsWith(rootPathWithSlash)) { + if (bestMatchRoot == null || rootPathWithSlash.length > bestMatchRoot.length) { + bestMatch = package; + bestMatchRoot = rootPathWithSlash; + } + } + } + + if (bestMatch == null || bestMatchRoot == null) { + return null; + } + + final String rest = path.substring(bestMatchRoot.length); + return Uri(scheme: 'package', path: '${bestMatch.name}/$rest'); + } +} diff --git a/packages/flutter_tools/lib/src/debug_adapters/flutter_adapter.dart b/packages/flutter_tools/lib/src/debug_adapters/flutter_adapter.dart index a6b434a0edf05..e127f539c20c7 100644 --- a/packages/flutter_tools/lib/src/debug_adapters/flutter_adapter.dart +++ b/packages/flutter_tools/lib/src/debug_adapters/flutter_adapter.dart @@ -452,7 +452,17 @@ class FlutterDebugAdapter extends FlutterBaseDebugAdapter with VmServiceInfoFile // This may be useful when there's no VM Service (for example Profile mode) // but the editor still wants to know that startup has finished. if (enableDebugger) { - await debuggerInitialized; // Ensure we're fully initialized before sending. + waitingForDebugger = true; + try { + await Future.any([debuggerInitialized, debuggerInitializationFailedCompleter.future]); + } catch (e) { + if (!isTerminating) { + rethrow; + } + return; + } finally { + waitingForDebugger = false; + } } sendEvent(RawEventBody({}), eventType: 'flutter.appStarted'); } diff --git a/packages/flutter_tools/lib/src/debug_adapters/flutter_base_adapter.dart b/packages/flutter_tools/lib/src/debug_adapters/flutter_base_adapter.dart index 3b483f105dcbf..26c69009ebae4 100644 --- a/packages/flutter_tools/lib/src/debug_adapters/flutter_base_adapter.dart +++ b/packages/flutter_tools/lib/src/debug_adapters/flutter_base_adapter.dart @@ -45,6 +45,27 @@ abstract class FlutterBaseDebugAdapter /// the same as what is passed to the base class, which is always provided 'false'. final bool enableFlutterDds; + /// Whether the adapter is currently waiting for the debugger to initialize. + bool waitingForDebugger = false; + + /// A completer that completes with an error if debugger initialization fails + /// (for example, if the session terminates early). + /// + /// A dummy error handler is attached to the future to prevent unhandled + /// asynchronous exceptions if it completes before any listeners are active. + final Completer debuggerInitializationFailedCompleter = Completer() + ..future.then((_) {}, onError: (Object _) {}); + + @override + void handleSessionTerminate([String exitSuffix = '']) { + if (waitingForDebugger && !debuggerInitializationFailedCompleter.isCompleted) { + debuggerInitializationFailedCompleter.completeError( + DebugAdapterException('Session terminated before debugger initialized: $exitSuffix'), + ); + } + super.handleSessionTerminate(exitSuffix); + } + @override final FlutterLaunchRequestArguments Function(Map obj) parseLaunchArgs = FlutterLaunchRequestArguments.fromJson; diff --git a/packages/flutter_tools/lib/src/debug_adapters/flutter_test_adapter.dart b/packages/flutter_tools/lib/src/debug_adapters/flutter_test_adapter.dart index cb0b36dd526d1..5cd8fddf2ecc3 100644 --- a/packages/flutter_tools/lib/src/debug_adapters/flutter_test_adapter.dart +++ b/packages/flutter_tools/lib/src/debug_adapters/flutter_test_adapter.dart @@ -66,11 +66,26 @@ class FlutterTestDebugAdapter extends FlutterBaseDebugAdapter with TestAdapter { final processArgs = [...toolArgs, ...?args.toolArgs, ?program, ...?args.args]; - await launchAsProcess(executable: executable, processArgs: processArgs, env: args.env); + if (debug) { + waitingForDebugger = true; + } + + try { + await launchAsProcess(executable: executable, processArgs: processArgs, env: args.env); + } catch (e) { + if (debug) { + waitingForDebugger = false; + } + rethrow; + } // Delay responding until the debugger is connected. if (debug) { - await debuggerInitialized; + try { + await Future.any([debuggerInitialized, debuggerInitializationFailedCompleter.future]); + } finally { + waitingForDebugger = false; + } } } diff --git a/packages/flutter_tools/lib/src/flutter_plugins.dart b/packages/flutter_tools/lib/src/flutter_plugins.dart index 1d52046878f6e..3af0fb0848407 100644 --- a/packages/flutter_tools/lib/src/flutter_plugins.dart +++ b/packages/flutter_tools/lib/src/flutter_plugins.dart @@ -1457,6 +1457,7 @@ Future injectPlugins( templateRenderer: globals.templateRenderer, processUtils: globals.processUtils, config: globals.config, + logger: globals.logger, ), fileSystem: globals.fs, featureFlags: featureFlags, diff --git a/packages/flutter_tools/lib/src/ios/core_devices.dart b/packages/flutter_tools/lib/src/ios/core_devices.dart index f0c96f0a3c5fa..8f792f4c667f1 100644 --- a/packages/flutter_tools/lib/src/ios/core_devices.dart +++ b/packages/flutter_tools/lib/src/ios/core_devices.dart @@ -14,12 +14,14 @@ import '../base/logger.dart'; import '../base/process.dart'; import '../base/template.dart'; import '../base/utils.dart'; +import '../base/version.dart'; import '../build_info.dart'; import '../convert.dart'; import '../device.dart'; import '../macos/xcode.dart'; import '../project.dart'; import 'application_package.dart'; +import 'device_support.dart'; import 'lldb.dart'; import 'xcode_debug.dart'; import 'xcodeproj.dart'; @@ -41,6 +43,7 @@ class IOSCoreDeviceLauncher { required FileSystem fileSystem, required ProcessUtils processUtils, required XcodeProjectInterpreter xcodeProjectInterpreter, + required Version? deviceVersion, @visibleForTesting LLDB? lldb, }) : _coreDeviceControl = coreDeviceControl, _logger = logger, @@ -52,6 +55,7 @@ class IOSCoreDeviceLauncher { logger: logger, processUtils: processUtils, xcodeProjectInterpreter: xcodeProjectInterpreter, + deviceVersion: deviceVersion, ); final IOSCoreDeviceControl _coreDeviceControl; @@ -101,6 +105,7 @@ class IOSCoreDeviceLauncher { /// Requires Xcode 16+. Future launchAppWithLLDBDebugger({ required String deviceId, + required IOSDeviceSupport deviceSupport, required String bundlePath, required String bundleId, required List launchArguments, @@ -150,12 +155,22 @@ class IOSCoreDeviceLauncher { return false; } + // Kill LLDB and devicectl if the CLI shuts down so they do not hang in the background. + shutdownHooks.addShutdownHook(() async { + try { + await stopApp(deviceId: deviceId, processId: processId); + } on Exception { + // ignore any failures + } + }); + // Start LLDB and attach to the device process. final bool attachStatus = await _lldb.attachAndStart( deviceId: deviceId, appProcessId: processId, lldbLogForwarder: lldbLogForwarder, mode: mode, + deviceSupport: deviceSupport, ); // If it fails to attach with lldb, kill the launched process so it doesn't stay hanging. @@ -853,7 +868,7 @@ class IOSCoreDeviceControl { unawaited( launchProcess.exitCode .then((int status) async { - _logger.printTrace('lldb exited with code $status'); + _logger.printTrace('devicectl exited with code $status'); await stdoutSubscription.cancel(); await stderrSubscription.cancel(); }) diff --git a/packages/flutter_tools/lib/src/ios/device_support.dart b/packages/flutter_tools/lib/src/ios/device_support.dart new file mode 100644 index 0000000000000..4a114f2fd9eb0 --- /dev/null +++ b/packages/flutter_tools/lib/src/ios/device_support.dart @@ -0,0 +1,250 @@ +// 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 '../base/file_system.dart'; +import '../base/io.dart'; +import '../base/logger.dart'; +import '../base/process.dart'; +import '../base/version.dart'; +import '../convert.dart'; +import '../macos/xcode.dart'; + +/// A class to handle preparing the device support for an iOS/iPad device. +class IOSDeviceSupport { + IOSDeviceSupport({ + required Logger logger, + required ProcessUtils processUtils, + required Xcode? xcode, + required String deviceId, + required Directory? homeDirectory, + required String? modelCode, + required String? operatingSystemVersion, + required String? cpuArchitectureString, + }) : _xcode = xcode, + _logger = logger, + _processUtils = processUtils, + _deviceId = deviceId, + _homeDirectory = homeDirectory, + _modelCode = modelCode, + _operatingSystemVersion = operatingSystemVersion, + _cpuArchitectureString = cpuArchitectureString; + + final Logger _logger; + final ProcessUtils _processUtils; + final Xcode? _xcode; + + final String _deviceId; + final Directory? _homeDirectory; + final String? _modelCode; + final String? _operatingSystemVersion; + final String? _cpuArchitectureString; + + /// The directory that contains iOS Device Support symbols for all devices. + /// + /// Return null if the $HOME directory cannot be found. + late final Directory? _deviceSupportDirectory = _homeDirectory + ?.childDirectory('Library') + .childDirectory('Developer') + .childDirectory('Xcode') + .childDirectory('iOS DeviceSupport'); + + /// Command to copy device support symbols from device to host machine. + List get _prepareDeviceSupportCommand => [ + 'xcrun', + 'xcodebuild', + '-prepareDeviceSupport', + '-destination', + 'id=$_deviceId', + ]; + + /// Whether the prepareDeviceSupport command is available. Available on Xcode 16.3+. + late final bool _prepareDeviceSupportCommandAvailable = () { + final Version? xcodeVersion = _xcode?.currentVersion; + if (xcodeVersion == null || xcodeVersion < Version(16, 3, 0)) { + return false; + } + return true; + }(); + + /// Returns the directory for existing Device Support symbols. If there is not an existing + /// directory, returns null. + Directory? get existingDeviceSupportSymbols { + final (Directory? symbolDirectory, Directory? archSymbolDirectory) = _findSymbolDirectories(); + if (archSymbolDirectory != null && archSymbolDirectory.existsSync()) { + return archSymbolDirectory; + } + if (symbolDirectory != null && symbolDirectory.existsSync()) { + return symbolDirectory; + } + return null; + } + + /// Calls `xcodebuild -prepareDeviceSupport` and streams the logs when copying is in progress. + /// + /// The command copies symbols from the iOS device to the host machine and stores them in + /// $HOME/Library/Developer/Xcode/iOS DeviceSupport. Without these symbols, debugging is + /// extremely slow. + Future prepareDeviceSupport() async { + if (!_prepareDeviceSupportCommandAvailable || existingDeviceSupportSymbols != null) { + return; + } + + try { + final Process process = await _processUtils.start(_prepareDeviceSupportCommand); + + final timer = Timer(const Duration(seconds: 10), () { + _logger.printError( + 'Xcode is taking longer than expected to start preparing Device Support symbols...\n' + 'Try closing Xcode, connecting your device via USB, and running the following command:\n' + ' "${_prepareDeviceSupportCommand.join(' ')}"', + ); + }); + + var printToTrace = true; + final StreamSubscription stdoutSubscription = process.stdout + .transform(utf8.decoder) + .listen((String text) { + if (text.contains('Copying')) { + printToTrace = false; + _logger.printStatus( + 'Copying Device Support symbols. This may take several minutes to complete...\n' + 'Please do not connect or disconnect your device or open Xcode until finished.', + ); + timer.cancel(); + } + if (printToTrace) { + _logger.printTrace(text); + } else { + _logger.printStatus(text, newline: false); + } + }); + + final StreamSubscription stderrSubscription = process.stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((String line) { + _logger.printError(line); + }); + + try { + // Wait for stdout and stderr to be fully processed + // because process.exitCode may complete first. + await Future.wait(>[ + stdoutSubscription.asFuture(), + stderrSubscription.asFuture(), + ]); + + unawaited(stdoutSubscription.cancel()); + unawaited(stderrSubscription.cancel()); + + final int exitCode = await process.exitCode; + if (exitCode != 0) { + _logger.printError('xcodebuild -prepareDeviceSupport exited with code $exitCode'); + } + } finally { + timer.cancel(); + } + + // Print an empty line so that next prints aren't inline with logs from here. + if (!printToTrace) { + _logger.printStatus(''); + } + } on ProcessException catch (exception, stackTrace) { + _logger.printError( + 'Process exception running "xcodebuild -prepareDeviceSupport": $exception', + ); + _logger.printTrace('$stackTrace'); + } + } + + /// Returns a warning describing the status of Device Support symbols and guided actions to + /// resolve issues. + String? missingSymbolsWarning({bool warnWhenSymbolsExist = false}) { + const reducePerformance = + 'This will likely reduce debugging performance and may cause the app to hang on a white ' + 'screen during launch.'; + const expectedPath = + r'Once Device Support symbols are finished being copied, they are expected to be found at'; + + if (existingDeviceSupportSymbols != null) { + if (warnWhenSymbolsExist) { + final String action; + if (_prepareDeviceSupportCommandAvailable) { + action = + 'Connect the device via USB and trigger a copy:\n' + ' ${_prepareDeviceSupportCommand.join(' ')}'; + } else { + action = + 'Connect the device via USB, close and then re-open Xcode. It may take several ' + 'minutes for Xcode to copy symbols from the device. $expectedPath ${existingDeviceSupportSymbols!.path}'; + } + return 'Xcode Device Support symbols exist for this device, but are being read from ' + 'process memory. $reducePerformance\n' + 'To re-copy symbols from your device, complete the following steps:\n' + ' 1. Remove cached symbols:\n' + ' rm -rf "${existingDeviceSupportSymbols!.parent.path}"\n' + ' 2. $action'; + } + return null; + } + + final (Directory? symbolDirectory, Directory? archSymbolDirectory) = _findSymbolDirectories(); + + const unableToFind = 'Xcode Device Support was not found for this device.'; + const incompleteCopy = 'Xcode has not finished copying Device Support symbols for this device.'; + final String action; + if (_prepareDeviceSupportCommandAvailable) { + action = + 'To trigger Device Symbols to be copied, connect the device via USB, and run this command:\n' + ' "${_prepareDeviceSupportCommand.join(' ')}"'; + } else { + action = + 'To trigger Device Symbols to be copied, connect the device via USB, close and then ' + 're-open Xcode. It may take several minutes for Xcode to copy symbols from the device.'; + } + + const retryAction = 'Please retry "flutter run" once symbols are finished being copied.'; + + // The symbol directory may be null if the home directory could not be determined. + if (symbolDirectory == null) { + return '$unableToFind $reducePerformance\n$action\n$expectedPath ' + '\$HOME/Library/Developer/Xcode/iOS DeviceSupport\n$retryAction'; + } + + // If the device's directory exists, it means Xcode has at least started copying symbols. + final bool deviceDirExists = symbolDirectory.parent.existsSync(); + final status = deviceDirExists ? incompleteCopy : unableToFind; + final archPath = archSymbolDirectory != null ? ' or ${archSymbolDirectory.path}' : ''; + + return '$status $reducePerformance\n$action\n$expectedPath ${symbolDirectory.path}$archPath\n' + '$retryAction'; + } + + /// Helper method to find the Device Support Symbols directories for a given device. + /// + /// Returns a tuple of the symbol directory and the architecture-specific symbol directory. + /// If the paths to the directories cannot be constructed, returns (null, null). + /// + /// The architecture-specific symbol directory is only present on newer versions of iOS (27+). + (Directory?, Directory?) _findSymbolDirectories() { + if (_deviceSupportDirectory == null || _modelCode == null || _operatingSystemVersion == null) { + return (null, null); + } + final Directory deviceDirectory = _deviceSupportDirectory.childDirectory( + '$_modelCode $_operatingSystemVersion', + ); + final Directory symbolDirectory = deviceDirectory.childDirectory('Symbols'); + + // iOS 27+ devices seem to store the symbol directory in a sub-directory + // /Users/username/Library/Developer/Xcode/iOS DeviceSupport/iPad14,3 27.0 (24A5380h)/arm64e/Symbols + final Directory? archSymbolDirectory = + _cpuArchitectureString != null && _cpuArchitectureString.isNotEmpty + ? deviceDirectory.childDirectory(_cpuArchitectureString).childDirectory('Symbols') + : null; + + return (symbolDirectory, archSymbolDirectory); + } +} diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart index d25373e648135..069aabd9d793e 100644 --- a/packages/flutter_tools/lib/src/ios/devices.dart +++ b/packages/flutter_tools/lib/src/ios/devices.dart @@ -38,6 +38,7 @@ import '../protocol_discovery.dart'; import '../vmservice.dart'; import 'application_package.dart'; import 'core_devices.dart'; +import 'device_support.dart'; import 'ios_deploy.dart'; import 'ios_workflow.dart'; import 'iproxy.dart'; @@ -308,14 +309,19 @@ class IOSDevice extends Device { IOSDevice( super.id, { required FileSystem fileSystem, + required FileSystemUtils fileSystemUtils, + required ProcessUtils processUtils, required this.name, required this.cpuArchitecture, + String? cpuArchitectureString, required this.connectionInterface, required this.isConnected, required this.isPaired, required this.devModeEnabled, required this.isCoreDevice, String? sdkVersion, + String? modelCode, + String? operatingSystemVersion, required Platform platform, required IOSDeploy iosDeploy, required IMobileDevice iMobileDevice, @@ -325,14 +331,21 @@ class IOSDevice extends Device { required IProxy iProxy, required super.logger, required Analytics analytics, + required Xcode? xcode, }) : _sdkVersion = sdkVersion, + _modelCode = modelCode, + _operatingSystemVersion = operatingSystemVersion, + _cpuArchitectureString = cpuArchitectureString, _iosDeploy = iosDeploy, _iMobileDevice = iMobileDevice, _coreDeviceControl = coreDeviceControl, _coreDeviceLauncher = coreDeviceLauncher, _xcodeDebug = xcodeDebug, + _xcode = xcode, _iproxy = iProxy, _fileSystem = fileSystem, + _fileSystemUtils = fileSystemUtils, + _processUtils = processUtils, _logger = logger, _analytics = analytics, _platform = platform, @@ -344,15 +357,20 @@ class IOSDevice extends Device { } final String? _sdkVersion; + final String? _modelCode; + final String? _operatingSystemVersion; final IOSDeploy _iosDeploy; final Analytics _analytics; final FileSystem _fileSystem; + final FileSystemUtils _fileSystemUtils; + final ProcessUtils _processUtils; final Logger _logger; final Platform _platform; final IMobileDevice _iMobileDevice; final IOSCoreDeviceControl _coreDeviceControl; final IOSCoreDeviceLauncher _coreDeviceLauncher; final XcodeDebug _xcodeDebug; + final Xcode? _xcode; final IProxy _iproxy; Version? get sdkVersion { @@ -364,6 +382,19 @@ class IOSDevice extends Device { return sdkVersion?.major ?? 0; } + late final IOSDeviceSupport deviceSupport = IOSDeviceSupport( + logger: _logger, + processUtils: _processUtils, + xcode: _xcode, + homeDirectory: _fileSystemUtils.homeDirPath == null + ? null + : _fileSystem.directory(_fileSystemUtils.homeDirPath), + modelCode: _modelCode, + operatingSystemVersion: _operatingSystemVersion, + cpuArchitectureString: _cpuArchitectureString, + deviceId: id, + ); + @override final String name; @@ -372,6 +403,8 @@ class IOSDevice extends Device { final DarwinArch cpuArchitecture; + final String? _cpuArchitectureString; + @override /// The [connectionInterface] provided from `XCDevice.getAvailableIOSDevices` /// may not be accurate. Sometimes if it doesn't have a long enough time @@ -559,6 +592,11 @@ class IOSDevice extends Device { return LaunchResult.failed(); } + final bool shouldAttachDebugger = shouldAttachLLDBDebugger(debuggingOptions); + if (shouldAttachDebugger) { + await deviceSupport.prepareDeviceSupport(); + } + // Step 3: Attempt to install the application on the device. final List launchArguments = debuggingOptions.getIOSLaunchArguments( EnvironmentType.physical, @@ -607,6 +645,7 @@ class IOSDevice extends Device { mainPath: mainPath, discoveryTimeout: discoveryTimeout, shutdownHooks: shutdownHooks ?? globals.shutdownHooks, + shouldAttachDebugger: shouldAttachDebugger, ); installationResult = result ? 0 : 1; deploymentMethod = coreDeviceDeploymentMethod; @@ -863,8 +902,8 @@ class IOSDevice extends Device { pattern: RegExp( '`UIScene` lifecycle will soon be required|This process does not adopt UIScene lifecycle', ), - action: () { - globals.printWarning(uisceneWarning); + action: (String message) { + _logger.printWarning(uisceneWarning); }, excludeFromStream: true, ); @@ -874,7 +913,7 @@ class IOSDevice extends Device { final uisceneCrashInterceptor = LogInterceptor( identifier: 'uiscene_crash', pattern: RegExp(r'UIScene life\s?cycle is required'), - action: () { + action: (String message) { throwToolExit(kUISceneMigrationRequiredError); }, excludeFromStream: false, @@ -889,7 +928,7 @@ class IOSDevice extends Device { final appTerminatedInterceptor = LogInterceptor( identifier: 'app_terminated', pattern: RegExp('^App terminated due to signal'), - action: () { + action: (String message) { if (!appTerminatedCompleter.isCompleted) { appTerminatedCompleter.complete(); } @@ -897,6 +936,20 @@ class IOSDevice extends Device { excludeFromStream: false, ); deviceLogReader.addLogInterceptor(appTerminatedInterceptor); + + final missingSymbolsInterceptor = LogInterceptor( + identifier: 'missing_symbols', + pattern: LLDB.missingSymbolsPattern, + action: (String message) { + _logger.printTrace(message); + final String? warning = deviceSupport.missingSymbolsWarning(warnWhenSymbolsExist: true); + if (warning != null) { + _logger.printWarning(warning); + } + }, + excludeFromStream: true, + ); + deviceLogReader.addLogInterceptor(missingSymbolsInterceptor); } /// Find the Dart VM url using ProtocolDiscovery (logs from `idevicesyslog`) @@ -968,7 +1021,7 @@ class IOSDevice extends Device { return LogInterceptor( identifier: kJITCrashLogInterceptorIdentifier, pattern: kJITCrashFailureMessage, - action: () { + action: (String message) { throwToolExit(jitCrashFailureInstructions(deviceSdkVersion)); }, excludeFromStream: false, @@ -1014,6 +1067,15 @@ class IOSDevice extends Device { ); } + /// Whether the LLDB debugger should be attached. + /// + /// The LLDB debugger should only be attached in debug mode or if the user uses the + /// `--ios-profile-debugger` flag in profile mode. + bool shouldAttachLLDBDebugger(DebuggingOptions debuggingOptions) { + return debuggingOptions.buildInfo.isDebug || + (debuggingOptions.buildInfo.isProfile && (debuggingOptions.iosProfileDebugger ?? false)); + } + /// Uses either `devicectl` or Xcode automation to install, launch, and debug /// apps on physical iOS devices. /// @@ -1037,6 +1099,7 @@ class IOSDevice extends Device { required IOSApp package, required List launchArguments, required String? mainPath, + required bool shouldAttachDebugger, required ShutdownHooks shutdownHooks, @visibleForTesting Duration? discoveryTimeout, }) async { @@ -1079,13 +1142,10 @@ class IOSDevice extends Device { await deviceLogReader.listenToCoreDeviceLauncher(_coreDeviceLauncher); } - final bool shouldAttachDebugger = - debuggingOptions.buildInfo.isDebug || - (debuggingOptions.buildInfo.isProfile && (debuggingOptions.iosProfileDebugger ?? false)); - if (shouldAttachDebugger) { final bool launchSuccess = await _coreDeviceLauncher.launchAppWithLLDBDebugger( deviceId: id, + deviceSupport: deviceSupport, bundlePath: package.deviceBundlePath, bundleId: package.id, launchArguments: launchArguments, @@ -1424,7 +1484,7 @@ class LogInterceptor { final Pattern pattern; /// If the log contain the [pattern], the [action] will be called. - final void Function() action; + final void Function(String message) action; /// If `true`, the log will be excluded from being added to the stream. final bool excludeFromStream; @@ -1462,7 +1522,7 @@ abstract class SharedIOSDeviceLogReader extends DeviceLogReader { bool _interceptLog(String message) { for (final LogInterceptor interceptor in _logInterceptors) { if (message.contains(interceptor.pattern)) { - interceptor.action(); + interceptor.action(message); if (interceptor.excludeFromStream) { return true; } diff --git a/packages/flutter_tools/lib/src/ios/lldb.dart b/packages/flutter_tools/lib/src/ios/lldb.dart index 31f24abbdff50..9a030ddbc7710 100644 --- a/packages/flutter_tools/lib/src/ios/lldb.dart +++ b/packages/flutter_tools/lib/src/ios/lldb.dart @@ -7,11 +7,14 @@ library; import 'dart:async'; +import '../base/file_system.dart'; import '../base/io.dart'; import '../base/logger.dart'; import '../base/process.dart'; import '../base/utils.dart'; +import '../base/version.dart'; import '../build_info.dart'; +import 'device_support.dart'; import 'xcodeproj.dart'; /// LLDB is the default debugger in Xcode on macOS. Once the application has @@ -23,13 +26,16 @@ class LLDB { required Logger logger, required ProcessUtils processUtils, required XcodeProjectInterpreter xcodeProjectInterpreter, + required Version? deviceVersion, }) : _logger = logger, _processUtils = processUtils, - _xcodeProjectInterpreter = xcodeProjectInterpreter; + _xcodeProjectInterpreter = xcodeProjectInterpreter, + _deviceVersion = deviceVersion; final Logger _logger; final ProcessUtils _processUtils; final XcodeProjectInterpreter _xcodeProjectInterpreter; + final Version? _deviceVersion; _LLDBProcess? _lldbProcess; @@ -39,6 +45,8 @@ class LLDB { /// Whether or not the LLDB process has attached and resumed the application process. var _isAttached = false; + var _warnedAboutMissingSymbols = false; + /// The process id of the application running on the iOS device. int? get appProcessId => _lldbProcess?.appProcessId; @@ -74,12 +82,32 @@ class LLDB { /// Example: "- Hook 1 (thread backtrace all)" static final _stopHookProcessedPattern = RegExp(r'- Hook \d+'); + /// Pattern of lldb log when the application has started. + /// + /// Example: Target 0: (Flutter Gallery) stopped. + static final _targetStoppedPattern = RegExp(r'Target .* stopped.'); + + /// LLDB command to continue execution of all threads in the current process. + static const _processContinueCommand = 'process continue'; + + /// LLDB command to show backtraces of all thread call stacks. + static const _threadBacktraceAllCommand = 'thread backtrace all'; + + /// LLDB command to detach from the current target process. + static const _detachCommand = 'detach'; + /// A list of log patterns to ignore. static final _ignorePatterns = [ RegExp(r'\d+ location added to breakpoint \d+'), _stopHookProcessedPattern, ]; + /// Pattern of lldb log when libobjc.A.dylib is being read from process memory. This indicates + /// that Device Support symbols were not found on the host machine. + static final missingSymbolsPattern = RegExp( + 'warning: libobjc.A.dylib is being read from process memory.', + ); + /// Breakpoint script required for JIT on iOS. /// /// This should match the "handle_new_rx_page" function in [IosProject._lldbPythonHelperTemplate]. @@ -100,8 +128,6 @@ if not error.Success(): print(f'Failed to write into {base}[+{page_len}]', error) return -# If the returned value is False, that tells LLDB not to stop at the breakpoint -return False '''; /// Starts an LLDB process and inputs commands to start debugging the [appProcessId]. @@ -114,34 +140,58 @@ return False required int appProcessId, required LLDBLogForwarder lldbLogForwarder, required BuildMode mode, + required IOSDeviceSupport deviceSupport, }) async { Timer? timer; try { timer = Timer(const Duration(minutes: 1), () { - _logger.printError( - 'LLDB is taking longer than expected to start debugging the app. ' - "LLDB debugging can be disabled for the project by adding the following in the project's pubspec.yaml:\n" - 'flutter:\n' - ' config:\n' - ' enable-lldb-debugging: false\n' - 'Or disable LLDB debugging globally with the following command:\n' - ' "flutter config --no-enable-lldb-debugging"', - ); + final String? warning = deviceSupport.missingSymbolsWarning(); + if (warning != null) { + _logger.printWarning( + 'LLDB is taking longer than expected to start debugging the app. $warning', + ); + _warnedAboutMissingSymbols = true; + } else { + _logger.printError( + 'LLDB is taking longer than expected to start debugging the app. Try again using ' + '--verbose and file a bug including the verbose logs ' + 'at https://github.com/flutter/flutter/issues/new?template=01_activation.yml', + ); + } }); + // iOS 27+ devices sometimes fail to stop at the JIT breakpoint (only used in debug mode) + // when `auto-continue` is enabled. This causes the app to crash. To workaround this, we + // manually listen for stops and continue the process if the stop is due to the breakpoint. + var manualContinue = false; + if (mode == BuildMode.debug && + _deviceVersion != null && + _deviceVersion >= Version(27, 0, 0)) { + manualContinue = true; + } + final bool start = await _startLLDB( appProcessId: appProcessId, lldbLogForwarder: lldbLogForwarder, + manualContinue: manualContinue, ); if (!start) { return false; } await _selectDevice(deviceId); + await _addSymbolSearchPaths(deviceSupport); + if (mode == BuildMode.debug) { - await _setBreakpoint(); + await _setBreakpoint(manualContinue); } await _attachToAppProcess(appProcessId); - await _setupStopHooks(); + + // Stop hooks should only be used when manualContinue is false. Otherwise, it would run on + // every breakpoint stop instead of just crashes. + if (!manualContinue) { + await _setupStopHooks(); + } + await _printDeviceSupportStatus(); await _resumeProcess(mode); _isAttached = true; } on _LLDBError catch (e) { @@ -162,6 +212,7 @@ return False Future _startLLDB({ required int appProcessId, required LLDBLogForwarder lldbLogForwarder, + required bool manualContinue, }) async { if (_lldbProcess != null) { _logger.printTrace( @@ -178,18 +229,63 @@ return False appProcessId: appProcessId, logger: _logger, ); + + void printLine(String line) { + if (_isAttached && !_ignoreLog(line)) { + // Only forwards logs after LLDB is attached. All logs before then are part of the + // attach process. + + lldbLogForwarder.addLog(line); + } else { + _logger.printTrace('[lldb]: $line'); + _logCompleter?.checkForMatch(line); + } + } + + var processIsStoppedAfterAttaching = false; + final List stopLogs = []; final StreamSubscription stdoutSubscription = _lldbProcess!.stdout .transform(utf8LineDecoder) - .listen((String line) { - if (_isAttached && !_ignoreLog(line)) { - // Only forwards logs after LLDB is attached. All logs before then are part of the - // attach process. + .listen((String line) async { + // If not manually handling stops or if not attached yet, print the line directly. + if (!manualContinue || !_isAttached) { + printLine(line); + return; + } - lldbLogForwarder.addLog(line); - } else { - _logger.printTrace('[lldb]: $line'); - _logCompleter?.checkForMatch(line); + // When manually continuing, listen for process stops and then handle the stop by + // either continuing the process or detaching. + // + // When the process stops, collect logs until the target has stopped. Once the target + // has stopped, check if it stopped due to a breakpoint. If it has, continue and + // process and discard the logs after the process resumes. If the stop is not caused by + // a breakpoint, print the collected logs, get the stack trace, and detach. + if (line.contains(_lldbProcessStopped)) { + processIsStoppedAfterAttaching = true; } + if (processIsStoppedAfterAttaching) { + stopLogs.add(line); + } + if (line.contains(_targetStoppedPattern)) { + if (stopLogs.any((log) => log.contains('stop reason = breakpoint'))) { + await _lldbProcess?.stdinWriteln(_processContinueCommand); + } else { + stopLogs.forEach(printLine); + await _lldbProcess?.stdinWriteln(_threadBacktraceAllCommand); + await _lldbProcess?.stdinWriteln(_detachCommand); + processIsStoppedAfterAttaching = false; + } + return; + } + if (processIsStoppedAfterAttaching) { + if (line.contains(_lldbProcessResuming)) { + processIsStoppedAfterAttaching = false; + stopLogs.clear(); + } + return; + } + + printLine(line); }); final StreamSubscription stderrSubscription = _lldbProcess!.stderr @@ -232,6 +328,7 @@ return False } _logCompleter = null; _isAttached = false; + _warnedAboutMissingSymbols = false; return success; } @@ -240,6 +337,27 @@ return False await _lldbProcess?.stdinWriteln('device select $deviceId'); } + /// Adds the symbol search paths to the LLDB process. + Future _addSymbolSearchPaths(IOSDeviceSupport deviceSupport) async { + final Directory? symbolDirectory = deviceSupport.existingDeviceSupportSymbols; + if (symbolDirectory == null) { + return; + } + await _lldbProcess?.stdinWriteln( + 'platform select remote-ios --sysroot "${symbolDirectory.path}"', + ); + } + + /// Prints logs of available Device Support + Future _printDeviceSupportStatus() async { + final Future futureLog = _startWaitingForLog( + RegExp(r'\s*Platform:'), + ).then((value) => value, onError: _handleAsyncError); + + await _lldbProcess?.stdinWriteln('platform status'); + await futureLog; + } + /// Attaches LLDB to the [appProcessId] running on the device. Future _attachToAppProcess(int appProcessId) async { // Since the app starts stopped (--start-stopped), we expect a stopped state @@ -254,14 +372,17 @@ return False /// Sets a breakpoint, waits for it print the breakpoint id, and adds a python /// script command to be executed whenever the breakpoint is hit. - Future _setBreakpoint() async { + Future _setBreakpoint(bool manualContinue) 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 breakpointSetCommand = manualContinue + ? r"breakpoint set --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'" + : r"breakpoint set --auto-continue true --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'"; + + await _lldbProcess?.stdinWriteln(breakpointSetCommand); + final String log = await futureLog; final Match? match = _breakpointPattern.firstMatch(log); final String? breakpointId = match?.group(1); @@ -290,7 +411,7 @@ return False mode == BuildMode.debug ? _lldbBreakpointAdded : _lldbProcessResuming, ).then((value) => value, onError: _handleAsyncError); - await _lldbProcess?.stdinWriteln('process continue'); + await _lldbProcess?.stdinWriteln(_processContinueCommand); await futureLog; } @@ -302,7 +423,9 @@ return False final Future futureLog = _startWaitingForLog( _stopHookAddedPattern, ).then((value) => value, onError: _handleAsyncError); - await _lldbProcess?.stdinWriteln('target stop-hook add -o "thread backtrace all" -o "detach"'); + await _lldbProcess?.stdinWriteln( + 'target stop-hook add -o "$_threadBacktraceAllCommand" -o "$_detachCommand"', + ); await futureLog; } @@ -346,6 +469,13 @@ return False } bool _ignoreLog(String log) { + // We only want to warn about missing symbols once. + if (log.contains(missingSymbolsPattern)) { + if (_warnedAboutMissingSymbols) { + return true; + } + _warnedAboutMissingSymbols = true; + } return _ignorePatterns.any((Pattern pattern) => log.contains(pattern)); } } diff --git a/packages/flutter_tools/lib/src/ios/mac.dart b/packages/flutter_tools/lib/src/ios/mac.dart index 0a6c0cd66fd05..ea4797b59326b 100644 --- a/packages/flutter_tools/lib/src/ios/mac.dart +++ b/packages/flutter_tools/lib/src/ios/mac.dart @@ -172,6 +172,11 @@ Future buildXcodeProject({ fileSystem: globals.fs, plistParser: globals.plistParser, config: globals.config, + analytics: globals.analytics, + hostPlatform: globals.platform, + operatingSystemUtils: globals.os, + flutterVersion: globals.flutterVersion, + reportCrashes: !await globals.isRunningOnBot ), SwiftPackageManagerGitignoreMigration(project, globals.logger), MetalAPIValidationMigrator.ios(app.project, globals.logger), @@ -205,6 +210,7 @@ Future buildXcodeProject({ fileSystem: globals.fs, logger: globals.logger, cocoapods: globals.cocoaPods, + analytics: globals.analytics, ); await removeExtendedAttributesForProject( @@ -328,7 +334,7 @@ Future buildXcodeProject({ .fetchDependenciesAndGenerateXcodebuildArgs( app.project, globals.fs.directory(buildDirectoryPath), - skipPackageUpdatesAndValidation: false, + skipPackageValidation: false, ); final buildCommands = [...xcodebuildCommandArgs, '-configuration', configuration]; @@ -933,6 +939,7 @@ Future diagnoseXcodeBuildFailure( platform: platform, logger: logger, fileSystem: fileSystem, + analytics: analytics, device: device, ); @@ -1160,6 +1167,7 @@ Future _handleIssues( required FlutterDarwinPlatform platform, required Logger logger, required FileSystem fileSystem, + required Analytics analytics, Device? device, }) async { var requiresProvisioningProfile = false; @@ -1250,6 +1258,11 @@ Future _handleIssues( final bool usesCocoapods = xcodeProject.podfile.existsSync(); final bool usesSwiftPackageManager = xcodeProject.usesSwiftPackageManager; if (usesCocoapods && usesSwiftPackageManager) { + for (final module in duplicateModules) { + analytics.send( + Event.appleUsageEvent(workflow: 'duplicate-modules-build-failure', parameter: module), + ); + } logger.printError( 'Your project uses both CocoaPods and Swift Package Manager, which can ' 'cause the above error. It may be caused by there being both a CocoaPod ' diff --git a/packages/flutter_tools/lib/src/ios/simulators.dart b/packages/flutter_tools/lib/src/ios/simulators.dart index 8cac0a614e7b1..e9c9193aea5c2 100644 --- a/packages/flutter_tools/lib/src/ios/simulators.dart +++ b/packages/flutter_tools/lib/src/ios/simulators.dart @@ -829,7 +829,7 @@ class _IOSSimulatorLogReader extends SharedIOSDeviceLogReader { final uisceneCrashInterceptor = LogInterceptor( identifier: 'uiscene_crash', pattern: RegExp(r'UIScene life\s?cycle is required'), - action: () { + action: (String message) { throwToolExit(kUISceneMigrationRequiredError); }, excludeFromStream: false, diff --git a/packages/flutter_tools/lib/src/ios/xcodeproj.dart b/packages/flutter_tools/lib/src/ios/xcodeproj.dart index 7f14e808d3c88..c7cd476278b8a 100644 --- a/packages/flutter_tools/lib/src/ios/xcodeproj.dart +++ b/packages/flutter_tools/lib/src/ios/xcodeproj.dart @@ -189,7 +189,7 @@ class XcodeProjectInterpreter { Future> fetchDependenciesAndGenerateXcodebuildArgs( XcodeBasedProject xcodeProject, Directory buildDirectory, { - bool skipPackageUpdatesAndValidation = true, + bool skipPackageValidation = true, }) async { // All `xcodebuild` project commands will download and resolve Swift packages. // We should always prefetch Swift packages before running any `xcodebuild` project command @@ -198,7 +198,7 @@ class XcodeProjectInterpreter { return _xcodebuildProjectCommandArguments( buildDirectory, - skipPackageUpdatesAndValidation: skipPackageUpdatesAndValidation, + skipPackageValidation: skipPackageValidation, ); } @@ -209,11 +209,11 @@ class XcodeProjectInterpreter { /// Returns a list of required arguments for the `xcodebuild` Xcode project command. /// - /// When [skipPackageUpdatesAndValidation] is true, it uses arguments to attempt skipping any - /// Swift package updates and validation. + /// When [skipPackageValidation] is true, it uses arguments to attempt skipping any Swift + /// package validation. List _xcodebuildProjectCommandArguments( Directory buildDirectory, { - bool skipPackageUpdatesAndValidation = true, + bool skipPackageValidation = true, }) { final String cachePath = swiftPackageCachePath(buildDirectory); return [ @@ -221,8 +221,7 @@ class XcodeProjectInterpreter { 'xcodebuild', '-clonedSourcePackagesDirPath', cachePath, - if (skipPackageUpdatesAndValidation) ...[ - '-skipPackageUpdates', + if (skipPackageValidation) ...[ '-skipPackagePluginValidation', '-skipPackageSignatureValidation', ], @@ -395,9 +394,9 @@ class XcodeProjectInterpreter { await xcodeProject.prefetchSwiftPackages( xcodebuildProjectCommandArguments: _xcodebuildProjectCommandArguments( buildDirectory, - // skipPackageUpdatesAndValidation should be false so that when subsequent xcodebuild + // skipPackageValidation should be false so that when subsequent xcodebuild // commands run, packages should already be resolved, downloaded, updated, and validated. - skipPackageUpdatesAndValidation: false, + skipPackageValidation: false, ), processUtils: _processUtils, logger: _logger, @@ -416,29 +415,33 @@ class XcodeProjectInterpreter { // The exit code returned by 'xcodebuild -list' when the project is corrupted. const corruptedProjectExitCode = 74; bool allowedFailures(int c) => c == missingProjectExitCode || c == corruptedProjectExitCode; - final List xcodebuildCommandArgs = await fetchDependenciesAndGenerateXcodebuildArgs( - xcodeProject, - buildDirectory, - ); - final RunResult result = await _processUtils.run( - [ - ...xcodebuildCommandArgs, - '-list', - if (projectFilename != null) ...['-project', projectFilename], - ], - throwOnError: true, - allowedFailures: allowedFailures, - workingDirectory: xcodeProject.hostAppRoot.path, - ); - if (allowedFailures(result.exitCode)) { - // User configuration error, tool exit instead of crashing. - throwToolExit('Unable to get Xcode project information:\n ${result.stderr}'); + try { + final List xcodebuildCommandArgs = await fetchDependenciesAndGenerateXcodebuildArgs( + xcodeProject, + buildDirectory, + ); + final RunResult result = await _processUtils.run( + [ + ...xcodebuildCommandArgs, + '-list', + if (projectFilename != null) ...['-project', projectFilename], + ], + throwOnError: true, + allowedFailures: allowedFailures, + workingDirectory: xcodeProject.hostAppRoot.path, + ); + if (allowedFailures(result.exitCode)) { + // User configuration error, tool exit instead of crashing. + throwToolExit('Unable to get Xcode project information:\n ${result.stderr}'); + } + return XcodeProjectInfo.fromXcodeBuildOutput( + result.toString(), + _logger, + ignoredSchemes: await _ignoredSwiftPackageSchemes(xcodeProject, buildDirectory), + ); + } on ProcessException catch (exception) { + throwToolExit('Unable to get Xcode project information:\n $exception'); } - return XcodeProjectInfo.fromXcodeBuildOutput( - result.toString(), - _logger, - ignoredSchemes: await _ignoredSwiftPackageSchemes(xcodeProject, buildDirectory), - ); } /// Returns scheme-name candidates for Swift packages that should be excluded from diff --git a/packages/flutter_tools/lib/src/isolated/native_assets/ios/native_assets.dart b/packages/flutter_tools/lib/src/isolated/native_assets/ios/native_assets.dart index 7eb1f1e049b4b..56343d3f9acf8 100644 --- a/packages/flutter_tools/lib/src/isolated/native_assets/ios/native_assets.dart +++ b/packages/flutter_tools/lib/src/isolated/native_assets/ios/native_assets.dart @@ -11,7 +11,7 @@ import '../macos/native_assets_host.dart'; import '../native_assets.dart'; // TODO(dcharkes): Fetch minimum iOS version from somewhere. https://github.com/flutter/flutter/issues/145104 -const targetIOSVersion = 13; +const targetIOSVersion = 15; IOSSdk getIOSSdk(EnvironmentType environmentType) { return switch (environmentType) { diff --git a/packages/flutter_tools/lib/src/isolated/native_assets/native_assets.dart b/packages/flutter_tools/lib/src/isolated/native_assets/native_assets.dart index 3e0ec38639659..f021e6380281b 100644 --- a/packages/flutter_tools/lib/src/isolated/native_assets/native_assets.dart +++ b/packages/flutter_tools/lib/src/isolated/native_assets/native_assets.dart @@ -12,6 +12,7 @@ import 'package:logging/logging.dart' as logging; import 'package:package_config/package_config_types.dart'; import '../../base/common.dart'; +import '../../base/error_handling_io.dart'; import '../../base/file_system.dart'; import '../../base/logger.dart'; import '../../base/platform.dart'; @@ -830,8 +831,8 @@ Future> _copyNativeCodeAssetsForOS( if (!targetDir.existsSync()) { targetDir.createSync(recursive: true); } - await for (final FileSystemEntity entity in targetDir.list()) { - await entity.delete(recursive: true); + for (final FileSystemEntity entity in await targetDir.list().toList()) { + ErrorHandlingFileSystem.deleteIfExists(entity, recursive: true); } if (assetTargetLocations.isEmpty) { diff --git a/packages/flutter_tools/lib/src/isolated/resident_web_runner.dart b/packages/flutter_tools/lib/src/isolated/resident_web_runner.dart index 204cd4db1b8c5..b86216161985d 100644 --- a/packages/flutter_tools/lib/src/isolated/resident_web_runner.dart +++ b/packages/flutter_tools/lib/src/isolated/resident_web_runner.dart @@ -26,6 +26,7 @@ import '../base/utils.dart'; import '../build_info.dart'; import '../cache.dart'; import '../dart/language_version.dart'; +import '../dart/package_map.dart'; import '../devfs.dart'; import '../device.dart'; import '../flutter_plugins.dart'; @@ -404,14 +405,14 @@ class ResidentWebRunner extends ResidentRunner { stackTrace: stackTrace, ); throwToolExit('Failed to connect to the web debug service.'); - } on DartDevelopmentServiceException catch (error) { + } on DartDevelopmentServiceException catch (error, stackTrace) { // The application may have started shutting down before DDS was able to finish establishing // its connection to DWDS. Don't treat this as an unhandled exception. appFailedToStart(); - if (error.errorCode == DartDevelopmentServiceException.failedToStartError) { - throwToolExit(kExitMessage); + if (error.errorCode != DartDevelopmentServiceException.failedToStartError) { + _logger.printError(error.message, stackTrace: stackTrace); } - rethrow; + throwToolExit(kExitMessage); } on Exception { appFailedToStart(); rethrow; @@ -514,7 +515,7 @@ class ResidentWebRunner extends ResidentRunner { } } - if (_connectionResult == null) { + if (supportsServiceProtocol && _connectionResult == null) { return _handleNoClientsAvailable(status); } @@ -710,7 +711,7 @@ class ResidentWebRunner extends ResidentRunner { // the web_plugin_registrant.dart file alongside the generated main.dart const generatedImport = 'web_plugin_registrant.dart'; - Uri? importedEntrypoint = packageConfig!.toPackageUri(mainUri); + Uri? importedEntrypoint = packageConfig!.toPackageUriForWorkspace(mainUri); // Special handling for entrypoints that are not under lib, such as test scripts. if (importedEntrypoint == null) { final String parent = _fileSystem.file(mainUri).parent.path; diff --git a/packages/flutter_tools/lib/src/isolated/web_asset_server.dart b/packages/flutter_tools/lib/src/isolated/web_asset_server.dart index 8ba8c0f06a65a..640925b965f51 100644 --- a/packages/flutter_tools/lib/src/isolated/web_asset_server.dart +++ b/packages/flutter_tools/lib/src/isolated/web_asset_server.dart @@ -25,6 +25,7 @@ import '../base/platform.dart'; import '../build_info.dart'; import '../cache.dart'; import '../convert.dart'; +import '../dart/package_map.dart'; import '../globals.dart' as globals; import '../web/bootstrap.dart'; import '../web/chrome.dart'; @@ -347,7 +348,7 @@ class WebAssetServer implements AssetReader { PackageUriMapper(packageConfig), digestProvider, BuildSettings( - appEntrypoint: packageConfig.toPackageUri( + appEntrypoint: packageConfig.toPackageUriForWorkspace( fileSystem.file(entrypoint).absolute.uri, ), canaryFeatures: canaryFeatures, @@ -361,7 +362,7 @@ class WebAssetServer implements AssetReader { PackageUriMapper(packageConfig), digestProvider, BuildSettings( - appEntrypoint: packageConfig.toPackageUri( + appEntrypoint: packageConfig.toPackageUriForWorkspace( fileSystem.file(entrypoint).absolute.uri, ), canaryFeatures: canaryFeatures, diff --git a/packages/flutter_tools/lib/src/macos/build_macos.dart b/packages/flutter_tools/lib/src/macos/build_macos.dart index 832121498b7e6..83a9495abd6aa 100644 --- a/packages/flutter_tools/lib/src/macos/build_macos.dart +++ b/packages/flutter_tools/lib/src/macos/build_macos.dart @@ -86,6 +86,10 @@ Future buildMacOS({ SizeAnalyzer? sizeAnalyzer, bool usingCISystem = false, }) async { + if (globals.xcodeProjectInterpreter?.isInstalled != true) { + throwToolExit(globals.userMessages.xcodeMissing); + } + final Directory? xcodeWorkspace = flutterProject.macos.xcodeWorkspace; if (xcodeWorkspace == null) { throwToolExit( @@ -117,6 +121,11 @@ Future buildMacOS({ fileSystem: globals.fs, plistParser: globals.plistParser, config: globals.config, + analytics: globals.analytics, + hostPlatform: globals.platform, + operatingSystemUtils: globals.os, + flutterVersion: globals.flutterVersion, + reportCrashes: !await globals.isRunningOnBot ), SwiftPackageManagerGitignoreMigration(flutterProject, globals.logger), MetalAPIValidationMigrator.macos(flutterProject.macos, globals.logger), @@ -132,6 +141,7 @@ Future buildMacOS({ fileSystem: globals.fs, logger: globals.logger, cocoapods: globals.cocoaPods, + analytics: globals.analytics, ); final String buildDirectoryPath = getMacOSBuildDirectory(); @@ -151,11 +161,14 @@ Future buildMacOS({ projectFilename: xcodeProjectName, buildDirectory: flutterBuildDir, ); - final String? scheme = projectInfo?.schemeFor(buildInfo); + if (projectInfo == null) { + throwToolExit('Unable to get Xcode project information.'); + } + final String? scheme = projectInfo.schemeFor(buildInfo); if (scheme == null) { - projectInfo!.reportFlavorNotFoundAndExit(); + projectInfo.reportFlavorNotFoundAndExit(); } - final String? configuration = projectInfo?.buildConfigurationFor(buildInfo, scheme); + final String? configuration = projectInfo.buildConfigurationFor(buildInfo, scheme); if (configuration == null) { throwToolExit('Unable to find expected configuration in Xcode project.'); } @@ -274,7 +287,7 @@ Future buildMacOS({ .fetchDependenciesAndGenerateXcodebuildArgs( flutterProject.macos, globals.fs.directory(buildDirectoryPath), - skipPackageUpdatesAndValidation: false, + skipPackageValidation: false, ); result = await globals.processUtils.stream( [ diff --git a/packages/flutter_tools/lib/src/macos/cocoapod_utils.dart b/packages/flutter_tools/lib/src/macos/cocoapod_utils.dart index 5a676b734d0ec..55f38ff954d64 100644 --- a/packages/flutter_tools/lib/src/macos/cocoapod_utils.dart +++ b/packages/flutter_tools/lib/src/macos/cocoapod_utils.dart @@ -62,6 +62,7 @@ Future processPodsIfNeeded( templateRenderer: globals.templateRenderer, processUtils: globals.processUtils, config: globals.config, + logger: globals.logger, ); final FlutterDarwinPlatform platform = xcodeProject is IosProject ? FlutterDarwinPlatform.ios diff --git a/packages/flutter_tools/lib/src/macos/cocoapods.dart b/packages/flutter_tools/lib/src/macos/cocoapods.dart index 0fe7a28879cd7..5564ac76e6af2 100644 --- a/packages/flutter_tools/lib/src/macos/cocoapods.dart +++ b/packages/flutter_tools/lib/src/macos/cocoapods.dart @@ -540,6 +540,13 @@ class CocoaPods { ), ); if (missingPluginSupportsSwiftPM) { + _analytics.send( + Event.appleUsageEvent( + workflow: 'cocoapod-swiftpm-interdependency-failure', + parameter: requiringPlugin, + result: missingPlugin, + ), + ); return 'Error: A dependency conflict has occurred because $requiringPlugin uses CocoaPods while ' '$missingPlugin uses Swift Package Manager. Please contact the $requiringPlugin ' 'maintainers to request Swift Package Manager adoption.\n\n' diff --git a/packages/flutter_tools/lib/src/macos/darwin_dependency_management.dart b/packages/flutter_tools/lib/src/macos/darwin_dependency_management.dart index d333091f99ff9..b4a85adfed857 100644 --- a/packages/flutter_tools/lib/src/macos/darwin_dependency_management.dart +++ b/packages/flutter_tools/lib/src/macos/darwin_dependency_management.dart @@ -175,6 +175,7 @@ class DarwinDependencyManagement { required FileSystem fileSystem, required Logger logger, required CocoaPods? cocoapods, + required Analytics analytics, }) async { final bool projectUsesSwiftPM = xcodeProject.usesSwiftPackageManager && @@ -222,6 +223,7 @@ class DarwinDependencyManagement { cocoapodOnlyPlugins: cocoapodOnlyPlugins, logger: logger, platform: platform, + analytics: analytics, ); await _printRemoveCocoapodIntegrationMessage( @@ -279,6 +281,7 @@ class DarwinDependencyManagement { required List cocoapodOnlyPlugins, required Logger logger, required FlutterDarwinPlatform platform, + required Analytics analytics, }) { if (cocoapodOnlyPlugins.isEmpty) { return; @@ -289,6 +292,11 @@ class DarwinDependencyManagement { 'This will become an error in a future version of Flutter. Please contact the plugin ' 'maintainers to request Swift Package Manager adoption.', ); + for (final plugin in cocoapodOnlyPlugins) { + analytics.send( + Event.appleUsageEvent(workflow: 'cocoapod-only-plugin-warning', parameter: plugin), + ); + } } /// Print a message recommending removing CocoaPod integration when all plugins support SwiftPM. diff --git a/packages/flutter_tools/lib/src/macos/swift_package_manager.dart b/packages/flutter_tools/lib/src/macos/swift_package_manager.dart index 6c2d83670af80..1339079a273be 100644 --- a/packages/flutter_tools/lib/src/macos/swift_package_manager.dart +++ b/packages/flutter_tools/lib/src/macos/swift_package_manager.dart @@ -8,6 +8,7 @@ import '../base/common.dart'; import '../base/config.dart'; import '../base/error_handling_io.dart'; import '../base/file_system.dart'; +import '../base/logger.dart'; import '../base/process.dart'; import '../base/template.dart'; import '../base/version.dart'; @@ -43,88 +44,118 @@ class SwiftPackageManager { required TemplateRenderer templateRenderer, required ProcessUtils processUtils, required Config config, + Logger? logger, }) : _fileSystem = fileSystem, _templateRenderer = templateRenderer, _processUtils = processUtils, - _config = config; + _config = config, + _logger = logger; final FileSystem _fileSystem; final TemplateRenderer _templateRenderer; final ProcessUtils _processUtils; final Config _config; + final Logger? _logger; - /// Creates a Swift Package called 'FlutterGeneratedPluginSwiftPackage' that - /// has dependencies on Flutter plugins that are compatible with Swift - /// Package Manager. Future generatePluginsSwiftPackage( List plugins, FlutterDarwinPlatform platform, XcodeBasedProject project, { bool flutterAsADependency = true, }) async { - final Directory symlinkDirectory = project.relativeSwiftPackagesDirectory; - ErrorHandlingFileSystem.deleteIfExists(symlinkDirectory, recursive: true); - symlinkDirectory.createSync(recursive: true); - - final ( - List packageDependencies, - List targetDependencies, - ) = _dependenciesForPlugins( - plugins: plugins, - platform: platform, - symlinkDirectory: symlinkDirectory, - pathRelativeTo: project.flutterPluginSwiftPackageDirectory.path, - ); + final String lockPath = project.ephemeralDirectory.childFile('.swift_pm.lock').path; + await _fileSystem.runLocked( + lockPath: lockPath, + logger: _logger, + traceMessage: + 'Waiting to be able to obtain lock of Swift Package Manager directory: $lockPath', + warningMessage: + 'Waiting for another flutter command to release the Swift Package Manager lock...', + scope: () async { + final Directory symlinkDirectory = project.relativeSwiftPackagesDirectory; + try { + symlinkDirectory.createSync(recursive: true); + } on FileSystemException catch (e) { + if (!_fileSystem.isDirectorySync(symlinkDirectory.path)) { + throwToolExit( + 'Failed to create Swift Packages directory at "${symlinkDirectory.path}": $e', + ); + } + } - // If there aren't any Swift Package plugins and the project hasn't been - // migrated yet, don't generate a Swift package or migrate the app since - // it's not needed. If the project has already been migrated, regenerate - // the Package.swift even if there are no dependencies in case there - // were dependencies previously. - if (packageDependencies.isEmpty && !project.flutterPluginSwiftPackageInProjectSettings) { - return; - } + final ( + List packageDependencies, + List targetDependencies, + Set expectedBasenames, + ) = _dependenciesForPlugins( + plugins: plugins, + platform: platform, + symlinkDirectory: symlinkDirectory, + pathRelativeTo: project.flutterPluginSwiftPackageDirectory.path, + ); - // Add Flutter framework Swift package dependency - if (flutterAsADependency) { - final ( - SwiftPackagePackageDependency flutterFrameworkPackageDependency, - SwiftPackageTargetDependency flutterFrameworkTargetDependency, - ) = _dependencyForFlutterFramework( - pathRelativeTo: project.flutterPluginSwiftPackageDirectory.path, - platform: platform, - project: project, - ); - packageDependencies.add(flutterFrameworkPackageDependency); - targetDependencies.add(flutterFrameworkTargetDependency); - } + // If there aren't any Swift Package plugins and the project hasn't been + // migrated yet, don't generate a Swift package or migrate the app since + // it's not needed. If the project has already been migrated, regenerate + // the Package.swift even if there are no dependencies in case there + // were dependencies previously. + if (packageDependencies.isEmpty && !project.flutterPluginSwiftPackageInProjectSettings) { + _cleanStaleSymlinks( + symlinkDirectory: symlinkDirectory, + expectedBasenames: expectedBasenames, + keepFlutterFramework: false, + ); + return; + } - // FlutterGeneratedPluginSwiftPackage must be statically linked to ensure - // any dynamic dependencies are linked to Runner and prevent undefined symbols. - final generatedProduct = SwiftPackageProduct.library( - name: kFlutterGeneratedPluginSwiftPackageName, - targets: [kFlutterGeneratedPluginSwiftPackageName], - libraryType: SwiftPackageLibraryType.static, - ); + // Add Flutter framework Swift package dependency + if (flutterAsADependency) { + final ( + SwiftPackagePackageDependency flutterFrameworkPackageDependency, + SwiftPackageTargetDependency flutterFrameworkTargetDependency, + ) = _dependencyForFlutterFramework( + pathRelativeTo: project.flutterPluginSwiftPackageDirectory.path, + platform: platform, + project: project, + ); + packageDependencies.add(flutterFrameworkPackageDependency); + targetDependencies.add(flutterFrameworkTargetDependency); + } - final generatedTarget = SwiftPackageTarget.defaultTarget( - name: kFlutterGeneratedPluginSwiftPackageName, - dependencies: targetDependencies, - ); + // FlutterGeneratedPluginSwiftPackage must be statically linked to ensure + // any dynamic dependencies are linked to Runner and prevent undefined symbols. + final generatedProduct = SwiftPackageProduct.library( + name: kFlutterGeneratedPluginSwiftPackageName, + targets: [kFlutterGeneratedPluginSwiftPackageName], + libraryType: SwiftPackageLibraryType.static, + ); - final pluginsPackage = SwiftPackage( - manifest: project.flutterPluginSwiftPackageManifest, - name: kFlutterGeneratedPluginSwiftPackageName, - platforms: [platform.supportedPackagePlatform], - products: [generatedProduct], - dependencies: packageDependencies, - targets: [generatedTarget], - templateRenderer: _templateRenderer, + final generatedTarget = SwiftPackageTarget.defaultTarget( + name: kFlutterGeneratedPluginSwiftPackageName, + dependencies: targetDependencies, + ); + + final pluginsPackage = SwiftPackage( + manifest: project.flutterPluginSwiftPackageManifest, + name: kFlutterGeneratedPluginSwiftPackageName, + platforms: [platform.supportedPackagePlatform], + products: [generatedProduct], + dependencies: packageDependencies, + targets: [generatedTarget], + templateRenderer: _templateRenderer, + ); + pluginsPackage.createSwiftPackage(); + + _cleanStaleSymlinks( + symlinkDirectory: symlinkDirectory, + expectedBasenames: expectedBasenames, + keepFlutterFramework: flutterAsADependency, + ); + }, ); - pluginsPackage.createSwiftPackage(); } - (List, List) + (List, List, Set) _dependenciesForPlugins({ required List plugins, required FlutterDarwinPlatform platform, @@ -133,6 +164,7 @@ class SwiftPackageManager { }) { final packageDependencies = []; final targetDependencies = []; + final expectedBasenames = {}; for (final plugin in plugins) { final String? pluginSwiftPackageManifestPath = plugin.pluginSwiftPackageManifestPath( @@ -174,6 +206,7 @@ class SwiftPackageManager { final Link pluginSymlink = symlinkDirectory.childLink(basename); _createPluginSymlink(pluginSymlink: pluginSymlink, packagePath: packagePath); + expectedBasenames.add(basename); final String packageRelativePath = _fileSystem.path.relative( pluginSymlink.path, @@ -195,7 +228,7 @@ class SwiftPackageManager { ), ); } - return (packageDependencies, targetDependencies); + return (packageDependencies, targetDependencies, expectedBasenames); } /// Safely creates a symlink at [pluginSymlink] pointing to [packagePath]. @@ -454,4 +487,37 @@ class SwiftPackageManager { manifestContents.replaceFirst(oldSupportedPlatform, newSupportedPlatform), ); } + + /// Cleans up any stale or unreferenced plugin symlinks from the project's + /// symlink directory. + /// + /// When plugins are removed from `pubspec.yaml` or updated to versions that + /// no longer use Swift Package Manager, their old symlinks remain on disk. + /// This method removes them to prevent Xcode compilation failures caused by + /// trying to resolve broken or obsolete symlinks. + void _cleanStaleSymlinks({ + required Directory symlinkDirectory, + required Set expectedBasenames, + required bool keepFlutterFramework, + }) { + if (!symlinkDirectory.existsSync()) { + return; + } + try { + for (final FileSystemEntity entity in symlinkDirectory.listSync()) { + final String name = _fileSystem.path.basename(entity.path); + if (name == 'FlutterFramework') { + if (!keepFlutterFramework) { + ErrorHandlingFileSystem.deleteIfExists(entity, recursive: true); + } + continue; + } + if (!expectedBasenames.contains(name)) { + ErrorHandlingFileSystem.deleteIfExists(entity, recursive: true); + } + } + } on FileSystemException catch (_) { + // Ignore errors, as another process might be concurrently modifying the directory. + } + } } diff --git a/packages/flutter_tools/lib/src/macos/swift_packages.dart b/packages/flutter_tools/lib/src/macos/swift_packages.dart index 5b7613675e09f..c1849f891becf 100644 --- a/packages/flutter_tools/lib/src/macos/swift_packages.dart +++ b/packages/flutter_tools/lib/src/macos/swift_packages.dart @@ -18,7 +18,10 @@ const _swiftPackageTemplate = ''' import PackageDescription -{{#hasSwiftCodeBefore}}\n{{swiftCodeBefore}}\n\n{{/hasSwiftCodeBefore}} +{{#hasSwiftCodeBefore}} +{{swiftCodeBefore}} + +{{/hasSwiftCodeBefore}} let package = Package( name: "{{packageName}}", {{#platforms}} @@ -120,11 +123,17 @@ class SwiftPackage { final Directory targetDirectory = _manifest.parent .childDirectory('Sources') .childDirectory(target.name); - if (generateEmptySources && - (!targetDirectory.existsSync() || targetDirectory.listSync().isEmpty)) { + if (generateEmptySources) { final File requiredSwiftFile = targetDirectory.childFile('${target.name}.swift'); - requiredSwiftFile.createSync(recursive: true); - requiredSwiftFile.writeAsStringSync(_swiftPackageSourceTemplate); + // Skip creating placeholder sources if sources already exist in the + // target directory to avoid unnecessary file writes during build. + final bool hasSources = + requiredSwiftFile.existsSync() || + (targetDirectory.existsSync() && targetDirectory.listSync().isNotEmpty); + if (!hasSources) { + requiredSwiftFile.createSync(recursive: true); + requiredSwiftFile.writeAsStringSync(_swiftPackageSourceTemplate); + } } } @@ -132,8 +141,24 @@ class SwiftPackage { _swiftPackageTemplate, _templateContext, ); - _manifest.createSync(recursive: true); - _manifest.writeAsStringSync(renderedTemplate); + + // Skip writing Package.swift if the existing file content is identical to + // renderedTemplate. Preserving file modification time (mtime) prevents + // Xcode and Swift Package Manager from invalidating caches and re-resolving + // dependencies during parallel builds. + var shouldWrite = true; + try { + if (_manifest.existsSync() && _manifest.readAsStringSync() == renderedTemplate) { + shouldWrite = false; + } + } on FileSystemException { + // If reading fails, write it anyway. + } + + if (shouldWrite) { + _manifest.createSync(recursive: true); + _manifest.writeAsStringSync(renderedTemplate); + } } String? _formatPlatforms() { diff --git a/packages/flutter_tools/lib/src/macos/xcdevice.dart b/packages/flutter_tools/lib/src/macos/xcdevice.dart index b5cbeeaa9b448..22f37f6a078c1 100644 --- a/packages/flutter_tools/lib/src/macos/xcdevice.dart +++ b/packages/flutter_tools/lib/src/macos/xcdevice.dart @@ -550,6 +550,9 @@ class XCDevice { var devModeEnabled = true; var isConnected = true; var isPaired = true; + final modelCode = device['modelCode'] as String?; + final operatingSystemVersion = device['operatingSystemVersion'] as String?; + final cpuArchitectureString = device['architecture'] as String?; final Map? errorProperties = _errorProperties(device); if (errorProperties != null) { final String? errorMessage = _parseErrorMessage(errorProperties); @@ -633,11 +636,15 @@ class XCDevice { identifier, name: name, cpuArchitecture: _cpuArchitecture(device), + cpuArchitectureString: cpuArchitectureString, connectionInterface: connectionInterface, isConnected: isConnected, sdkVersion: sdkVersionString, + modelCode: modelCode, + operatingSystemVersion: operatingSystemVersion, iProxy: _iProxy, fileSystem: globals.fs, + fileSystemUtils: globals.fsUtils, logger: _logger, analytics: globals.analytics, iosDeploy: _iosDeploy, @@ -650,12 +657,15 @@ class XCDevice { fileSystem: globals.fs, processUtils: _processUtils, xcodeProjectInterpreter: globals.xcodeProjectInterpreter!, + deviceVersion: Version.parse(sdkVersionString), ), xcodeDebug: _xcodeDebug, + xcode: _xcode, platform: globals.platform, devModeEnabled: devModeEnabled, isPaired: isPaired, isCoreDevice: coreDevice != null, + processUtils: _processUtils, ); } } diff --git a/packages/flutter_tools/lib/src/macos/xcode.dart b/packages/flutter_tools/lib/src/macos/xcode.dart index edd4fcf671475..fc34cc59e4602 100644 --- a/packages/flutter_tools/lib/src/macos/xcode.dart +++ b/packages/flutter_tools/lib/src/macos/xcode.dart @@ -235,11 +235,11 @@ class Xcode { Future> fetchDependenciesAndGenerateXcodebuildArgs( XcodeBasedProject xcodeProject, Directory buildDirectory, { - bool skipPackageUpdatesAndValidation = true, + bool skipPackageValidation = true, }) async => _xcodeProjectInterpreter.fetchDependenciesAndGenerateXcodebuildArgs( xcodeProject, buildDirectory, - skipPackageUpdatesAndValidation: skipPackageUpdatesAndValidation, + skipPackageValidation: skipPackageValidation, ); Future cc(List args) => _run('cc', args); diff --git a/packages/flutter_tools/lib/src/migrations/analysis_options_migration.dart b/packages/flutter_tools/lib/src/migrations/analysis_options_migration.dart index 0d6fbebd5ab6a..53a8231ed0006 100644 --- a/packages/flutter_tools/lib/src/migrations/analysis_options_migration.dart +++ b/packages/flutter_tools/lib/src/migrations/analysis_options_migration.dart @@ -2,19 +2,26 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:package_config/package_config.dart'; import 'package:yaml/yaml.dart'; import 'package:yaml_edit/yaml_edit.dart'; import '../base/file_system.dart'; import '../base/project_migrator.dart'; +import '../dart/package_map.dart'; import '../project.dart'; /// Migrates analysis_options.yaml to exclude build and platform directories. class AnalysisOptionsMigration extends ProjectMigrator { - AnalysisOptionsMigration(FlutterProject project, super.logger) - : _analysisOptionsFile = project.directory.childFile('analysis_options.yaml'); + AnalysisOptionsMigration(FlutterProject project, super.logger, {PackageConfig? packageConfig}) + : _project = project, + _analysisOptionsFile = project.directory.childFile('analysis_options.yaml'), + _packageConfig = packageConfig; + final FlutterProject _project; final File _analysisOptionsFile; + final PackageConfig? _packageConfig; + PackageConfig? _loadedPackageConfig; @override Future migrate() async { @@ -22,6 +29,14 @@ class AnalysisOptionsMigration extends ProjectMigrator { return; } + // A pure Dart package (no `flutter` dependency) has no platform scaffold + // directories, so `web/`, `android/`, etc. are ordinary source or asset + // directories rather than generated platform code. Excluding them here + // would silently drop them from analysis. + if (!_project.manifest.dependencies.contains('flutter')) { + return; + } + final String originalContent = _analysisOptionsFile.readAsStringSync(); final YamlNode root; try { @@ -36,35 +51,25 @@ class AnalysisOptionsMigration extends ProjectMigrator { return; } - const excludesToExclude = [ + final excludesToExclude = [ 'build/**', - 'android/**', - 'ios/**', - 'web/**', - 'windows/**', - 'macos/**', - 'linux/**', + if (_project.android.existsSync()) 'android/**', + if (_project.ios.existsSync()) 'ios/**', + if (_project.web.existsSync()) 'web/**', + if (_project.windows.existsSync()) 'windows/**', + if (_project.macos.existsSync()) 'macos/**', + if (_project.linux.existsSync()) 'linux/**', ]; - var needsMigration = false; - final Object? analyzer = root['analyzer']; - if (analyzer is! YamlMap) { - needsMigration = true; - } else { - final exclude = analyzer['exclude'] as Object?; - if (exclude is! YamlList) { - needsMigration = true; - } else { - for (final requiredExclude in excludesToExclude) { - if (!exclude.contains(requiredExclude)) { - needsMigration = true; - break; - } - } + final Set activeExcludes = await _collectExcludes(_analysisOptionsFile); + final missingExcludes = []; + for (final requiredExclude in excludesToExclude) { + if (!activeExcludes.contains(requiredExclude)) { + missingExcludes.add(requiredExclude); } } - if (!needsMigration) { + if (missingExcludes.isEmpty) { return; } @@ -73,18 +78,28 @@ class AnalysisOptionsMigration extends ProjectMigrator { ); final editor = YamlEditor(originalContent); + final Object? analyzer = root['analyzer']; try { if (analyzer is! YamlMap) { - editor.update(['analyzer'], {'exclude': excludesToExclude}); + editor.update(['analyzer'], {'exclude': missingExcludes}); } else { final exclude = analyzer['exclude'] as Object?; if (exclude is! YamlList) { - editor.update(['analyzer', 'exclude'], excludesToExclude); + editor.update(['analyzer', 'exclude'], missingExcludes); + } else if (exclude.style == CollectionStyle.FLOW) { + // Workaround for https://github.com/dart-lang/tools/issues/2532. + // Appending to a multiline flow-style list with a trailing comma crashes YamlEditor. + // Instead, rewrite the entire exclude list as a block list. + final newExcludes = [ + ...exclude, + ...missingExcludes.where((String item) => !exclude.contains(item)), + ]; + editor.update(['analyzer', 'exclude'], newExcludes); } else { - for (final requiredExclude in excludesToExclude) { - if (!exclude.contains(requiredExclude)) { - editor.appendToList(['analyzer', 'exclude'], requiredExclude); + for (final missingExclude in missingExcludes) { + if (!exclude.contains(missingExclude)) { + editor.appendToList(['analyzer', 'exclude'], missingExclude); } } } @@ -94,4 +109,109 @@ class AnalysisOptionsMigration extends ProjectMigrator { logger.printError('Failed to migrate analysis_options.yaml: $e'); } } + + /// Recursively collects analyzer `exclude:` patterns from [file] and any + /// configuration files referenced via `include:` directives. + /// + /// Supports both relative paths and `package:` URIs, handling single string + /// or list-based include directives while tracking [visited] canonical paths + /// to prevent infinite recursion on cyclic includes. + Future> _collectExcludes(File file, {Set visited = const {}}) async { + final String canonicalPath = file.fileSystem.path.canonicalize(file.path); + if (visited.contains(canonicalPath)) { + return {}; // Avoid cycles + } + if (!file.existsSync()) { + return {}; + } + final String content; + try { + content = file.readAsStringSync(); + } on FileSystemException { + return {}; + } + final YamlNode root; + try { + root = loadYamlNode(content); + } on YamlException { + return {}; + } + if (root is! YamlMap) { + return {}; + } + + final excludes = {}; + final Object? analyzer = root['analyzer']; + if (analyzer is YamlMap) { + final Object? exclude = analyzer['exclude']; + if (exclude is YamlList) { + excludes.addAll(exclude.whereType()); + } + } + + final Object? include = root['include']; + final includes = []; + if (include is String) { + includes.add(include); + } else if (include is YamlList) { + includes.addAll(include.whereType()); + } + + for (final includeUri in includes) { + final File? includedFile = await _resolveInclude(includeUri, file); + if (includedFile != null) { + excludes.addAll( + await _collectExcludes(includedFile, visited: {...visited, canonicalPath}), + ); + } + } + + return excludes; + } + + Future _resolveInclude(String includeUri, File referringFile) async { + if (includeUri.startsWith('package:')) { + final PackageConfig? packageConfig = await _getPackageConfig(); + if (packageConfig == null) { + return null; + } + try { + final Uri? resolvedUri = packageConfig.resolve(Uri.parse(includeUri)); + if (resolvedUri != null && resolvedUri.scheme == 'file') { + return referringFile.fileSystem.file(resolvedUri); + } + } on FormatException { + // Invalid URI + } + return null; + } else { + // Relative path + final String path = referringFile.fileSystem.path.join(referringFile.parent.path, includeUri); + return referringFile.fileSystem.file(path); + } + } + + Future _getPackageConfig() async { + if (_packageConfig != null) { + return _packageConfig; + } + if (_loadedPackageConfig != null) { + return _loadedPackageConfig; + } + final File? configFile = findPackageConfigFile(_project.directory); + if (configFile == null) { + return null; + } + try { + _loadedPackageConfig = await loadPackageConfigWithLogging( + configFile, + logger: logger, + throwOnError: false, + ); + return _loadedPackageConfig; + } on Exception { + // Ignore errors loading package config during migration checks. + return null; + } + } } diff --git a/packages/flutter_tools/lib/src/migrations/swift_package_manager_integration_migration.dart b/packages/flutter_tools/lib/src/migrations/swift_package_manager_integration_migration.dart index dc7df4db919ca..057116276490e 100644 --- a/packages/flutter_tools/lib/src/migrations/swift_package_manager_integration_migration.dart +++ b/packages/flutter_tools/lib/src/migrations/swift_package_manager_integration_migration.dart @@ -2,13 +2,18 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:meta/meta.dart'; +import 'package:unified_analytics/unified_analytics.dart'; import 'package:xml/xml.dart'; +import '../base/async_guard.dart'; import '../base/common.dart'; import '../base/config.dart'; import '../base/error_handling_io.dart'; import '../base/file_system.dart'; import '../base/logger.dart'; +import '../base/os.dart'; +import '../base/platform.dart'; import '../base/project_migrator.dart'; import '../build_info.dart'; import '../convert.dart'; @@ -18,6 +23,8 @@ import '../ios/xcodeproj.dart'; import '../macos/swift_package_manager.dart'; import '../plugins.dart'; import '../project.dart'; +import '../reporting/crash_reporting.dart'; +import '../version.dart'; /// Swift Package Manager integration requires changes to the Xcode project's /// project.pbxproj and xcscheme. This class handles making those changes. @@ -31,6 +38,11 @@ class SwiftPackageManagerIntegrationMigration extends ProjectMigrator { required FileSystem fileSystem, required PlistParser plistParser, required Config config, + required Analytics analytics, + required Platform hostPlatform, + required OperatingSystemUtils operatingSystemUtils, + required FlutterVersion flutterVersion, + required bool reportCrashes, }) : _xcodeProject = project, _platform = platform, _buildInfo = buildInfo, @@ -39,6 +51,11 @@ class SwiftPackageManagerIntegrationMigration extends ProjectMigrator { _fileSystem = fileSystem, _plistParser = plistParser, _config = config, + _analytics = analytics, + _hostPlatform = hostPlatform, + _operatingSystemUtils = operatingSystemUtils, + _flutterVersion = flutterVersion, + _reportCrashes = reportCrashes, super(logger); final XcodeBasedProject _xcodeProject; @@ -49,6 +66,11 @@ class SwiftPackageManagerIntegrationMigration extends ProjectMigrator { final File _xcodeProjectInfoFile; final PlistParser _plistParser; final Config _config; + final Analytics _analytics; + final Platform _hostPlatform; + final OperatingSystemUtils _operatingSystemUtils; + final FlutterVersion _flutterVersion; + final bool _reportCrashes; /// New identifier for FlutterGeneratedPluginSwiftPackage PBXBuildFile. static const _flutterPluginsSwiftPackageBuildFileIdentifier = '78A318202AECB46A00862997'; @@ -134,6 +156,8 @@ class SwiftPackageManagerIntegrationMigration extends ProjectMigrator { /// If the app is not an example app or the plugin cannot be found, this will return null. late final ({String name, String path})? _examplePlugin; + String _flutterVersionString() => _flutterVersion.getVersionString(redactUnknownBranches: true); + void restoreFromBackup(SchemeInfo? schemeInfo) { if (backupProjectSettings.existsSync()) { logger.printTrace('Restoring project settings from backup file...'); @@ -171,7 +195,7 @@ class SwiftPackageManagerIntegrationMigration extends ProjectMigrator { var optionalOnly = false; try { if (!_xcodeProjectInfoFile.existsSync()) { - throw Exception('Xcode project not found.'); + throw SwiftPackageManagerMigrationException('Xcode project not found.'); } _examplePlugin = await _loadPluginFromExampleProject( @@ -219,23 +243,76 @@ class SwiftPackageManagerIntegrationMigration extends ProjectMigrator { // If pbxproj was not already migrated, verify settings were set correctly. if (!isPbxprojMigrated) { if (!_isPbxprojMigratedCorrectly(updatedInfo, logErrorIfNotMigrated: true)) { - throw Exception('Settings were not updated correctly.'); + throw SwiftPackageManagerMigrationException('Settings were not updated correctly.'); } } else if (!isOptionalFilesMigrated) { if (!_areOptionalFilesMigratedCorrectly(updatedInfo, logErrorIfNotMigrated: true)) { - throw Exception('Settings were not updated correctly.'); + throw SwiftPackageManagerMigrationException('Settings were not updated correctly.'); } } // Get the project info to make sure it compiles with xcodebuild - await _xcodeProjectInterpreter.getInfo( - _xcodeProject, - buildDirectory: _fileSystem.directory( - _platform.buildDirectory(config: _config, fileSystem: _fileSystem), + try { + await _xcodeProjectInterpreter.getInfo( + _xcodeProject, + buildDirectory: _fileSystem.directory( + _platform.buildDirectory(config: _config, fileSystem: _fileSystem), + ), + ); + } on Exception catch (e, stackTrace) { + // Send error to crash reporter. + if (_reportCrashes) { + try { + await asyncGuard(() async { + final crashReportSender = CrashReportSender( + platform: _hostPlatform, + logger: logger, + operatingSystemUtils: _operatingSystemUtils, + analytics: _analytics, + ); + await crashReportSender.sendReport( + error: e, + stackTrace: stackTrace, + getFlutterVersion: _flutterVersionString, + command: 'swiftpm-migration', + typeId: 'XcodeBuildError', + ); + }); + } on Exception catch (_) { + // Fail silently if it fails to send crash report + } + } + + final message = e.toString(); + if (message.contains('Could not resolve package dependencies')) { + throw SwiftPackageManagerMigrationException( + message, + analyticsMessage: 'Xcode could not resolve package dependencies.', + ); + } else { + throw SwiftPackageManagerMigrationException( + message, + analyticsMessage: 'Xcode failed for unknown reason.', + ); + } + } + _analytics.send( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-success', + parameter: optionalOnly ? 'optional' : 'full', ), ); } on Exception catch (e) { restoreFromBackup(schemeInfo); + if (e is SwiftPackageManagerMigrationException) { + _analytics.send( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-failure', + parameter: optionalOnly ? 'optional' : 'full', + result: e.analyticsMessage ?? e.userMessage, + ), + ); + } if (optionalOnly) { // This part of the migration is optional. We'll log this for debugging sake but don't // really expect the user to see it. @@ -248,7 +325,7 @@ class SwiftPackageManagerIntegrationMigration extends ProjectMigrator { throwToolExit( 'An error occurred when adding Swift Package Manager integration:\n' ' $e\n\n' - 'Swift Package Manager is currently an experimental feature, please file a bug at\n' + 'To help the Flutter team improve this process, please file a bug at\n' ' https://github.com/flutter/flutter/issues/new?template=01_activation.yml \n' 'Consider including a copy of the following files in your bug report:\n' ' ${_platform.name}/Runner.xcodeproj/project.pbxproj\n' @@ -271,7 +348,7 @@ class SwiftPackageManagerIntegrationMigration extends ProjectMigrator { Future _getSchemeFile() async { final XcodeProjectInfo? projectInfo = await _xcodeProject.projectInfo(); if (projectInfo == null) { - throw Exception('Unable to get Xcode project info.'); + throw SwiftPackageManagerMigrationException('Unable to get Xcode project info.'); } if (_xcodeProject.xcodeWorkspace == null) { throw Exception('Xcode workspace not found.'); @@ -283,7 +360,9 @@ class SwiftPackageManagerIntegrationMigration extends ProjectMigrator { final File schemeFile = _xcodeProject.xcodeProjectSchemeFile(scheme: scheme); if (!schemeFile.existsSync()) { - throw Exception('Unable to get scheme file for $scheme.'); + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => 'Unable to get scheme file for ${sanitize(scheme, .name)}.', + ); } final String schemeContent = schemeFile.readAsStringSync(); @@ -318,7 +397,10 @@ class SwiftPackageManagerIntegrationMigration extends ProjectMigrator { try { document = XmlDocument.parse(schemeContent); } on XmlException catch (exception) { - throw Exception('Failed to parse ${schemeFile.basename}: Invalid xml: $exception'); + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Failed to parse ${sanitize(schemeFile.basename, .basename)}: Invalid xml${sanitize(': $exception', .error)}', + ); } final Iterable buildableReferences = document.findAllElements('BuildableReference'); @@ -330,28 +412,36 @@ class SwiftPackageManagerIntegrationMigration extends ProjectMigrator { } } if (targetReference == null) { - throw Exception( - 'Failed to parse ${schemeFile.basename}: Could not find BuildableReference ' - 'for ${_xcodeProject.hostAppProjectName}.', + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Failed to parse ${sanitize(schemeFile.basename, .basename)}: Could not find BuildableReference ' + 'for ${sanitize(_xcodeProject.hostAppProjectName, .name)}.', ); } final String? buildableNameAttr = targetReference.getAttribute('BuildableName'); if (buildableNameAttr == null) { - throw Exception('Failed to parse ${schemeFile.basename}: Could not find BuildableName.'); + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Failed to parse ${sanitize(schemeFile.basename, .basename)}: Could not find BuildableName.', + ); } final buildableName = 'BuildableName = "$buildableNameAttr"'; final String? blueprintNameAttr = targetReference.getAttribute('BlueprintName'); if (blueprintNameAttr == null) { - throw Exception('Failed to parse ${schemeFile.basename}: Could not find BlueprintName.'); + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Failed to parse ${sanitize(schemeFile.basename, .basename)}: Could not find BlueprintName.', + ); } final blueprintName = 'BlueprintName = "$blueprintNameAttr"'; final String? referencedContainerAttr = targetReference.getAttribute('ReferencedContainer'); if (referencedContainerAttr == null) { - throw Exception( - 'Failed to parse ${schemeFile.basename}: Could not find ReferencedContainer.', + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Failed to parse ${sanitize(schemeFile.basename, .basename)}: Could not find ReferencedContainer.', ); } final referencedContainer = 'ReferencedContainer = "$referencedContainerAttr"'; @@ -408,7 +498,10 @@ $newContent .firstOrNull; if (buildAction == null) { - throw Exception('Failed to parse ${schemeFile.basename}: Could not find BuildAction.'); + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Failed to parse ${sanitize(schemeFile.basename, .basename)}: Could not find BuildAction.', + ); } } newScheme = schemeContent.replaceFirst(buildAction, '$newContent$buildAction'); @@ -418,8 +511,9 @@ $newContent try { XmlDocument.parse(newScheme); } on XmlException catch (exception) { - throw Exception( - 'Failed to parse ${schemeFile.basename}: Invalid xml: $newScheme\n$exception', + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Failed to parse ${sanitize(schemeFile.basename, .basename)} after updating: Invalid xml${sanitize(': $newScheme\n$exception', .error)}', ); } } @@ -429,17 +523,23 @@ $newContent ParsedProjectInfo _parsePbxproj() { final String? results = _plistParser.plistJsonContent(_xcodeProjectInfoFile.path); if (results == null) { - throw Exception('Failed to parse project settings.'); + throw SwiftPackageManagerMigrationException('Failed to parse project settings.'); } try { final decodeResult = json.decode(results) as Object; if (decodeResult is! Map) { - throw Exception('project.pbxproj returned unexpected JSON response: $results'); + throw SwiftPackageManagerMigrationException( + 'project.pbxproj returned unexpected JSON response: $results', + analyticsMessage: 'project.pbxproj returned unexpected JSON response', + ); } return ParsedProjectInfo.fromJson(decodeResult); } on FormatException { - throw Exception('project.pbxproj returned non-JSON response: $results'); + throw SwiftPackageManagerMigrationException( + 'project.pbxproj returned non-JSON response: $results', + analyticsMessage: 'project.pbxproj returned non-JSON response', + ); } } @@ -612,19 +712,23 @@ $newContent '$_flutterPluginsSwiftPackageBuildFileIdentifier /* $kFlutterGeneratedPluginSwiftPackageName in Frameworks */', ) && originalProjectContents.contains(_flutterPluginsSwiftPackageBuildFileIdentifier)) { - throw Exception('Duplicate id found for PBXBuildFile.'); + throw SwiftPackageManagerMigrationException('Duplicate id found for PBXBuildFile.'); } if (!originalProjectContents.contains( '$_flutterPluginsSwiftPackageProductDependencyIdentifier /* $kFlutterGeneratedPluginSwiftPackageName */', ) && originalProjectContents.contains(_flutterPluginsSwiftPackageProductDependencyIdentifier)) { - throw Exception('Duplicate id found for XCSwiftPackageProductDependency.'); + throw SwiftPackageManagerMigrationException( + 'Duplicate id found for XCSwiftPackageProductDependency.', + ); } if (!originalProjectContents.contains( '$_localFlutterPluginsSwiftPackageReferenceIdentifier /* XCLocalSwiftPackageReference', ) && originalProjectContents.contains(_localFlutterPluginsSwiftPackageReferenceIdentifier)) { - throw Exception('Duplicate id found for XCLocalSwiftPackageReference.'); + throw SwiftPackageManagerMigrationException( + 'Duplicate id found for XCLocalSwiftPackageReference.', + ); } } @@ -633,7 +737,7 @@ $newContent '$_flutterPluginsSwiftPackageFileIdentifer /* $kFlutterGeneratedPluginSwiftPackageName */', ) && originalProjectContents.contains(_flutterPluginsSwiftPackageFileIdentifer)) { - throw Exception( + throw SwiftPackageManagerMigrationException( 'Duplicate id found for $kFlutterGeneratedPluginSwiftPackageName PBXFileReference.', ); } @@ -642,13 +746,16 @@ $newContent '$_flutterPluginLocalOverrideFileIdenitifier /* ${_examplePlugin.name} */', ) && originalProjectContents.contains(_flutterPluginLocalOverrideFileIdenitifier)) { - throw Exception('Duplicate id found for ${_examplePlugin.name} PBXFileReference.'); + throw SwiftPackageManagerMigrationException( + 'Duplicate id found for ${_examplePlugin.name} PBXFileReference.', + analyticsMessage: 'Duplicate id found for plugin PBXFileReference.', + ); } if (!originalProjectContents.contains( '$_flutterFrameworkLocalOverrideFileIdentifier /* $kFlutterGeneratedFrameworkSwiftPackageTargetName */', ) && originalProjectContents.contains(_flutterFrameworkLocalOverrideFileIdentifier)) { - throw Exception( + throw SwiftPackageManagerMigrationException( 'Duplicate id found for $kFlutterGeneratedFrameworkSwiftPackageTargetName PBXFileReference.', ); } @@ -752,8 +859,9 @@ $newContent ); if (runnerFrameworksPhaseStartIndex == -1 || runnerFrameworksPhaseStartIndex > endSectionIndex) { - throw Exception( - 'Unable to find PBXFrameworksBuildPhase for ${_xcodeProject.hostAppProjectName} target.', + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Unable to find PBXFrameworksBuildPhase for ${sanitize(_xcodeProject.hostAppProjectName, .name)} target.', ); } @@ -768,8 +876,9 @@ $newContent .toList() .firstOrNull; if (runnerFrameworksPhase == null) { - throw Exception( - 'Unable to find parsed PBXFrameworksBuildPhase for ${_xcodeProject.hostAppProjectName} target.', + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Unable to find parsed PBXFrameworksBuildPhase for ${sanitize(_xcodeProject.hostAppProjectName, .name)} target.', ); } @@ -788,8 +897,9 @@ $newContent runnerFrameworksPhaseStartIndex, ); if (startFilesIndex == -1 || startFilesIndex > endSectionIndex) { - throw Exception( - 'Unable to files for PBXFrameworksBuildPhase ${_xcodeProject.hostAppProjectName} target.', + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Unable to find files for PBXFrameworksBuildPhase for ${sanitize(_xcodeProject.hostAppProjectName, .name)} target.', ); } const newContent = @@ -834,8 +944,9 @@ $newContent .where((ParsedNativeTarget target) => target.identifier == _runnerNativeTargetIdentifier) .firstOrNull; if (runnerNativeTarget == null) { - throw Exception( - 'Unable to find parsed PBXNativeTarget for ${_xcodeProject.hostAppProjectName} target.', + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Unable to find parsed PBXNativeTarget for ${sanitize(_xcodeProject.hostAppProjectName, .name)} target.', ); } final String subsectionLineStart = runnerNativeTarget.name != null @@ -846,8 +957,9 @@ $newContent startSectionIndex, ); if (runnerNativeTargetStartIndex == -1 || runnerNativeTargetStartIndex > endSectionIndex) { - throw Exception( - 'Unable to find PBXNativeTarget for ${_xcodeProject.hostAppProjectName} target.', + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Unable to find PBXNativeTarget for ${sanitize(_xcodeProject.hostAppProjectName, .name)} target.', ); } @@ -867,8 +979,9 @@ $newContent ); if (packageProductDependenciesIndex == -1 || packageProductDependenciesIndex > endSectionIndex) { - throw Exception( - 'Unable to find packageProductDependencies for ${_xcodeProject.hostAppProjectName} PBXNativeTarget.', + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Unable to find packageProductDependencies for ${sanitize(_xcodeProject.hostAppProjectName, .name)} PBXNativeTarget.', ); } const newContent = @@ -917,7 +1030,7 @@ $newContent startSectionIndex, ); if (flutterGroupStartIndex == -1 || flutterGroupStartIndex > endSectionIndex) { - throw Exception('Unable to find Flutter PBXGroup.'); + throw SwiftPackageManagerMigrationException('Unable to find Flutter PBXGroup.'); } // Get the Flutter Group from the parsed project info. @@ -926,7 +1039,7 @@ $newContent .toList() .firstOrNull; if (parsedGroup == null) { - throw Exception('Unable to find parsed Flutter PBXGroup.'); + throw SwiftPackageManagerMigrationException('Unable to find parsed Flutter PBXGroup.'); } // Find the children field within the Flutter PBXGroup. @@ -984,7 +1097,10 @@ $newContent startSectionIndex, ); if (projectStartIndex == -1 || projectStartIndex > endSectionIndex) { - throw Exception('Unable to find PBXProject for ${_xcodeProject.hostAppProjectName}.'); + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Unable to find PBXProject for ${sanitize(_xcodeProject.hostAppProjectName, .name)}.', + ); } // Get the Runner project from the parsed project info. @@ -993,7 +1109,10 @@ $newContent .toList() .firstOrNull; if (projectObject == null) { - throw Exception('Unable to find parsed PBXProject for ${_xcodeProject.hostAppProjectName}.'); + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Unable to find parsed PBXProject for ${sanitize(_xcodeProject.hostAppProjectName, .name)}.', + ); } if (projectObject.packageReferences == null) { @@ -1011,8 +1130,9 @@ $newContent projectStartIndex, ); if (packageReferencesIndex == -1 || packageReferencesIndex > endSectionIndex) { - throw Exception( - 'Unable to find packageReferences for ${_xcodeProject.hostAppProjectName} PBXProject.', + throw SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Unable to find packageReferences for ${sanitize(_xcodeProject.hostAppProjectName, .name)} PBXProject.', ); } const newContent = @@ -1065,7 +1185,7 @@ $newContent final int index = lines.lastIndexWhere((String line) => line.trim().startsWith('/* End')); if (index == -1) { - throw Exception('Unable to find any sections.'); + throw SwiftPackageManagerMigrationException('Unable to find any sections.'); } lines.insertAll(index + 1, newContent); @@ -1127,7 +1247,7 @@ $newContent final int index = lines.lastIndexWhere((String line) => line.trim().startsWith('/* End')); if (index == -1) { - throw Exception('Unable to find any sections.'); + throw SwiftPackageManagerMigrationException('Unable to find any sections.'); } lines.insertAll(index + 1, newContent); @@ -1207,14 +1327,18 @@ $newContent (int, int) _sectionRange(String sectionName, List lines, {bool throwIfMissing = true}) { final int startSectionIndex = lines.indexOf('/* Begin $sectionName section */'); if (throwIfMissing && startSectionIndex == -1) { - throw Exception('Unable to find beginning of $sectionName section.'); + throw SwiftPackageManagerMigrationException( + 'Unable to find beginning of $sectionName section.', + ); } final int endSectionIndex = lines.indexOf('/* End $sectionName section */'); if (throwIfMissing && endSectionIndex == -1) { - throw Exception('Unable to find end of $sectionName section.'); + throw SwiftPackageManagerMigrationException('Unable to find end of $sectionName section.'); } if (throwIfMissing && startSectionIndex > endSectionIndex) { - throw Exception('Found the end of $sectionName section before the beginning.'); + throw SwiftPackageManagerMigrationException( + 'Found the end of $sectionName section before the beginning.', + ); } return (startSectionIndex, endSectionIndex); } @@ -1430,3 +1554,69 @@ class ParsedProject { final String identifier; final List? packageReferences; } + +/// Exception thrown when the Swift Package Manager integration migration fails. +@visibleForTesting +class SwiftPackageManagerMigrationException implements Exception { + SwiftPackageManagerMigrationException(this.userMessage, {this.analyticsMessage}); + + /// Creates a [SwiftPackageManagerMigrationException] where both [userMessage] + /// and [analyticsMessage] are built by [builder]. + /// + /// The [userMessage] is created by evaluating [builder] without sanitization. + /// The [analyticsMessage] is created by evaluating [builder] with [sanitizer] + /// to anonymize user-specific identifiers and strip sensitive error details + /// for analytics reporting. + factory SwiftPackageManagerMigrationException.sanitized( + String Function(String Function(String, SanitizeType type) sanitize) builder, { + String Function(String, SanitizeType type) sanitizer = _defaultSanitizer, + }) { + return SwiftPackageManagerMigrationException( + builder((value, type) => value), + analyticsMessage: builder(sanitizer), + ); + } + + /// Default sanitizer used to redact names, file basenames, or error details + /// for analytics reporting. + static String _defaultSanitizer(String name, SanitizeType type) { + switch (type) { + case SanitizeType.name: + return name == 'Runner' ? 'Runner' : 'custom'; + case SanitizeType.basename: + if (name.startsWith('Runner.')) { + return name; + } + final List parts = name.split('.'); + if (parts.length >= 2) { + return 'custom.${parts.last}'; + } + return 'custom'; + case SanitizeType.error: + return ''; + } + } + + /// The message to display to the user. + final String userMessage; + + /// An optional sanitized message for analytics reporting. + final String? analyticsMessage; + + @override + String toString() => userMessage; +} + +/// The type of data to sanitize for analytics reporting in +/// [SwiftPackageManagerMigrationException]. +@visibleForTesting +enum SanitizeType { + /// A target or scheme name. + name, + + /// A file basename (e.g. `Runner.xcscheme`). + basename, + + /// An error message or stack trace. + error, +} diff --git a/packages/flutter_tools/lib/src/platform_plugins.dart b/packages/flutter_tools/lib/src/platform_plugins.dart index e82469cab6fe7..bc6e7ffaa8a3d 100644 --- a/packages/flutter_tools/lib/src/platform_plugins.dart +++ b/packages/flutter_tools/lib/src/platform_plugins.dart @@ -17,12 +17,42 @@ const kDartPluginClass = 'dartPluginClass'; /// Constant for 'dartPluginFile' key in plugin maps. const kDartFileName = 'dartFileName'; +/// Constant for 'fileName' key in plugin maps. +const kFileName = 'fileName'; + /// Constant for 'ffiPlugin' key in plugin maps. const kFfiPlugin = 'ffiPlugin'; // Constant for 'defaultPackage' key in plugin maps. const kDefaultPackage = 'default_package'; +/// Matches a valid native plugin class or dot-separated package identifier. +/// +/// Plugin `class`/`package` values are interpolated verbatim into the generated +/// GeneratedPluginRegistrant source files (Java/Kotlin, Swift, Objective-C, +/// C++). Restricting them to identifier characters prevents a (possibly +/// transitive) dependency from injecting arbitrary native code into the +/// consuming app's build via its pubspec plugin declaration. +final RegExp _pluginIdentifierPattern = RegExp( + r'^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$', +); + +/// Whether [value] is a valid native plugin class or dot-separated package +/// identifier. Callers first confirm the value is a String via the schema type +/// checks; absent fields are not validated here. +bool _isValidPluginIdentifier(String value) => _pluginIdentifierPattern.hasMatch(value); + +/// Matches a safe relative Dart source path (e.g. `src/foo_web.dart`) ending in +/// `.dart`. Plugin `fileName`/`dartFileName` values are interpolated into an +/// `import` in the generated registrant, so they must not contain quotes, +/// semicolons, whitespace or parent-directory segments. +final RegExp pluginDartFileNamePattern = RegExp(r'^\w[\w./-]*\.dart$'); + +/// Whether [value] is a safe relative Dart source path for a plugin. Callers +/// first confirm the value is a String via the schema type checks. +bool isValidPluginDartFileName(String value) => + pluginDartFileNamePattern.hasMatch(value) && !value.contains('..'); + /// Constant for 'sharedDarwinSource' key in plugin maps. /// Can be set for iOS and macOS plugins. const kSharedDarwinSource = 'sharedDarwinSource'; @@ -131,10 +161,34 @@ class AndroidPlugin extends PluginPlatform implements NativeOrDartPlugin { bool hasDart() => dartPluginClass != null; static bool validate(YamlMap yaml) { - return (yaml['package'] is String && yaml[kPluginClass] is String) || - yaml[kDartPluginClass] is String || + final Object? package = yaml['package']; + final Object? pluginClass = yaml[kPluginClass]; + final Object? dartPluginClass = yaml[kDartPluginClass]; + + final bool hasPluginDeclaration = + (package is String && pluginClass is String) || + dartPluginClass is String || yaml[kFfiPlugin] == true || yaml[kDefaultPackage] is String; + + if (!hasPluginDeclaration) { + return false; + } + + // Validate every identifier that is present, not just the ones that made + // the declaration above valid, so a plugin cannot smuggle an unsafe + // identifier in alongside an `ffiPlugin` or `default_package` entry. + if (package is String && !_isValidPluginIdentifier(package)) { + return false; + } + if (pluginClass is String && !_isValidPluginIdentifier(pluginClass)) { + return false; + } + if (dartPluginClass is String && !_isValidPluginIdentifier(dartPluginClass)) { + return false; + } + + return true; } static const kConfigKey = 'android'; @@ -288,11 +342,30 @@ class IOSPlugin extends PluginPlatform implements NativeOrDartPlugin, DarwinPlug } static bool validate(YamlMap yaml) { - return yaml[kPluginClass] is String || - yaml[kDartPluginClass] is String || + final Object? pluginClass = yaml[kPluginClass]; + final Object? dartPluginClass = yaml[kDartPluginClass]; + + final bool hasPluginDeclaration = + pluginClass is String || + dartPluginClass is String || yaml[kFfiPlugin] == true || yaml[kSharedDarwinSource] == true || yaml[kDefaultPackage] is String; + + if (!hasPluginDeclaration) { + return false; + } + + // Validate every identifier that is present, not just the ones that made + // the declaration above valid. + if (pluginClass is String && !_isValidPluginIdentifier(pluginClass)) { + return false; + } + if (dartPluginClass is String && !_isValidPluginIdentifier(dartPluginClass)) { + return false; + } + + return true; } static const kConfigKey = 'ios'; @@ -381,11 +454,30 @@ class MacOSPlugin extends PluginPlatform implements NativeOrDartPlugin, DarwinPl } static bool validate(YamlMap yaml) { - return yaml[kPluginClass] is String || - yaml[kDartPluginClass] is String || + final Object? pluginClass = yaml[kPluginClass]; + final Object? dartPluginClass = yaml[kDartPluginClass]; + + final bool hasPluginDeclaration = + pluginClass is String || + dartPluginClass is String || yaml[kFfiPlugin] == true || yaml[kSharedDarwinSource] == true || yaml[kDefaultPackage] is String; + + if (!hasPluginDeclaration) { + return false; + } + + // Validate every identifier that is present, not just the ones that made + // the declaration above valid. + if (pluginClass is String && !_isValidPluginIdentifier(pluginClass)) { + return false; + } + if (dartPluginClass is String && !_isValidPluginIdentifier(dartPluginClass)) { + return false; + } + + return true; } static const kConfigKey = 'macos'; @@ -488,10 +580,29 @@ class WindowsPlugin extends PluginPlatform implements NativeOrDartPlugin, Varian } static bool validate(YamlMap yaml) { - return yaml[kPluginClass] is String || - yaml[kDartPluginClass] is String || + final Object? pluginClass = yaml[kPluginClass]; + final Object? dartPluginClass = yaml[kDartPluginClass]; + + final bool hasPluginDeclaration = + pluginClass is String || + dartPluginClass is String || yaml[kFfiPlugin] == true || yaml[kDefaultPackage] is String; + + if (!hasPluginDeclaration) { + return false; + } + + // Validate every identifier that is present, not just the ones that made + // the declaration above valid. + if (pluginClass is String && !_isValidPluginIdentifier(pluginClass)) { + return false; + } + if (dartPluginClass is String && !_isValidPluginIdentifier(dartPluginClass)) { + return false; + } + + return true; } static const kConfigKey = 'windows'; @@ -575,10 +686,29 @@ class LinuxPlugin extends PluginPlatform implements NativeOrDartPlugin { } static bool validate(YamlMap yaml) { - return yaml[kPluginClass] is String || - yaml[kDartPluginClass] is String || + final Object? pluginClass = yaml[kPluginClass]; + final Object? dartPluginClass = yaml[kDartPluginClass]; + + final bool hasPluginDeclaration = + pluginClass is String || + dartPluginClass is String || yaml[kFfiPlugin] == true || yaml[kDefaultPackage] is String; + + if (!hasPluginDeclaration) { + return false; + } + + // Validate every identifier that is present, not just the ones that made + // the declaration above valid. + if (pluginClass is String && !_isValidPluginIdentifier(pluginClass)) { + return false; + } + if (dartPluginClass is String && !_isValidPluginIdentifier(dartPluginClass)) { + return false; + } + + return true; } static const kConfigKey = 'linux'; @@ -622,19 +752,25 @@ class WebPlugin extends PluginPlatform { const WebPlugin({required this.name, required this.pluginClass, required this.fileName}); factory WebPlugin.fromYaml(String name, YamlMap yaml) { - if (yaml['pluginClass'] is! String) { + final Object? pluginClass = yaml[kPluginClass]; + if (pluginClass is! String) { throwToolExit( 'The plugin `$name` is missing the required field `pluginClass` in pubspec.yaml', ); } - if (yaml['fileName'] is! String) { + final Object? fileName = yaml[kFileName]; + if (fileName is! String) { throwToolExit('The plugin `$name` is missing the required field `fileName` in pubspec.yaml'); } - return WebPlugin( - name: name, - pluginClass: yaml['pluginClass'] as String, - fileName: yaml['fileName'] as String, - ); + if (!_isValidPluginIdentifier(pluginClass)) { + throwToolExit( + 'The plugin `$name` has an invalid `pluginClass` in its web plugin declaration.', + ); + } + if (!isValidPluginDartFileName(fileName)) { + throwToolExit('The plugin `$name` has an invalid `fileName` in its web plugin declaration.'); + } + return WebPlugin(name: name, pluginClass: pluginClass, fileName: fileName); } static const kConfigKey = 'web'; diff --git a/packages/flutter_tools/lib/src/plugins.dart b/packages/flutter_tools/lib/src/plugins.dart index 0628199b54c83..4f2427a123fe9 100644 --- a/packages/flutter_tools/lib/src/plugins.dart +++ b/packages/flutter_tools/lib/src/plugins.dart @@ -389,6 +389,12 @@ class Plugin { final dartClass = (platformsYaml[platformKey] as YamlMap)[kDartPluginClass] as String; final String dartFileName = (platformsYaml[platformKey] as YamlMap)[kDartFileName] as String? ?? '$pluginName.dart'; + if (!isValidPluginDartFileName(dartFileName)) { + throwToolExit( + 'The plugin `$pluginName` has an invalid `dartFileName` for platform `$platformKey` ' + 'in pubspec.yaml.', + ); + } return (dartClass: dartClass, dartFileName: dartFileName); } return null; diff --git a/packages/flutter_tools/lib/src/project.dart b/packages/flutter_tools/lib/src/project.dart index 0cbfe1435c031..1c4e753d531ce 100644 --- a/packages/flutter_tools/lib/src/project.dart +++ b/packages/flutter_tools/lib/src/project.dart @@ -426,7 +426,7 @@ class FlutterProject { } final migration = ProjectMigration([ - AnalysisOptionsMigration(this, globals.logger), + AnalysisOptionsMigration(this, globals.logger, packageConfig: packageConfig), ]); await migration.run(); diff --git a/packages/flutter_tools/lib/src/reporting/crash_reporting.dart b/packages/flutter_tools/lib/src/reporting/crash_reporting.dart index 513471fc6e427..e81ab3f15eae6 100644 --- a/packages/flutter_tools/lib/src/reporting/crash_reporting.dart +++ b/packages/flutter_tools/lib/src/reporting/crash_reporting.dart @@ -146,11 +146,15 @@ class CrashReportSender { /// Sends one crash report. /// /// The report is populated from data in [error] and [stackTrace]. + /// + /// If [typeId] is provided, it will be used as the type of the crash, otherwise will default to + /// being a Dart error ([_kDartTypeId]). Future sendReport({ required Object error, required StackTrace stackTrace, required String Function() getFlutterVersion, required String command, + String? typeId, }) async { // Only send one crash report per run. if (_crashReportSent) { @@ -176,7 +180,7 @@ class CrashReportSender { req.fields['version'] = flutterVersion; req.fields['osName'] = _platform.operatingSystem; req.fields['osVersion'] = _operatingSystemUtils.name; // this actually includes version - req.fields['type'] = _kDartTypeId; + req.fields['type'] = typeId ?? _kDartTypeId; req.fields['error_runtime_type'] = '${error.runtimeType}'; req.fields['error_message'] = '$error'; req.fields['comments'] = command; diff --git a/packages/flutter_tools/lib/src/run_hot.dart b/packages/flutter_tools/lib/src/run_hot.dart index 17ca7be7a166e..dd85a3542e1a6 100644 --- a/packages/flutter_tools/lib/src/run_hot.dart +++ b/packages/flutter_tools/lib/src/run_hot.dart @@ -1486,6 +1486,20 @@ class ProjectFileInvalidator { if (_isNotInPubCache(uri)) uri, ]; final invalidatedFiles = []; + + final bool Function(DateTime) isInvalidated; + if (_platform.isWindows) { + // On Windows, FileStat.modified truncates to second precision (via GetFileAttributesExW). + // However, lastCompiled is recorded with millisecond precision. + final lastCompiledTruncated = DateTime.fromMillisecondsSinceEpoch( + (lastCompiled.millisecondsSinceEpoch ~/ 1000) * 1000, + isUtc: lastCompiled.isUtc, + ); + isInvalidated = (DateTime updatedAt) => !updatedAt.isBefore(lastCompiledTruncated); + } else { + isInvalidated = (DateTime updatedAt) => updatedAt.isAfter(lastCompiled); + } + if (asyncScanning) { final pool = Pool(_kMaxPendingStats); final waitList = >[]; @@ -1502,7 +1516,7 @@ class ProjectFileInvalidator { : _fileSystem.stat(uri.toFilePath(windows: _platform.isWindows))) .then((FileStat stat) { final DateTime updatedAt = stat.modified; - if (updatedAt.isAfter(lastCompiled)) { + if (isInvalidated(updatedAt)) { invalidatedFiles.add(uri); } }), @@ -1517,7 +1531,7 @@ class ProjectFileInvalidator { final DateTime updatedAt = uri.hasScheme && uri.scheme != 'file' ? _fileSystem.file(uri).statSync().modified : _fileSystem.statSync(uri.toFilePath(windows: _platform.isWindows)).modified; - if (updatedAt.isAfter(lastCompiled)) { + if (isInvalidated(updatedAt)) { invalidatedFiles.add(uri); } } @@ -1527,7 +1541,7 @@ class ProjectFileInvalidator { final File packageFile = _fileSystem.file(packagesPath); final Uri packageUri = packageFile.uri; final DateTime updatedAt = packageFile.statSync().modified; - if (updatedAt.isAfter(lastCompiled)) { + if (isInvalidated(updatedAt)) { invalidatedFiles.add(packageUri); } diff --git a/packages/flutter_tools/lib/src/test/flutter_platform.dart b/packages/flutter_tools/lib/src/test/flutter_platform.dart index 40752624454ad..ae5ed668400ec 100644 --- a/packages/flutter_tools/lib/src/test/flutter_platform.dart +++ b/packages/flutter_tools/lib/src/test/flutter_platform.dart @@ -721,7 +721,7 @@ class FlutterPlatform extends PlatformPlugin { 'test $ourTestCount: connected to test device, now awaiting test result', ); - await _pipeHarnessToRemote( + await pipeHarnessToRemote( id: ourTestCount, harnessChannel: testHarnessChannel, remoteChannel: remoteChannel, @@ -889,9 +889,10 @@ class _AsyncError { /// /// The returned future completes when either side is closed, which also /// indicates when the tests have finished. -Future _pipeHarnessToRemote({ +@visibleForTesting +Future pipeHarnessToRemote({ required int id, - required StreamChannel harnessChannel, + required StreamChannel harnessChannel, required StreamChannel remoteChannel, }) async { globals.printTrace('test $id: Waiting for test harness or tests to finish'); @@ -902,10 +903,18 @@ Future _pipeHarnessToRemote({ ) { globals.printTrace('test $id: Test process is no longer needed by test harness'); }), - remoteChannel.stream.map(json.decode).pipe(harnessChannel.sink).then(( - void value, - ) { - globals.printTrace('test $id: Test harness is no longer needed by test process'); - }), + remoteChannel.stream + .map(json.decode) + .handleError((Object error) { + final formatException = error as FormatException; + globals.printWarning( + 'Received unexpected non-JSON output from test runner: ${formatException.source}', + ); + globals.printTrace('test $id: JSON decoding failed: $formatException'); + }, test: (error) => error is FormatException) + .pipe(harnessChannel.sink) + .then((void value) { + globals.printTrace('test $id: Test harness is no longer needed by test process'); + }), ]); } diff --git a/packages/flutter_tools/lib/src/test/flutter_web_platform.dart b/packages/flutter_tools/lib/src/test/flutter_web_platform.dart index 47f8557c750e1..1782f62060f3e 100644 --- a/packages/flutter_tools/lib/src/test/flutter_web_platform.dart +++ b/packages/flutter_tools/lib/src/test/flutter_web_platform.dart @@ -699,6 +699,7 @@ window.\$dartLoader.loader.nextAttempt(); hostUrl, completer.future, headless: !_config.pauseAfterLoad, + logger: _logger, ); } @@ -760,7 +761,7 @@ class OneOffHandler { class BrowserManager { /// Creates a new BrowserManager that communicates with [_browser] over /// [webSocket]. - BrowserManager._(this._browser, this._runtime, WebSocketChannel webSocket) { + BrowserManager._(this._browser, this._runtime, WebSocketChannel webSocket, this._logger) { // The duration should be short enough that the debugging console is open as // soon as the user is done setting breakpoints, but long enough that a test // doing a lot of synchronous work doesn't trigger a false positive. @@ -777,16 +778,24 @@ class BrowserManager { // the browser is still running code which means the user isn't debugging. _channel = MultiChannel( webSocket.cast().transform(jsonDocument).changeStream((Stream stream) { - return stream.map((Object? message) { - if (!_closed) { - _timer.reset(); - } - for (final RunnerSuiteController controller in _controllers) { - controller.setDebugging(false); - } + return stream + .handleError((Object error) { + final formatException = error as FormatException; + _logger.printWarning( + 'Received unexpected non-JSON message from browser WebSocket: ${formatException.source}', + ); + _logger.printTrace('JSON decode error: $formatException'); + }, test: (error) => error is FormatException) + .map((Object? message) { + if (!_closed) { + _timer.reset(); + } + for (final RunnerSuiteController controller in _controllers) { + controller.setDebugging(false); + } - return message; - }); + return message; + }); }), ); @@ -797,6 +806,7 @@ class BrowserManager { /// The browser instance that this is connected to via [_channel]. final Chromium _browser; final Runtime _runtime; + final Logger _logger; /// The channel used to communicate with the browser. /// @@ -856,6 +866,7 @@ class BrowserManager { Runtime runtime, Uri url, Future future, { + required Logger logger, bool debug = false, bool headless = true, List webBrowserFlags = const [], @@ -888,7 +899,7 @@ class BrowserManager { if (completer.isCompleted) { return; } - completer.complete(BrowserManager._(chrome, runtime, webSocket)); + completer.complete(BrowserManager._(chrome, runtime, webSocket, logger)); }, onError: (Object error, StackTrace stackTrace) { chrome.close(); diff --git a/packages/flutter_tools/lib/src/vmservice.dart b/packages/flutter_tools/lib/src/vmservice.dart index 4c5f444784116..3fe7e0bc8cea0 100644 --- a/packages/flutter_tools/lib/src/vmservice.dart +++ b/packages/flutter_tools/lib/src/vmservice.dart @@ -146,9 +146,7 @@ Future _defaultOpenChannel( attempts += 1; try { socket = await constructor(url, compression: compression, logger: logger); - } on io.WebSocketException catch (e) { - await handleError(e); - } on io.SocketException catch (e) { + } on io.IOException catch (e) { await handleError(e); } } diff --git a/packages/flutter_tools/lib/src/widget_preview/lsp_preview_detector.dart b/packages/flutter_tools/lib/src/widget_preview/lsp_preview_detector.dart index ef8d6483ed64d..ec86988971959 100644 --- a/packages/flutter_tools/lib/src/widget_preview/lsp_preview_detector.dart +++ b/packages/flutter_tools/lib/src/widget_preview/lsp_preview_detector.dart @@ -185,11 +185,17 @@ class LspPreviewDetector { onPubspecChangeDetected(filePath); return; } - await _analysisServer?.waitForAnalysis(); + if (!filePath.isDartFile) { + return; + } + previewAnalytics.startPreviewReloadStopwatch(); try { + await _analysisServer?.waitForAnalysis(); final FlutterWidgetPreviews result = await dtd.getFlutterWidgetPreviews(); onChangeDetected(result); + previewAnalytics.reportPreviewReloadTiming(); } catch (e) { + previewAnalytics.resetPreviewReloadStopwatch(); if (_disposed || shutdownHooks.isShuttingDown) { logger.printTrace('Failed to get widget previews during shutdown: $e'); } else if (e is StateError || e is Exception) { @@ -200,6 +206,7 @@ class LspPreviewDetector { } else { rethrow; } + return; } } } diff --git a/packages/flutter_tools/pubspec.yaml b/packages/flutter_tools/pubspec.yaml index f7e224aff8e3a..d7cf86eabad21 100644 --- a/packages/flutter_tools/pubspec.yaml +++ b/packages/flutter_tools/pubspec.yaml @@ -13,7 +13,7 @@ dependencies: analyzer: 10.1.0 archive: 3.6.1 args: 2.7.0 - dds: 5.3.0 + dds: 5.4.0 dwds: 27.1.2 code_builder: 4.11.1 collection: 1.19.1 @@ -85,7 +85,7 @@ dependencies: csslib: 1.0.2 dap: 1.4.0 dds_service_extensions: 2.1.0 - devtools_shared: 12.1.0 + devtools_shared: 13.1.0 dtd: 4.0.0 extension_discovery: 2.1.0 fixnum: 1.1.1 @@ -127,4 +127,4 @@ dartdoc: # Exclude this package from the hosted API docs. nodoc: true -# PUBSPEC CHECKSUM: ctgrgi +# PUBSPEC CHECKSUM: 8d140k diff --git a/packages/flutter_tools/templates/add_to_app/darwin/Scripts/flutter_integration.sh.tmpl b/packages/flutter_tools/templates/add_to_app/darwin/Scripts/flutter_integration.sh.tmpl index ef30cd2e7d5bb..46011b91c0076 100644 --- a/packages/flutter_tools/templates/add_to_app/darwin/Scripts/flutter_integration.sh.tmpl +++ b/packages/flutter_tools/templates/add_to_app/darwin/Scripts/flutter_integration.sh.tmpl @@ -28,8 +28,8 @@ FLUTTER_TOOL_BUILD_MODE="release" buildTool() { cd "$FLUTTER_NATIVE_TOOLS_PACKAGE_PATH" || exit 1 - xcrun --sdk macosx swift package clean --build-path "$FLUTTER_TOOL_BUILD_PATH" - xcrun --sdk macosx swift build --product $FLUTTER_TOOL --build-path "$FLUTTER_TOOL_BUILD_PATH" -c $FLUTTER_TOOL_BUILD_MODE --disable-sandbox + env -i PATH="$PATH" DEVELOPER_DIR="${DEVELOPER_DIR:-}" xcrun --sdk macosx swift package clean --build-path "$FLUTTER_TOOL_BUILD_PATH" + env -i PATH="$PATH" DEVELOPER_DIR="${DEVELOPER_DIR:-}" xcrun --sdk macosx swift build --product $FLUTTER_TOOL --build-path "$FLUTTER_TOOL_BUILD_PATH" -c $FLUTTER_TOOL_BUILD_MODE --disable-sandbox } # Use prebuilt tool if possible, otherwise re-build it for next time diff --git a/packages/flutter_tools/templates/app/analysis_options.yaml.tmpl b/packages/flutter_tools/templates/app/analysis_options.yaml.tmpl index 775c7ce3cf98d..86d64ab5c6fd3 100644 --- a/packages/flutter_tools/templates/app/analysis_options.yaml.tmpl +++ b/packages/flutter_tools/templates/app/analysis_options.yaml.tmpl @@ -14,12 +14,24 @@ include: package:flutter_lints/flutter.yaml analyzer: exclude: - build/** +{{#android}} - android/** +{{/android}} +{{#ios}} - ios/** +{{/ios}} +{{#web}} - web/** +{{/web}} +{{#windows}} - windows/** +{{/windows}} +{{#macos}} - macos/** +{{/macos}} +{{#linux}} - linux/** +{{/linux}} {{^withEmptyMain}} linter: diff --git a/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/widget_preview_rendering.dart.tmpl b/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/widget_preview_rendering.dart.tmpl index b731021768edb..05c7f552c841a 100644 --- a/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/widget_preview_rendering.dart.tmpl +++ b/packages/flutter_tools/templates/widget_preview_scaffold/lib/src/widget_preview_rendering.dart.tmpl @@ -217,7 +217,7 @@ class PreviewWidgetElement extends StatelessElement { PreviewWidgetElement(super.widget); } -class WidgetPreviewGroupWidget extends StatelessWidget { +class WidgetPreviewGroupWidget extends StatefulWidget { const WidgetPreviewGroupWidget({ super.key, required this.controller, @@ -235,6 +235,14 @@ class WidgetPreviewGroupWidget extends StatelessWidget { // TODO(bkonyi): inherit this from the theme. static const _kCardRadius = Radius.circular(12); + @override + State createState() => + _WidgetPreviewGroupWidgetState(); +} + +class _WidgetPreviewGroupWidgetState extends State { + final _bucket = PageStorageBucket(); + Widget _buildGridViewFlex(List previews) { return Wrap( spacing: WidgetPreviewGroupWidget._gridSpacing, @@ -242,7 +250,7 @@ class WidgetPreviewGroupWidget extends StatelessWidget { alignment: WrapAlignment.start, children: [ for (final WidgetPreview preview in previews) - WidgetPreviewWidget(controller: controller, preview: preview), + WidgetPreviewWidget(controller: widget.controller, preview: preview), ], ); } @@ -253,7 +261,7 @@ class WidgetPreviewGroupWidget extends StatelessWidget { for (final preview in previews) Center( child: WidgetPreviewWidget( - controller: controller, + controller: widget.controller, preview: preview, ), ), @@ -269,7 +277,9 @@ class WidgetPreviewGroupWidget extends StatelessWidget { data: ListTileTheme.of(context).copyWith( dense: true, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.all(_kCardRadius), + borderRadius: BorderRadius.all( + WidgetPreviewGroupWidget._kCardRadius, + ), ), ), child: Theme( @@ -277,20 +287,31 @@ class WidgetPreviewGroupWidget extends StatelessWidget { // expanded ExpansionTile. data: theme.copyWith(dividerColor: Colors.transparent), child: ExpansionTile( - key: PageStorageKey(group.name), - title: Text(group.name), + key: PageStorageKey(widget.group.name), + title: Text(widget.group.name), initiallyExpanded: true, children: [ - ValueListenableBuilder( - valueListenable: controller.layoutTypeListenable, - builder: (context, selectedLayout, _) { - return switch (selectedLayout) { - LayoutType.gridView => _buildGridViewFlex(group.previews), - LayoutType.listView => _buildVerticalListView( - group.previews, - ), - }; - }, + // Wrap children in a PageStorage to create a storage boundary. + // Without this, descendant scrollables (which search ancestor + // elements for a PageStorageKey when restoring scroll offset) + // inherit the PageStorageKey from the ExpansionTile and attempt + // to read the ExpansionTile's boolean expansion state as a double, + // throwing a TypeError (see https://github.com/flutter/flutter/issues/191242). + PageStorage( + bucket: _bucket, + child: ValueListenableBuilder( + valueListenable: widget.controller.layoutTypeListenable, + builder: (context, selectedLayout, _) { + return switch (selectedLayout) { + LayoutType.gridView => _buildGridViewFlex( + widget.group.previews, + ), + LayoutType.listView => _buildVerticalListView( + widget.group.previews, + ), + }; + }, + ), ), ], ), diff --git a/packages/flutter_tools/test/android_java17_integration.shard/android_dependency_version_checking_test.dart b/packages/flutter_tools/test/android_java17_integration.shard/android_dependency_version_checking_test.dart index 1bf951db5f558..6aa89b25a9fe4 100644 --- a/packages/flutter_tools/test/android_java17_integration.shard/android_dependency_version_checking_test.dart +++ b/packages/flutter_tools/test/android_java17_integration.shard/android_dependency_version_checking_test.dart @@ -25,7 +25,7 @@ void main() { testUsingContext('AGP version out of "warn" support band but in "error" band builds ' 'successfully and prints warning', () async { final versionTuple = VersionTuple( - agpVersion: '8.7.0', + agpVersion: '8.12.0', gradleVersion: '8.14', kotlinVersion: '2.2.20', ); @@ -42,7 +42,7 @@ void main() { // Create a new flutter project. final versionTuple = VersionTuple( agpVersion: '8.11.1', - gradleVersion: '8.13', + gradleVersion: '8.14', kotlinVersion: '2.2.20', ); final ProcessResult result = await buildFlutterApkWithSpecifiedDependencyVersions( @@ -56,9 +56,9 @@ void main() { testUsingContext('Kotlin version out of "warn" support band but in "error" band builds ' 'successfully and prints warning', () async { final versionTuple = VersionTuple( - agpVersion: '8.7.0', + agpVersion: '8.11.1', gradleVersion: '8.14', - kotlinVersion: '2.1.20', + kotlinVersion: '2.3.0', ); final ProcessResult result = await buildFlutterApkWithSpecifiedDependencyVersions( versions: versionTuple, @@ -71,9 +71,9 @@ void main() { testUsingContext('No logs are printed when suppression flag is passed', () async { final versionTuple = VersionTuple( - agpVersion: '8.6.0', - gradleVersion: '8.7', - kotlinVersion: '2.0.0', + agpVersion: '8.11.1', + gradleVersion: '8.14', + kotlinVersion: '2.2.20', ); final ProcessResult result = await buildFlutterApkWithSpecifiedDependencyVersions( versions: versionTuple, diff --git a/packages/flutter_tools/test/commands.shard/hermetic/build_macos_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/build_macos_test.dart index 561b2a9c6d174..3b75a895b9173 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/build_macos_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/build_macos_test.dart @@ -252,6 +252,46 @@ STDERR STUFF }, ); + testUsingContext( + 'macOS build fails when Xcode is not installed', + () async { + final command = BuildCommand( + androidSdk: FakeAndroidSdk(), + buildSystem: TestBuildSystem.all(BuildResult(success: true)), + fileSystem: fileSystem, + logger: logger, + osUtils: FakeOperatingSystemUtils(), + config: FakeConfig(), + platform: FakePlatform(), + fileSystemUtils: FakeFileSystemUtils(), + terminal: FakeTerminal(), + plistParser: FakePlistParser(), + processUtils: FakeProcessUtils(), + processManager: FakeProcessManager.any(), + templateRenderer: FakeTemplateRenderer(), + xcode: FakeXcode(), + artifacts: FakeArtifacts(), + cache: FakeCache(), + flutterVersion: FakeFlutterVersion(), + ); + createMinimalMockProjectFiles(); + + expect( + createTestCommandRunner(command).run(const ['build', 'macos', '--no-pub']), + throwsToolExit( + message: 'Xcode not installed; this is necessary for iOS and macOS development.', + ), + ); + }, + overrides: { + Platform: () => macosPlatform, + FileSystem: () => fileSystem, + ProcessManager: () => FakeProcessManager.any(), + FeatureFlags: () => TestFeatureFlags(isMacOSEnabled: true), + XcodeProjectInterpreter: () => FakeXcodeProjectInterpreter(isInstalled: false), + }, + ); + testUsingContext( 'macOS build successfully with renamed .xcodeproj/.xcworkspace files', () async { diff --git a/packages/flutter_tools/test/commands.shard/hermetic/symbolize_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/symbolize_test.dart index 1ae3c77279be9..82d0f83b10bc5 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/symbolize_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/symbolize_test.dart @@ -53,6 +53,33 @@ void main() { ); }); + // Regression test for https://github.com/flutter/flutter/issues/191205. + testUsingContext('DwarfSymbolizationService handles stream errors without crashing', () async { + final symbolizationService = DwarfSymbolizationService( + symbolsTransformer: (Uint8List symbols) { + return StreamTransformer.fromHandlers( + handleData: (String data, EventSink sink) { + sink.addError('Test error'); + }, + ); + }, + ); + final output = StreamController>(); + + final Future decodeFuture = symbolizationService.decode( + input: Stream.fromIterable([ + const Utf8Encoder().convert('Hello, World\n'), + ]), + symbols: Uint8List(0), + output: IOSink(output.sink), + ); + + expect( + decodeFuture, + throwsA(isA().having((ToolExit e) => e.message, 'message', contains('Test error'))), + ); + }); + testUsingContext( 'symbolize exits when --debug-info and --unit-id-debug-info arguments are missing', () async { diff --git a/packages/flutter_tools/test/commands.shard/hermetic/widget_preview/lsp_preview_detector_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/widget_preview/lsp_preview_detector_test.dart new file mode 100644 index 0000000000000..d44768cdf1670 --- /dev/null +++ b/packages/flutter_tools/test/commands.shard/hermetic/widget_preview/lsp_preview_detector_test.dart @@ -0,0 +1,277 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; + +import 'package:file/memory.dart'; +import 'package:flutter_tools/src/artifacts.dart'; +import 'package:flutter_tools/src/base/file_system.dart'; +import 'package:flutter_tools/src/base/logger.dart'; +import 'package:flutter_tools/src/base/platform.dart'; +import 'package:flutter_tools/src/base/process.dart'; +import 'package:flutter_tools/src/base/terminal.dart'; +import 'package:flutter_tools/src/dart/analysis.dart'; +import 'package:flutter_tools/src/project.dart'; +import 'package:flutter_tools/src/widget_preview/analytics.dart'; +import 'package:flutter_tools/src/widget_preview/dtd_services.dart'; +import 'package:flutter_tools/src/widget_preview/dtd_types.dart'; +import 'package:flutter_tools/src/widget_preview/lsp_preview_detector.dart'; +import 'package:test/fake.dart'; +import 'package:unified_analytics/unified_analytics.dart'; +import 'package:watcher/watcher.dart'; + +import '../../../src/common.dart'; +import '../../../src/context.dart'; +import '../../../src/fakes.dart'; + +const String _kProjectRootPath = '/project'; +const String _kDartFilePath = '/project/lib/foo.dart'; +const String _kNonDartFilePath = '/project/README.md'; +const String _kPubspecPath = '/project/pubspec.yaml'; +const String _kIgnoredDartToolPath = '/project/.dart_tool/package_config.json'; + +void main() { + group('$LspPreviewDetector', () { + late MemoryFileSystem fs; + late BufferLogger logger; + late FakePlatform platform; + late FakeAnalytics fakeAnalytics; + late WidgetPreviewAnalytics previewAnalytics; + late FakeFlutterProject project; + late FakeWidgetPreviewDtdServices fakeDtd; + late FakeWatcher fakeWatcher; + late FakeAnalysisServer fakeAnalysisServer; + late ShutdownHooks shutdownHooks; + late List detectedChanges; + late List detectedPubspecChanges; + late LspPreviewDetector detector; + + setUp(() async { + fs = MemoryFileSystem.test(); + logger = BufferLogger.test(); + platform = FakePlatform(); + fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: fs, + fakeFlutterVersion: FakeFlutterVersion(), + ); + previewAnalytics = WidgetPreviewAnalytics(analytics: fakeAnalytics); + project = FakeFlutterProject(fs.directory(_kProjectRootPath)); + fakeDtd = FakeWidgetPreviewDtdServices(); + fakeWatcher = FakeWatcher(); + fakeAnalysisServer = FakeAnalysisServer(); + shutdownHooks = ShutdownHooks(); + detectedChanges = []; + detectedPubspecChanges = []; + + detector = LspPreviewDetector( + platform: platform, + previewAnalytics: previewAnalytics, + project: project, + fs: fs, + logger: logger, + onChangeDetected: detectedChanges.add, + onPubspecChangeDetected: detectedPubspecChanges.add, + dtd: fakeDtd, + processManager: FakeProcessManager.any(), + terminal: Terminal.test(), + suppressAnalytics: false, + artifacts: Artifacts.test(), + shutdownHooks: shutdownHooks, + watcherBuilder: (_) => fakeWatcher, + analysisServerFactory: () async => fakeAnalysisServer, + ); + await detector.initialize(); + }); + + tearDown(() async { + await detector.dispose(); + await fakeWatcher.close(); + }); + + void expectNPreviewReloadTimingEvents(int n) { + final Iterable reloadTimingEvents = fakeAnalytics.sentEvents.where( + (Event e) => + e.eventData['workflow'] == WidgetPreviewAnalytics.kWorkflow && + e.eventData['variableName'] == WidgetPreviewAnalytics.kPreviewReloadTime, + ); + expect(reloadTimingEvents, hasLength(n)); + } + + Future emitEvent(WatchEvent event) async { + fakeWatcher.emitEvent(event); + await pumpEventQueue(); + await detector.mutex.runGuarded(() async {}); + } + + testWithoutContext('reports preview reload timing when files are modified', () async { + expectNPreviewReloadTimingEvents(0); + expect(detectedChanges, isEmpty); + expect(fakeAnalysisServer.waitForAnalysisCallCount, 0); + + await emitEvent(WatchEvent(ChangeType.MODIFY, _kDartFilePath)); + + expect(detectedChanges, hasLength(1)); + expectNPreviewReloadTimingEvents(1); + expect(fakeAnalysisServer.waitForAnalysisCallCount, 1); + + await emitEvent(WatchEvent(ChangeType.MODIFY, _kDartFilePath)); + + expect(detectedChanges, hasLength(2)); + expectNPreviewReloadTimingEvents(2); + expect(fakeAnalysisServer.waitForAnalysisCallCount, 2); + }); + + testWithoutContext( + 'does not report preview reload timing when pubspec.yaml is modified', + () async { + expectNPreviewReloadTimingEvents(0); + expect(detectedPubspecChanges, isEmpty); + + await emitEvent(WatchEvent(ChangeType.MODIFY, _kPubspecPath)); + + expect(detectedPubspecChanges, [_kPubspecPath]); + expectNPreviewReloadTimingEvents(0); + expect(fakeAnalysisServer.waitForAnalysisCallCount, 0); + }, + ); + + testWithoutContext( + 'does not report preview reload timing when non-Dart file is modified', + () async { + expectNPreviewReloadTimingEvents(0); + expect(detectedChanges, isEmpty); + expect(detectedPubspecChanges, isEmpty); + + await emitEvent(WatchEvent(ChangeType.MODIFY, _kNonDartFilePath)); + + expect(detectedChanges, isEmpty); + expect(detectedPubspecChanges, isEmpty); + expectNPreviewReloadTimingEvents(0); + expect(fakeAnalysisServer.waitForAnalysisCallCount, 0); + }, + ); + + testWithoutContext('does not report preview reload timing for ignored directories', () async { + expectNPreviewReloadTimingEvents(0); + + await emitEvent(WatchEvent(ChangeType.MODIFY, _kIgnoredDartToolPath)); + + expect(detectedChanges, isEmpty); + expect(detectedPubspecChanges, isEmpty); + expectNPreviewReloadTimingEvents(0); + expect(fakeAnalysisServer.waitForAnalysisCallCount, 0); + }); + + testWithoutContext( + 'resets reload timing stopwatch on DTD exception and does not report timing', + () async { + fakeDtd.shouldThrow = true; + expectNPreviewReloadTimingEvents(0); + + await emitEvent(WatchEvent(ChangeType.MODIFY, _kDartFilePath)); + + expect(detectedChanges, isEmpty); + expectNPreviewReloadTimingEvents(0); + + fakeDtd.shouldThrow = false; + await emitEvent(WatchEvent(ChangeType.MODIFY, _kDartFilePath)); + + expect(detectedChanges, hasLength(1)); + expectNPreviewReloadTimingEvents(1); + }, + ); + + testWithoutContext( + 'resets reload timing stopwatch on analysis server exception and does not report timing', + () async { + fakeAnalysisServer.shouldThrow = true; + expectNPreviewReloadTimingEvents(0); + + await emitEvent(WatchEvent(ChangeType.MODIFY, _kDartFilePath)); + + expect(detectedChanges, isEmpty); + expectNPreviewReloadTimingEvents(0); + + fakeAnalysisServer.shouldThrow = false; + await emitEvent(WatchEvent(ChangeType.MODIFY, _kDartFilePath)); + + expect(detectedChanges, hasLength(1)); + expectNPreviewReloadTimingEvents(1); + }, + ); + }); +} + +class FakeFlutterProject extends Fake implements FlutterProject { + FakeFlutterProject(this.directory); + + @override + final Directory directory; + + @override + void reloadManifest({Logger? logger, FileSystem? fs}) {} + + @override + Set get ephemeralDirectories => const {}; +} + +class FakeWatcher extends Fake implements Watcher { + final StreamController _events = StreamController.broadcast(); + + @override + Stream get events => _events.stream; + + @override + Future get ready => Future.value(); + + void emitEvent(WatchEvent event) => _events.add(event); + + Future close() => _events.close(); +} + +class FakeAnalysisServer extends Fake implements AnalysisServer { + int waitForAnalysisCallCount = 0; + bool shouldThrow = false; + + @override + Future start() async {} + + @override + Future connectToDtd({required Uri dtdUri}) async {} + + @override + Future dispose() async => true; + + @override + Future waitForAnalysis({Duration delay = const Duration(milliseconds: 100)}) async { + if (shouldThrow) { + throw StateError('Fake analysis server error'); + } + waitForAnalysisCallCount++; + } +} + +class FakeWidgetPreviewDtdServices extends Fake implements WidgetPreviewDtdServices { + @override + bool lspServiceAvailable = false; + + @override + Uri get dtdUri => Uri.parse('ws://127.0.0.1:12345'); + + bool shouldThrow = false; + FlutterWidgetPreviews? nextUpdate; + + @override + Future getFlutterWidgetPreviews() async { + if (shouldThrow) { + throw StateError('Fake DTD error'); + } + return nextUpdate ?? + const FlutterWidgetPreviews( + namespaces: {}, + previews: [], + scriptUris: [], + ); + } +} diff --git a/packages/flutter_tools/test/commands.shard/permeable/create_test.dart b/packages/flutter_tools/test/commands.shard/permeable/create_test.dart index 59b683a8592a5..8b171e20223ee 100644 --- a/packages/flutter_tools/test/commands.shard/permeable/create_test.dart +++ b/packages/flutter_tools/test/commands.shard/permeable/create_test.dart @@ -4173,6 +4173,23 @@ void main() { }, ); + testUsingContext('analysis_options.yaml only excludes platforms that were generated', () async { + await _createProject( + projectDir, + ['--no-pub', '--platforms', 'android,ios'], + ['analysis_options.yaml'], + ); + + final String analysisOptions = projectDir.childFile('analysis_options.yaml').readAsStringSync(); + expect(analysisOptions, contains('- build/**')); + expect(analysisOptions, contains('- android/**')); + expect(analysisOptions, contains('- ios/**')); + expect(analysisOptions, isNot(contains('- web/**'))); + expect(analysisOptions, isNot(contains('- windows/**'))); + expect(analysisOptions, isNot(contains('- macos/**'))); + expect(analysisOptions, isNot(contains('- linux/**'))); + }); + testUsingContext('should escape ":" in project description', () async { await _createProject( projectDir, diff --git a/packages/flutter_tools/test/commands.shard/permeable/packages_test.dart b/packages/flutter_tools/test/commands.shard/permeable/packages_test.dart index 830c8d55c81c6..f886156392d66 100644 --- a/packages/flutter_tools/test/commands.shard/permeable/packages_test.dart +++ b/packages/flutter_tools/test/commands.shard/permeable/packages_test.dart @@ -458,6 +458,52 @@ workspace: }, ); + testUsingContext( + 'get does not generate platform tooling for a non-Flutter workspace root', + // Regression test for https://github.com/flutter/flutter/issues/189550. + () async { + tempDir.childFile('pubspec.yaml').writeAsStringSync(''' +name: workspace +environment: + sdk: ^3.7.0-0 +workspace: + - flutter_project +'''); + // A stray platform directory in the plain Dart workspace root must not + // be populated with Flutter project files. + tempDir.childDirectory('ios').createSync(); + tempDir.childDirectory('android').createSync(); + final String projectPath = await createProject(tempDir, arguments: ['--no-pub']); + final File pubspecFile = fileSystem.file(fileSystem.path.join(projectPath, 'pubspec.yaml')); + final pubspecYaml = loadYaml(pubspecFile.readAsStringSync()) as YamlMap; + final pubspec = { + ...pubspecYaml.value.cast(), + 'resolution': 'workspace', + 'environment': {'sdk': '^3.5.0-0'}, + }; + pubspecFile.writeAsStringSync(jsonEncode(pubspec)); + await runCommandIn(tempDir.path, 'get'); + + expectDependenciesResolved(tempDir.path); + expect(tempDir.childDirectory('ios').listSync(), isEmpty); + expect(tempDir.childDirectory('android').listSync(), isEmpty); + // The Flutter app member of the workspace is still processed. + expectExists(projectPath, 'ios/Flutter/Generated.xcconfig'); + }, + overrides: { + Stdio: () => mockStdio, + Pub: () => Pub.test( + fileSystem: globals.fs, + logger: globals.logger, + processManager: globals.processManager, + botDetector: globals.botDetector, + platform: globals.platform, + stdio: mockStdio, + ), + Analytics: () => fakeAnalytics, + }, + ); + testUsingContext( 'get generates files into lib/l10n', () async { diff --git a/packages/flutter_tools/test/commands.shard/permeable/upgrade_test.dart b/packages/flutter_tools/test/commands.shard/permeable/upgrade_test.dart index 82a93250150b2..d3340d53b176b 100644 --- a/packages/flutter_tools/test/commands.shard/permeable/upgrade_test.dart +++ b/packages/flutter_tools/test/commands.shard/permeable/upgrade_test.dart @@ -2,6 +2,11 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +// TODO(team-tools): Remove when the execution order dependency is resolved. +// See https://github.com/flutter/flutter/issues/189919 +@Tags(['no-shuffle']) +library; + import 'package:args/command_runner.dart'; import 'package:file/memory.dart'; import 'package:flutter_tools/src/base/file_system.dart'; diff --git a/packages/flutter_tools/test/general.shard/android/android_device_discovery_test.dart b/packages/flutter_tools/test/general.shard/android/android_device_discovery_test.dart index 31d7cbb502162..fe7fc8ee03ad1 100644 --- a/packages/flutter_tools/test/general.shard/android/android_device_discovery_test.dart +++ b/packages/flutter_tools/test/general.shard/android/android_device_discovery_test.dart @@ -238,6 +238,84 @@ adb-ZY22MGW35T-Z3uXXq (2)._adb-tls-connect._tcp device product:vantage_ge mod 'expectedId': '127.0.0.1:5555', 'expectedStatus': 'success', }, + // ADB long listings use a minimum-width serial column, so a serial that + // is 22 characters or longer can be followed by only one space. + { + 'input': + 'adb-0123456789abcdef._adb-tls-connect._tcp device product:socrates model:22127RK46C device:socrates transport_id:1', + 'expectedId': 'adb-0123456789abcdef._adb-tls-connect._tcp', + 'expectedStatus': 'success', + }, + // mDNS conflict suffixes introduce whitespace into the serial itself. + { + 'input': + 'adb-0123456789abcdef (2)._adb-tls-connect._tcp device product:socrates model:22127RK46C device:socrates transport_id:1', + 'expectedId': 'adb-0123456789abcdef (2)._adb-tls-connect._tcp', + 'expectedStatus': 'success', + }, + { + 'input': 'adb-0123456789abcdef (2)._adb-tls-connect._tcp offline', + 'expectedId': 'adb-0123456789abcdef (2)._adb-tls-connect._tcp', + 'expectedStatus': 'offline', + }, + // Serial contents may themselves include known ADB state names. + { + 'input': 'my device offline device', + 'expectedId': 'my device offline', + 'expectedStatus': 'success', + }, + // Exercise every state currently recognized by Flutter with the ADB + // single-space form and a serial containing whitespace. + { + 'input': 'adb-0123456789abcdef (2)._adb-tls-connect._tcp unauthorized', + 'expectedId': 'adb-0123456789abcdef (2)._adb-tls-connect._tcp', + 'expectedStatus': 'unauthorized', + }, + { + 'input': 'adb-0123456789abcdef (2)._adb-tls-connect._tcp no permissions', + 'expectedId': 'adb-0123456789abcdef (2)._adb-tls-connect._tcp', + 'expectedStatus': 'no permissions', + }, + { + 'input': 'adb-0123456789abcdef (2)._adb-tls-connect._tcp bootloader', + 'expectedId': 'adb-0123456789abcdef (2)._adb-tls-connect._tcp', + 'expectedStatus': 'success', + }, + { + 'input': 'adb-0123456789abcdef (2)._adb-tls-connect._tcp recovery', + 'expectedId': 'adb-0123456789abcdef (2)._adb-tls-connect._tcp', + 'expectedStatus': 'success', + }, + { + 'input': 'adb-0123456789abcdef (2)._adb-tls-connect._tcp sideload', + 'expectedId': 'adb-0123456789abcdef (2)._adb-tls-connect._tcp', + 'expectedStatus': 'success', + }, + { + 'input': 'adb-0123456789abcdef (2)._adb-tls-connect._tcp rescue', + 'expectedId': 'adb-0123456789abcdef (2)._adb-tls-connect._tcp', + 'expectedStatus': 'success', + }, + { + 'input': 'adb-0123456789abcdef (2)._adb-tls-connect._tcp connecting', + 'expectedId': 'adb-0123456789abcdef (2)._adb-tls-connect._tcp', + 'expectedStatus': 'success', + }, + { + 'input': 'adb-0123456789abcdef (2)._adb-tls-connect._tcp authorizing', + 'expectedId': 'adb-0123456789abcdef (2)._adb-tls-connect._tcp', + 'expectedStatus': 'success', + }, + { + 'input': 'adb-0123456789abcdef (2)._adb-tls-connect._tcp host', + 'expectedId': 'adb-0123456789abcdef (2)._adb-tls-connect._tcp', + 'expectedStatus': 'success', + }, + { + 'input': 'adb-0123456789abcdef (2)._adb-tls-connect._tcp unknown', + 'expectedId': 'adb-0123456789abcdef (2)._adb-tls-connect._tcp', + 'expectedStatus': 'success', + }, // States that should go to diagnostics { 'input': '015d172c98400a03 offline', @@ -549,6 +627,64 @@ device4 unknown usb:3-7 expect(diagnostics[1], contains('device2 is offline')); }, ); + + // Related to https://github.com/flutter/flutter/issues/189274 + testWithoutContext( + 'AndroidDevices can parse output with extra non-key-value attributes without mis-matching device ID', + () async { + final androidDevices = AndroidDevices( + userMessages: UserMessages(), + androidWorkflow: androidWorkflow, + androidSdk: FakeAndroidSdk(), + logger: BufferLogger.test(), + processManager: FakeProcessManager.list([ + const FakeCommand( + command: ['adb', 'devices', '-l'], + stdout: ''' +List of devices attached +ABCDEFG device 20-30 product:mokey model:mokey device:mokey transport_id:1 +''', + ), + ]), + platform: FakePlatform(), + fileSystem: MemoryFileSystem.test(), + ); + + final List devices = await androidDevices.pollingGetDevices(); + + expect(devices, hasLength(1)); + expect(devices.first.id, 'ABCDEFG'); + expect(devices.first.name, 'mokey'); + }, + ); + + testWithoutContext( + 'AndroidDevices handles serial containing spaces and state keywords correctly', + () async { + final androidDevices = AndroidDevices( + userMessages: UserMessages(), + androidWorkflow: androidWorkflow, + androidSdk: FakeAndroidSdk(), + logger: BufferLogger.test(), + processManager: FakeProcessManager.list([ + const FakeCommand( + command: ['adb', 'devices', '-l'], + stdout: ''' +List of devices attached +my device (2) device product:model +''', + ), + ]), + platform: FakePlatform(), + fileSystem: MemoryFileSystem.test(), + ); + + final List devices = await androidDevices.pollingGetDevices(); + + expect(devices, hasLength(1)); + expect(devices.first.id, 'my device (2)'); + }, + ); } class FakeAndroidSdk extends Fake implements AndroidSdk { diff --git a/packages/flutter_tools/test/general.shard/android/android_gradle_builder_test.dart b/packages/flutter_tools/test/general.shard/android/android_gradle_builder_test.dart index e6d333afc6e03..b0bbccf992501 100644 --- a/packages/flutter_tools/test/general.shard/android/android_gradle_builder_test.dart +++ b/packages/flutter_tools/test/general.shard/android/android_gradle_builder_test.dart @@ -19,6 +19,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/user_messages.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/globals.dart' as globals; @@ -3069,6 +3070,324 @@ Gradle Crashed ); expect(processManager, hasNoRemainingExpectations); }, overrides: {AndroidStudio: () => FakeAndroidStudio()}); + + testUsingContext( + 'build apk throws ToolExit when Java and Gradle versions are incompatible', + () async { + final builder = AndroidGradleBuilder( + java: FakeJava(version: const Version.withText(21, 0, 0, '21.0.0')), + logger: logger, + processManager: processManager, + fileSystem: fileSystem, + artifacts: Artifacts.test(), + analytics: fakeAnalytics, + gradleUtils: FakeGradleUtils(), + platform: FakePlatform(), + androidStudio: FakeAndroidStudio(), + androidSdk: globals.androidSdk, + ); + + // Setup incompatible gradle version (e.g. 8.0 for Java 21) in gradle-wrapper.properties + fileSystem.file('android/gradle/wrapper/gradle-wrapper.properties') + ..createSync(recursive: true) + ..writeAsStringSync( + r'distributionUrl=https://services.gradle.org/distributions/gradle-8.0-all.zip', + ); + + fileSystem.file('android/gradlew').createSync(recursive: true); + fileSystem.directory('android').childFile('gradle.properties').createSync(recursive: true); + fileSystem.file('android/build.gradle').createSync(recursive: true); + fileSystem.directory('android').childDirectory('app').childFile('build.gradle') + ..createSync(recursive: true) + ..writeAsStringSync('apply from: irrelevant/flutter.gradle'); + + final FlutterProject project = FlutterProject.fromDirectoryTest( + fileSystem.currentDirectory, + ); + project.android.appManifestFile + ..createSync(recursive: true) + ..writeAsStringSync(minimalV2EmbeddingManifest); + + processManager.addCommand( + FakeCommand( + command: [ + 'gradlew', + '-q', + '-Ptarget-platform=android-arm,android-arm64,android-x64', + '-Ptarget=lib/main.dart', + '-Pbase-application-name=android.app.Application', + '-Pdart-obfuscation=false', + '-Ptrack-widget-creation=false', + '-Ptree-shake-icons=false', + '-Pflutter.androidSdkRoot=${sdkPath()}', + '-Pflutter.installedNdkVersions=29.0.13846066', + '-Pflutter.sdkManagerPath=${sdkManagerPath()}', + 'assembleDevRelease', + ], + exitCode: 1, + ), + ); + + await expectLater( + () => builder.buildGradleApp( + project: project, + androidBuildInfo: const AndroidBuildInfo( + BuildInfo( + BuildMode.release, + 'dev', + treeShakeIcons: false, + packageConfigPath: '.dart_tool/package_config.json', + ), + ), + target: 'lib/main.dart', + isBuildingBundle: false, + configOnly: false, + localGradleErrors: const [], + ), + throwsToolExit( + message: + 'Gradle build failed due to Java/Gradle incompatibility.\n' + 'The Java version used for the build is 21.0.0, which is incompatible with Gradle 8.0.\n' + 'To fix this, you can either:\n' + " 1. Upgrade your project's Gradle version (typically in gradle-wrapper.properties to a version matching the range: compatible Gradle versions for Java 21.0.0 are 8.4 or newer).\n" + ' 2. Use a different Java version for Flutter by running `flutter config --jdk-dir=`.' + ), + ); + expect(processManager, hasNoRemainingExpectations); + }, + overrides: { + AndroidSdk: () { + fileSystem.directory(sdkPath()).createSync(recursive: true); + fileSystem.directory(sdkLicensesPath()).createSync(recursive: true); + fileSystem + .directory(fileSystem.path.join(sdkPath(), 'cmdline-tools', 'latest', 'bin')) + .childFile(globals.platform.isWindows ? 'sdkmanager.bat' : 'sdkmanager') + .createSync(recursive: true); + fileSystem + .directory(ndkPath('29.0.13846066')) + .childFile('source.properties') + .createSync(recursive: true); + fileSystem.directory(ndkPath('29.0.13846066-bad')).createSync(recursive: true); + return AndroidSdk( + fileSystem.directory(sdkPath()), + java: FakeJava(version: const Version.withText(21, 0, 0, '21.0.0')), + fileSystem: fileSystem, + ); + }, + AndroidStudio: () => FakeAndroidStudio(), + }, + ); + + testUsingContext( + 'build apk succeeds when Java and Gradle versions are incompatible but Gradle build succeeds', + () async { + final builder = AndroidGradleBuilder( + java: FakeJava(version: const Version.withText(21, 0, 0, '21.0.0')), + logger: logger, + processManager: processManager, + fileSystem: fileSystem, + artifacts: Artifacts.test(), + analytics: fakeAnalytics, + gradleUtils: FakeGradleUtils(), + platform: FakePlatform(), + androidStudio: FakeAndroidStudio(), + androidSdk: globals.androidSdk, + ); + + // Setup incompatible gradle version (e.g. 8.0 for Java 21) in gradle-wrapper.properties + fileSystem.file('android/gradle/wrapper/gradle-wrapper.properties') + ..createSync(recursive: true) + ..writeAsStringSync( + r'distributionUrl=https://services.gradle.org/distributions/gradle-8.0-all.zip', + ); + + fileSystem.file('android/gradlew').createSync(recursive: true); + fileSystem.directory('android').childFile('gradle.properties').createSync(recursive: true); + fileSystem.file('android/build.gradle').createSync(recursive: true); + fileSystem.directory('android').childDirectory('app').childFile('build.gradle') + ..createSync(recursive: true) + ..writeAsStringSync('apply from: irrelevant/flutter.gradle'); + fileSystem + .directory('build') + .childDirectory('app') + .childDirectory('outputs') + .childDirectory('flutter-apk') + .childFile('app-dev-release.apk') + .createSync(recursive: true); + + final FlutterProject project = FlutterProject.fromDirectoryTest( + fileSystem.currentDirectory, + ); + project.android.appManifestFile + ..createSync(recursive: true) + ..writeAsStringSync(minimalV2EmbeddingManifest); + + processManager.addCommand( + FakeCommand( + command: [ + 'gradlew', + '-q', + '-Ptarget-platform=android-arm,android-arm64,android-x64', + '-Ptarget=lib/main.dart', + '-Pbase-application-name=android.app.Application', + '-Pdart-obfuscation=false', + '-Ptrack-widget-creation=false', + '-Ptree-shake-icons=false', + '-Pflutter.androidSdkRoot=${sdkPath()}', + '-Pflutter.installedNdkVersions=29.0.13846066', + '-Pflutter.sdkManagerPath=${sdkManagerPath()}', + 'assembleDevRelease', + ], + ), + ); + + await builder.buildGradleApp( + project: project, + androidBuildInfo: const AndroidBuildInfo( + BuildInfo( + BuildMode.release, + 'dev', + treeShakeIcons: false, + packageConfigPath: '.dart_tool/package_config.json', + ), + ), + target: 'lib/main.dart', + isBuildingBundle: false, + configOnly: false, + localGradleErrors: const [], + ); + + expect(processManager, hasNoRemainingExpectations); + }, + overrides: { + AndroidSdk: () { + fileSystem.directory(sdkPath()).createSync(recursive: true); + fileSystem.directory(sdkLicensesPath()).createSync(recursive: true); + fileSystem + .directory(fileSystem.path.join(sdkPath(), 'cmdline-tools', 'latest', 'bin')) + .childFile(globals.platform.isWindows ? 'sdkmanager.bat' : 'sdkmanager') + .createSync(recursive: true); + fileSystem + .directory(ndkPath('29.0.13846066')) + .childFile('source.properties') + .createSync(recursive: true); + fileSystem.directory(ndkPath('29.0.13846066-bad')).createSync(recursive: true); + return AndroidSdk( + fileSystem.directory(sdkPath()), + java: FakeJava(version: const Version.withText(21, 0, 0, '21.0.0')), + fileSystem: fileSystem, + ); + }, + AndroidStudio: () => FakeAndroidStudio(), + }, + ); + + testUsingContext( + 'skips Java and Gradle compatibility check when androidSkipBuildDependencyValidation is true', + () async { + final builder = AndroidGradleBuilder( + java: FakeJava(version: const Version.withText(21, 0, 0, '21.0.0')), + logger: logger, + processManager: processManager, + fileSystem: fileSystem, + artifacts: Artifacts.test(), + analytics: fakeAnalytics, + gradleUtils: FakeGradleUtils(), + platform: FakePlatform(), + androidStudio: FakeAndroidStudio(), + androidSdk: globals.androidSdk, + ); + + // Setup incompatible gradle version (e.g. 8.0 for Java 21) in gradle-wrapper.properties + // This would normally fail unless skipped. + fileSystem.file('android/gradle/wrapper/gradle-wrapper.properties') + ..createSync(recursive: true) + ..writeAsStringSync( + r'distributionUrl=https://services.gradle.org/distributions/gradle-8.0-all.zip', + ); + + fileSystem.file('android/gradlew').createSync(recursive: true); + fileSystem.directory('android').childFile('gradle.properties').createSync(recursive: true); + fileSystem.file('android/build.gradle').createSync(recursive: true); + fileSystem.directory('android').childDirectory('app').childFile('build.gradle') + ..createSync(recursive: true) + ..writeAsStringSync('apply from: irrelevant/flutter.gradle'); + fileSystem + .directory('build') + .childDirectory('app') + .childDirectory('outputs') + .childDirectory('flutter-apk') + .childFile('app-dev-release.apk') + .createSync(recursive: true); + + final FlutterProject project = FlutterProject.fromDirectoryTest( + fileSystem.currentDirectory, + ); + project.android.appManifestFile + ..createSync(recursive: true) + ..writeAsStringSync(minimalV2EmbeddingManifest); + + processManager.addCommand( + FakeCommand( + command: [ + 'gradlew', + '-q', + '-PskipDependencyChecks=true', + '-Ptarget-platform=android-arm,android-arm64,android-x64', + '-Ptarget=lib/main.dart', + '-Pbase-application-name=android.app.Application', + '-Pdart-obfuscation=false', + '-Ptrack-widget-creation=false', + '-Ptree-shake-icons=false', + '-Pflutter.androidSdkRoot=${sdkPath()}', + '-Pflutter.installedNdkVersions=29.0.13846066', + '-Pflutter.sdkManagerPath=${sdkManagerPath()}', + 'assembleDevRelease', + ], + ), + ); + + await builder.buildGradleApp( + project: project, + androidBuildInfo: const AndroidBuildInfo( + BuildInfo( + BuildMode.release, + 'dev', + treeShakeIcons: false, + packageConfigPath: '.dart_tool/package_config.json', + androidSkipBuildDependencyValidation: true, // Skip validation + ), + ), + target: 'lib/main.dart', + isBuildingBundle: false, + configOnly: false, + localGradleErrors: const [], + ); + + expect(processManager, hasNoRemainingExpectations); + }, + overrides: { + AndroidSdk: () { + fileSystem.directory(sdkPath()).createSync(recursive: true); + fileSystem.directory(sdkLicensesPath()).createSync(recursive: true); + fileSystem + .directory(fileSystem.path.join(sdkPath(), 'cmdline-tools', 'latest', 'bin')) + .childFile(globals.platform.isWindows ? 'sdkmanager.bat' : 'sdkmanager') + .createSync(recursive: true); + fileSystem + .directory(ndkPath('29.0.13846066')) + .childFile('source.properties') + .createSync(recursive: true); + fileSystem.directory(ndkPath('29.0.13846066-bad')).createSync(recursive: true); + return AndroidSdk( + fileSystem.directory(sdkPath()), + java: FakeJava(version: const Version.withText(21, 0, 0, '21.0.0')), + fileSystem: fileSystem, + ); + }, + AndroidStudio: () => FakeAndroidStudio(), + }, + ); }); } diff --git a/packages/flutter_tools/test/general.shard/android/android_workflow_test.dart b/packages/flutter_tools/test/general.shard/android/android_workflow_test.dart index fbacdecb9cf59..4541888898b1e 100644 --- a/packages/flutter_tools/test/general.shard/android/android_workflow_test.dart +++ b/packages/flutter_tools/test/general.shard/android/android_workflow_test.dart @@ -307,6 +307,112 @@ Review licenses that have not been accepted (y/N)? expect(result, LicensesAccepted.none); }); + testWithoutContext( + 'licensesAccepted falls back to the licenses directory when sdkmanager output is unparseable (new Android CLI, licenses present)', + () async { + sdk.sdkManagerPath = '/foo/bar/sdkmanager'; + sdk.licensesAvailable = true; + sdk.directory = fileSystem.directory('/sdk')..createSync(recursive: true); + fileSystem.directory('/sdk/licenses').createSync(recursive: true); + fileSystem + .file('/sdk/licenses/android-sdk-license') + .writeAsStringSync('24333f8a63b6825ea9c5514f83c2829b004d1fee\n'); + + // Real output from Android cmdline-tools 23.0's `sdkmanager --licenses`. + // See https://github.com/flutter/flutter/issues/191487. + const output = ''' +WARNING: The SDK Manager CLI tool (sdkmanager) is deprecated. Android CLI will be used instead. +The 'android' binary can also be found in the cmdline-tools directory, and 'android sdk' is the replacement for 'sdkmanager'. +To learn more about the Android CLI and how to use it, see the documentation (https://d.android.com/tools/agents/android-cli) +Warning: The --licenses option is no longer needed. +'''; + processManager.addCommand( + const FakeCommand(command: ['/foo/bar/sdkmanager', '--licenses'], stdout: output), + ); + + final licenseValidator = AndroidLicenseValidator( + java: FakeJava(), + androidSdk: sdk, + processManager: processManager, + platform: FakePlatform(environment: {'HOME': '/home/me'}), + stdio: stdio, + logger: BufferLogger.test(), + userMessages: UserMessages(), + ); + final LicensesAccepted result = await licenseValidator.licensesAccepted; + + expect(result, LicensesAccepted.all); + }, + ); + + testWithoutContext( + 'licensesAccepted falls back to none when sdkmanager output is unparseable and no licenses are present (new Android CLI, licenses missing)', + () async { + sdk.sdkManagerPath = '/foo/bar/sdkmanager'; + sdk.licensesAvailable = false; + sdk.directory = fileSystem.directory('/sdk')..createSync(recursive: true); + + const output = ''' +WARNING: The SDK Manager CLI tool (sdkmanager) is deprecated. Android CLI will be used instead. +The 'android' binary can also be found in the cmdline-tools directory, and 'android sdk' is the replacement for 'sdkmanager'. +To learn more about the Android CLI and how to use it, see the documentation (https://d.android.com/tools/agents/android-cli) +Warning: The --licenses option is no longer needed. +'''; + processManager.addCommand( + const FakeCommand(command: ['/foo/bar/sdkmanager', '--licenses'], stdout: output), + ); + + final licenseValidator = AndroidLicenseValidator( + java: FakeJava(), + androidSdk: sdk, + processManager: processManager, + platform: FakePlatform(environment: {'HOME': '/home/me'}), + stdio: stdio, + logger: BufferLogger.test(), + userMessages: UserMessages(), + ); + final LicensesAccepted result = await licenseValidator.licensesAccepted; + + expect(result, LicensesAccepted.none); + }, + ); + + testWithoutContext( + 'licensesAccepted returns unknown when the licenses directory cannot be listed (new Android CLI)', + () async { + sdk.sdkManagerPath = '/foo/bar/sdkmanager'; + // `licensesAvailable` reports true, but the licenses directory does + // not actually exist on disk (e.g. a race where it was deleted + // between the check and use), so `listSync()` throws a + // FileSystemException rather than returning results. + sdk.licensesAvailable = true; + sdk.directory = fileSystem.directory('/sdk')..createSync(recursive: true); + + const output = ''' +WARNING: The SDK Manager CLI tool (sdkmanager) is deprecated. Android CLI will be used instead. +The 'android' binary can also be found in the cmdline-tools directory, and 'android sdk' is the replacement for 'sdkmanager'. +To learn more about the Android CLI and how to use it, see the documentation (https://d.android.com/tools/agents/android-cli) +Warning: The --licenses option is no longer needed. +'''; + processManager.addCommand( + const FakeCommand(command: ['/foo/bar/sdkmanager', '--licenses'], stdout: output), + ); + + final licenseValidator = AndroidLicenseValidator( + java: FakeJava(), + androidSdk: sdk, + processManager: processManager, + platform: FakePlatform(environment: {'HOME': '/home/me'}), + stdio: stdio, + logger: BufferLogger.test(), + userMessages: UserMessages(), + ); + final LicensesAccepted result = await licenseValidator.licensesAccepted; + + expect(result, LicensesAccepted.unknown); + }, + ); + testWithoutContext('runLicenseManager succeeds for version >= 26', () async { sdk.sdkManagerPath = '/foo/bar/sdkmanager'; sdk.sdkManagerVersion = '26.0.0'; diff --git a/packages/flutter_tools/test/general.shard/android/gradle_utils_test.dart b/packages/flutter_tools/test/general.shard/android/gradle_utils_test.dart index 88f83a6094f34..53aefc775bec8 100644 --- a/packages/flutter_tools/test/general.shard/android/gradle_utils_test.dart +++ b/packages/flutter_tools/test/general.shard/android/gradle_utils_test.dart @@ -1494,9 +1494,9 @@ allprojects { expect(getGradleVersionFor('8.8'), '8.10.2'); expect(getGradleVersionFor('8.9'), '8.11.1'); expect(getGradleVersionFor('8.10'), '8.11.1'); - expect(getGradleVersionFor('8.11'), '8.13'); - expect(getGradleVersionFor('8.12'), '8.13'); - expect(getGradleVersionFor('8.13'), '8.13'); + expect(getGradleVersionFor('8.11'), '8.14'); + expect(getGradleVersionFor('8.12'), '8.14'); + expect(getGradleVersionFor('8.13'), '8.14'); expect(getGradleVersionFor('9.0.1'), '9.1.0'); expect(getGradleVersionFor('9.1.0'), '9.3.1'); }); diff --git a/packages/flutter_tools/test/general.shard/base/error_handling_io_test.dart b/packages/flutter_tools/test/general.shard/base/error_handling_io_test.dart index c0f514165de28..84fc0a012cf79 100644 --- a/packages/flutter_tools/test/general.shard/base/error_handling_io_test.dart +++ b/packages/flutter_tools/test/general.shard/base/error_handling_io_test.dart @@ -986,6 +986,9 @@ Please ensure that the SDK and/or project is installed in a location that has re const kDeviceFull = 112; const kUserMappedSectionOpened = 1224; const kUserPermissionDenied = 5; + const kApplicationControlPolicyBlocked = 4551; + const kAccessDisabledByPolicy = 1260; + const kSystemIntegrityPolicyViolation = 454; testWithoutContext( 'when PackageProcess throws an exception containing non-executable bits', @@ -1180,6 +1183,49 @@ Please ensure that the SDK and/or project is installed in a location that has re throwsToolExit(message: expectedMessage), ); }); + + for (final (name, errorCode) in const <(String, int)>[ + ('Windows Application Control policy', kApplicationControlPolicyBlocked), + ('Windows group policy', kAccessDisabledByPolicy), + ('Windows system integrity policy violation', kSystemIntegrityPolicyViolation), + ]) { + testWithoutContext('when blocked by $name', () { + final fakeProcessManager = FakeProcessManager.list([ + FakeCommand( + command: const ['foo'], + exception: ProcessException('foo', const [], 'Blocked', errorCode), + ), + FakeCommand( + command: const ['foo'], + exception: ProcessException('foo', const [], 'Blocked', errorCode), + ), + FakeCommand( + command: const ['foo'], + exception: ProcessException('foo', const [], 'Blocked', errorCode), + ), + ]); + + final ProcessManager processManager = createProcessManager( + delegate: fakeProcessManager, + platform: windowsPlatform, + ); + + const expectedMessage = + 'An Application Control policy or security policy has blocked execution.'; + expect( + () async => processManager.start(['foo']), + throwsToolExit(message: expectedMessage), + ); + expect( + () async => processManager.run(['foo']), + throwsToolExit(message: expectedMessage), + ); + expect( + () => processManager.runSync(['foo']), + throwsToolExit(message: expectedMessage), + ); + }); + } }); group('ProcessManager on linux throws tool exit', () { diff --git a/packages/flutter_tools/test/general.shard/build_info_test.dart b/packages/flutter_tools/test/general.shard/build_info_test.dart index 0a04f6092df9f..aa53589470283 100644 --- a/packages/flutter_tools/test/general.shard/build_info_test.dart +++ b/packages/flutter_tools/test/general.shard/build_info_test.dart @@ -289,6 +289,20 @@ void main() { }); }); + testWithoutContext('toEnvironmentConfig includes build name and build number', () { + const buildInfo = BuildInfo( + BuildMode.release, + null, + buildName: '4.5.6', + buildNumber: '7', + treeShakeIcons: false, + packageConfigPath: 'foo/.dart_tool/package_config.json', + ); + + expect(buildInfo.toEnvironmentConfig()['BUILD_NAME'], '4.5.6'); + expect(buildInfo.toEnvironmentConfig()['BUILD_NUMBER'], '7'); + }); + testWithoutContext('toGradleConfig encoding of standard values', () { const buildInfo = BuildInfo( BuildMode.debug, diff --git a/packages/flutter_tools/test/general.shard/build_system/targets/shader_compiler_test.dart b/packages/flutter_tools/test/general.shard/build_system/targets/shader_compiler_test.dart index 94dda581a3f96..fdf19b4d18fa6 100644 --- a/packages/flutter_tools/test/general.shard/build_system/targets/shader_compiler_test.dart +++ b/packages/flutter_tools/test/general.shard/build_system/targets/shader_compiler_test.dart @@ -244,6 +244,8 @@ void main() { '"/output/shaders/my_shader.frag" failed with exit code 1.', ), ); + expect(e.stdout, 'impellerc stdout'); + expect(e.stderr, 'impellerc stderr'); } expect(fileSystem.file(outputPath).existsSync(), false); @@ -1034,4 +1036,220 @@ void main() { expect(headerLine.allMatches(logger.errorText).length, 2); }); }); + + group('ShaderCompiler hints and diagnostics', () { + Future expectShaderCompilerException({ + required ShaderCompiler shaderCompiler, + required String inputPath, + required String outputPath, + required List matchers, + }) async { + await expectLater( + shaderCompiler.compileShader( + input: fileSystem.file(inputPath), + outputPath: outputPath, + targetPlatform: TargetPlatform.web_javascript, + ), + throwsA( + isA().having( + (ShaderCompilerException e) => e.toString(), + 'toString()', + allOf(matchers), + ), + ), + ); + } + + testWithoutContext('macOS and exit code -9 adds Gatekeeper/OOM hint', () async { + final processManager = FakeProcessManager.list([ + FakeCommand( + command: [ + impellerc, + '--sksl', + '--iplr', + '--json', + '--sl=$outputPath', + '--spirv=$outputSpirvPath', + '--input=$notFragPath', + '--input-type=frag', + '--include=$fragDir', + '--include=$shaderLibDir', + ], + exitCode: -9, + ), + ]); + final shaderCompiler = ShaderCompiler( + processManager: processManager, + logger: logger, + fileSystem: fileSystem, + artifacts: artifacts, + platform: FakePlatform(operatingSystem: 'macos'), + ); + + await expectShaderCompilerException( + shaderCompiler: shaderCompiler, + inputPath: notFragPath, + outputPath: outputPath, + matchers: [ + contains('blocked by macOS Gatekeeper or run out of memory (OOM)'), + contains('xattr -d com.apple.quarantine'), + ], + ); + }); + + testWithoutContext('macOS and exit code -6 adds abort hint', () async { + final processManager = FakeProcessManager.list([ + FakeCommand( + command: [ + impellerc, + '--sksl', + '--iplr', + '--json', + '--sl=$outputPath', + '--spirv=$outputSpirvPath', + '--input=$notFragPath', + '--input-type=frag', + '--include=$fragDir', + '--include=$shaderLibDir', + ], + exitCode: -6, + ), + ]); + final shaderCompiler = ShaderCompiler( + processManager: processManager, + logger: logger, + fileSystem: fileSystem, + artifacts: artifacts, + platform: FakePlatform(operatingSystem: 'macos'), + ); + + await expectShaderCompilerException( + shaderCompiler: shaderCompiler, + inputPath: notFragPath, + outputPath: outputPath, + matchers: [ + contains('The shader compiler (impellerc) aborted during compilation.'), + ], + ); + }); + + testWithoutContext('Linux and exit code -6 adds abort hint', () async { + final processManager = FakeProcessManager.list([ + FakeCommand( + command: [ + impellerc, + '--sksl', + '--iplr', + '--json', + '--sl=$outputPath', + '--spirv=$outputSpirvPath', + '--input=$notFragPath', + '--input-type=frag', + '--include=$fragDir', + '--include=$shaderLibDir', + ], + exitCode: -6, + ), + ]); + final shaderCompiler = ShaderCompiler( + processManager: processManager, + logger: logger, + fileSystem: fileSystem, + artifacts: artifacts, + platform: FakePlatform(), + ); + + await expectShaderCompilerException( + shaderCompiler: shaderCompiler, + inputPath: notFragPath, + outputPath: outputPath, + matchers: [ + contains('The shader compiler (impellerc) aborted during compilation.'), + ], + ); + }); + + testWithoutContext('Windows and exit code 3 adds abort hint', () async { + final processManager = FakeProcessManager.list([ + FakeCommand( + command: [ + impellerc, + '--sksl', + '--iplr', + '--json', + '--sl=$outputPath', + '--spirv=$outputSpirvPath', + '--input=$notFragPath', + '--input-type=frag', + '--include=$fragDir', + '--include=$shaderLibDir', + ], + exitCode: 3, + ), + ]); + final shaderCompiler = ShaderCompiler( + processManager: processManager, + logger: logger, + fileSystem: fileSystem, + artifacts: artifacts, + platform: FakePlatform(operatingSystem: 'windows'), + ); + + await expectShaderCompilerException( + shaderCompiler: shaderCompiler, + inputPath: notFragPath, + outputPath: outputPath, + matchers: [ + contains('The shader compiler (impellerc) aborted during compilation.'), + isNot(contains('Warning: The path contains non-ASCII characters')), + ], + ); + }); + + testWithoutContext( + 'Windows and exit code 3 with Unicode path adds Unicode path warning ' + '(regression test for https://github.com/flutter/flutter/issues/190233)', + () async { + const unicodeFragPath = '/shaders/my_shåder.frag'; + const unicodeOutputPath = '/output/shaders/my_shåder.frag'; + const unicodeOutputSpirvPath = '/output/shaders/my_shåder.frag.spirv'; + fileSystem.file(unicodeFragPath).createSync(recursive: true); + + final processManager = FakeProcessManager.list([ + FakeCommand( + command: [ + impellerc, + '--sksl', + '--iplr', + '--json', + '--sl=$unicodeOutputPath', + '--spirv=$unicodeOutputSpirvPath', + '--input=$unicodeFragPath', + '--input-type=frag', + '--include=$fragDir', + '--include=$shaderLibDir', + ], + exitCode: 3, + ), + ]); + final shaderCompiler = ShaderCompiler( + processManager: processManager, + logger: logger, + fileSystem: fileSystem, + artifacts: artifacts, + platform: FakePlatform(operatingSystem: 'windows'), + ); + + await expectShaderCompilerException( + shaderCompiler: shaderCompiler, + inputPath: unicodeFragPath, + outputPath: unicodeOutputPath, + matchers: [ + contains('The shader compiler (impellerc) aborted during compilation.'), + contains('Warning: The path contains non-ASCII characters'), + ], + ); + }, + ); + }); } diff --git a/packages/flutter_tools/test/general.shard/build_system/targets/web_dry_run_test.dart b/packages/flutter_tools/test/general.shard/build_system/targets/web_dry_run_test.dart index 10f17402ba58f..7e4bec8e12213 100644 --- a/packages/flutter_tools/test/general.shard/build_system/targets/web_dry_run_test.dart +++ b/packages/flutter_tools/test/general.shard/build_system/targets/web_dry_run_test.dart @@ -97,7 +97,6 @@ name: my_app '-DFLUTTER_WEB_CANVASKIT_URL=https://www.gstatic.com/flutter-canvaskit/abcdefghijklmnopqrstuvwxyz/', '--extra-compiler-option=--depfile=${environment.buildDir.childFile('dart2wasm.d').path}', '--recorded-uses=${environment.buildDir.childFile('recorded_uses_wasm.json').path}', - '--enable-experiment=record-use', '-O0', '--no-strip-wasm', '--no-minify', diff --git a/packages/flutter_tools/test/general.shard/build_system/targets/web_test.dart b/packages/flutter_tools/test/general.shard/build_system/targets/web_test.dart index 4706115f0deb3..4206ac8d5f375 100644 --- a/packages/flutter_tools/test/general.shard/build_system/targets/web_test.dart +++ b/packages/flutter_tools/test/general.shard/build_system/targets/web_test.dart @@ -40,7 +40,6 @@ const _kStandardFlutterWebDefines = [ '-DFLUTTER_WEB_USE_SKWASM=false', '-DFLUTTER_WEB_CANVASKIT_URL=https://www.gstatic.com/flutter-canvaskit/abcdefghijklmnopqrstuvwxyz/', '--write-resources', - '--enable-experiment=record-use', ]; const _kDart2WasmLinuxArgs = [ @@ -134,6 +133,31 @@ name: foo ), ); + test( + 'WebEntrypointTarget declares package_config.json, pubspec.yaml, and plugin dependencies as inputs', + () => testbed.run(() async { + const target = WebEntrypointTarget(); + expect( + target.inputs, + equals([ + const Source.pattern( + '{FLUTTER_ROOT}/packages/flutter_tools/lib/src/build_system/targets/web.dart', + ), + const Source.pattern('{WORKSPACE_DIR}/.dart_tool/package_config.json'), + const Source.pattern('{PROJECT_DIR}/pubspec.yaml'), + const Source.pattern('{PROJECT_DIR}/.flutter-plugins-dependencies', optional: true), + ]), + ); + expect( + target.outputs, + equals([ + const Source.pattern('{BUILD_DIR}/main.dart'), + const Source.pattern('{BUILD_DIR}/web_plugin_registrant.dart'), + ]), + ); + }), + ); + test( 'version.json is created after release build', () => testbed.run(() async { @@ -1341,7 +1365,6 @@ _flutter.loader.load(); '-DFLUTTER_WEB_CANVASKIT_URL=https://www.gstatic.com/flutter-canvaskit/abcdefghijklmnopqrstuvwxyz/', '--extra-compiler-option=--depfile=${depFile.absolute.path}', '--recorded-uses=${environment.buildDir.childFile('recorded_uses_wasm.json').absolute.path}', - '--enable-experiment=record-use', '-O$expectedLevel', if (strip && buildMode == 'release') '--strip-wasm' diff --git a/packages/flutter_tools/test/general.shard/compile_incremental_test.dart b/packages/flutter_tools/test/general.shard/compile_incremental_test.dart index c773fbfd44acc..a05c0b4c3dd91 100644 --- a/packages/flutter_tools/test/general.shard/compile_incremental_test.dart +++ b/packages/flutter_tools/test/general.shard/compile_incremental_test.dart @@ -131,6 +131,62 @@ void main() { expect(fakeProcessManager, hasNoRemainingExpectations); }); + testWithoutContext( + 'incremental compile sends correct package URI for pub workspace member package located under root lib/', + () async { + final packageConfig = PackageConfig([ + Package( + 'root', + Uri.parse('file:///workspace/'), + packageUriRoot: Uri.parse('file:///workspace/lib/'), + ), + Package( + 'member', + Uri.parse('file:///workspace/lib/member/'), + packageUriRoot: Uri.parse('file:///workspace/lib/member/lib/'), + ), + ]); + + fakeProcessManager.addCommand( + FakeCommand( + command: const [ + ...frontendServerCommand, + '--initialize-from-dill', + expectedCachePath, + '--verbosity=error', + ], + stdout: 'result abc\nline1\nline2\nabc\nabc /path/to/main.dart.dill 0', + stdin: frontendServerStdIn, + ), + ); + + await generator.recompile( + Uri.parse('file:///workspace/lib/main.dart'), + null, + outputPath: '/build/', + packageConfig: packageConfig, + fs: MemoryFileSystem(), + projectRootPath: '', + ); + expect(frontendServerStdIn.getAndClear(), 'compile package:root/main.dart\n'); + + await _accept(generator, frontendServerStdIn, ''); + await _reject(generatorStdoutHandler, generator, frontendServerStdIn, '', ''); + + await _recompile( + generatorStdoutHandler, + generator, + frontendServerStdIn, + 'result abc\nline1\nline2\nabc\nabc /path/to/main.dart.dill 0\n', + mainUri: Uri.parse('file:///workspace/lib/main.dart'), + expectedMainUri: 'package:root/main.dart', + updatedUris: [Uri.parse('file:///workspace/lib/member/lib/foo.dart')], + expectedUpdatedUris: ['package:member/foo.dart'], + packageConfig: packageConfig, + ); + }, + ); + testWithoutContext('incremental compile single dart compile with filesystem scheme', () async { fakeProcessManager.addCommand( FakeCommand( @@ -648,6 +704,7 @@ Future _recompile( String expectedMainUri = '/path/to/main.dart', List? updatedUris, List? expectedUpdatedUris, + PackageConfig? packageConfig, }) async { mainUri ??= Uri.parse('/path/to/main.dart'); updatedUris ??= [mainUri]; @@ -657,7 +714,7 @@ Future _recompile( mainUri, updatedUris, outputPath: '/build/', - packageConfig: PackageConfig.empty, + packageConfig: packageConfig ?? PackageConfig.empty, suppressErrors: suppressErrors, fs: MemoryFileSystem(), projectRootPath: '', diff --git a/packages/flutter_tools/test/general.shard/crash_reporting_test.dart b/packages/flutter_tools/test/general.shard/crash_reporting_test.dart index 245355f38d6f2..37568c46c4abf 100644 --- a/packages/flutter_tools/test/general.shard/crash_reporting_test.dart +++ b/packages/flutter_tools/test/general.shard/crash_reporting_test.dart @@ -152,6 +152,30 @@ void main() { await verifyCrashReportSent(requestInfo); }); + testWithoutContext('should send crash reports with custom typeId', () async { + final requestInfo = RequestInfo(); + + final crashReportSender = CrashReportSender( + client: FakeCrashReportSender(requestInfo), + platform: platform, + logger: logger, + operatingSystemUtils: operatingSystemUtils, + analytics: fakeAnalytics, + ); + + await crashReportSender.sendReport( + error: StateError('Test bad state error'), + stackTrace: stackTrace, + getFlutterVersion: () => 'test-version', + command: 'crash', + typeId: 'XcodeBuildError', + ); + + expect(requestInfo.fields?['type'], 'XcodeBuildError'); + expect(logger.traceText, contains('Sending crash report to Google.')); + expect(logger.traceText, contains('Crash report sent (report ID: test-report-id)')); + }); + testWithoutContext( 'should print an explanatory message when there is a SocketException', () async { diff --git a/packages/flutter_tools/test/general.shard/custom_devices/custom_device_test.dart b/packages/flutter_tools/test/general.shard/custom_devices/custom_device_test.dart index 51790a14af8e8..18c652f1596ad 100644 --- a/packages/flutter_tools/test/general.shard/custom_devices/custom_device_test.dart +++ b/packages/flutter_tools/test/general.shard/custom_devices/custom_device_test.dart @@ -8,6 +8,7 @@ import 'package:file/file.dart'; 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/io.dart'; import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/build_system/build_system.dart'; @@ -297,6 +298,58 @@ void main() { }, ); + testWithoutContext( + "CustomDevices.discoverDevices doesn't report device and does not throw when ping command times out", + () async { + final fs = MemoryFileSystem.test(); + final Directory dir = fs.directory('custom_devices_config_dir'); + + _writeCustomDevicesConfigFile(dir, [testConfig]); + + final logger = BufferLogger.test(); + final discovery = CustomDevices( + featureFlags: TestFeatureFlags(areCustomDevicesEnabled: true), + logger: logger, + processManager: FakeProcessManager.list([ + FakeCommand( + command: testConfig.pingCommand, + exception: const ProcessException('testping', [], 'Process timed out'), + ), + ]), + config: CustomDevicesConfig.test(fileSystem: fs, directory: dir, logger: logger), + ); + + expect(await discovery.discoverDevices(), hasLength(0)); + expect( + logger.traceText, + contains('Error pinging custom device testid: ProcessException: Process timed out'), + ); + }, + ); + + testWithoutContext( + 'CustomDevice.tryPing returns false and logs trace when ping command times out', + () async { + final logger = BufferLogger.test(); + final device = CustomDevice( + config: testConfig, + logger: logger, + processManager: FakeProcessManager.list([ + FakeCommand( + command: testConfig.pingCommand, + exception: const ProcessException('testping', [], 'Process timed out'), + ), + ]), + ); + + expect(await device.tryPing(), isFalse); + expect( + logger.traceText, + contains('Error pinging custom device testid: ProcessException: Process timed out'), + ); + }, + ); + testWithoutContext('CustomDevice.isSupportedForProject is true with editable host app', () async { final fileSystem = MemoryFileSystem.test(); fileSystem.file('pubspec.yaml').createSync(); diff --git a/packages/flutter_tools/test/general.shard/dart/package_map_test.dart b/packages/flutter_tools/test/general.shard/dart/package_map_test.dart index 3f695c9990014..bbee6d58eb4af 100644 --- a/packages/flutter_tools/test/general.shard/dart/package_map_test.dart +++ b/packages/flutter_tools/test/general.shard/dart/package_map_test.dart @@ -135,4 +135,36 @@ void main() { }, ); }); + + group('PackageConfigWorkspaceExtension', () { + testWithoutContext('toPackageUriForWorkspace finds most specific package in pub workspace', () { + final packageConfig = PackageConfig([ + Package( + 'root', + Uri.parse('file:///workspace/'), + packageUriRoot: Uri.parse('file:///workspace/lib/'), + ), + Package( + 'member', + Uri.parse('file:///workspace/lib/member/'), + packageUriRoot: Uri.parse('file:///workspace/lib/member/lib/'), + ), + ]); + + expect( + packageConfig.toPackageUriForWorkspace(Uri.parse('file:///workspace/lib/foo.dart')), + Uri.parse('package:root/foo.dart'), + ); + expect( + packageConfig.toPackageUriForWorkspace( + Uri.parse('file:///workspace/lib/member/lib/bar.dart'), + ), + Uri.parse('package:member/bar.dart'), + ); + expect( + packageConfig.toPackageUriForWorkspace(Uri.parse('package:member/bar.dart')), + Uri.parse('package:member/bar.dart'), + ); + }); + }); } diff --git a/packages/flutter_tools/test/general.shard/flutter_platform_test.dart b/packages/flutter_tools/test/general.shard/flutter_platform_test.dart index 5a3e16f017690..eeb66864e4a79 100644 --- a/packages/flutter_tools/test/general.shard/flutter_platform_test.dart +++ b/packages/flutter_tools/test/general.shard/flutter_platform_test.dart @@ -246,6 +246,48 @@ void main() { expect(flutterPlatform.testAssetDirectory, '/build/test'); expect(flutterPlatform.icudtlPath, equals('ghi')); }); + + testUsingContext('pipeHarnessToRemote safely ignores non-JSON string and logs warning', () async { + final harnessController = StreamChannelController(); + final remoteController = StreamChannelController(); + + final Future pipeFuture = pipeHarnessToRemote( + id: 0, + harnessChannel: harnessController.foreign, + remoteChannel: remoteController.foreign, + ); + + final receivedFromRemote = []; + harnessController.local.stream.listen(receivedFromRemote.add); + + // Send non-JSON error string from remote channel followed by valid JSON. + remoteController.local.sink.add( + 'Loading dynamic library failed: dlopen(/opt/homebrew/share/flutter/bin/cache/libflutter.dylib)', + ); + remoteController.local.sink.add('{"valid": true}'); + + await pumpEventQueue(); + + await remoteController.local.sink.close(); + await harnessController.local.sink.close(); + + await pipeFuture; + + expect( + receivedFromRemote, + equals([ + {'valid': true}, + ]), + ); + final logger = globals.logger as BufferLogger; + expect( + logger.warningText, + contains( + 'Received unexpected non-JSON output from test runner: Loading dynamic library failed: dlopen', + ), + ); + expect(logger.traceText, contains('test 0: JSON decoding failed:')); + }); }); group('generateTestBootstrap', () { 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 f73621ed93bd9..8b667e8ab668a 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 @@ -19,6 +19,7 @@ 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/device_support.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'; @@ -91,6 +92,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: fakeLLDB, ); @@ -118,6 +120,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), ); final bool result = await launcher.launchAppWithoutDebugger( @@ -147,6 +150,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), ); final bool result = await launcher.launchAppWithoutDebugger( @@ -172,6 +176,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), ); final bool result = await launcher.launchAppWithoutDebugger( @@ -220,11 +225,13 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: fakeLLDB, ); final bool result = await launcher.launchAppWithLLDBDebugger( deviceId: 'device-id', + deviceSupport: createDeviceSupport(), bundlePath: 'bundle-path', bundleId: 'bundle-id', launchArguments: [], @@ -236,6 +243,128 @@ void main() { expect(fakeLLDB.attemptedToAttach, isTrue); }); + testWithoutContext('registers shutdown hook to stop app', () async { + final fakeCoreDeviceControl = FakeIOSCoreDeviceControl( + installResult: IOSCoreDeviceInstallResult.fromJson(const { + 'info': {'outcome': 'success'}, + 'result': { + 'installedApplications': [ + {'installationURL': '/asdf'}, + ], + }, + }), + launchResult: IOSCoreDeviceLaunchResult.fromJson(const { + 'info': {'outcome': 'success'}, + 'result': { + 'process': {'processIdentifier': 123}, + }, + }), + runningProcesses: [ + IOSCoreDeviceRunningProcess.fromJson(const { + 'processIdentifier': 123, + 'executable': '/asdf', + }), + ], + ); + final processManager = FakeProcessManager.any(); + final logger = BufferLogger.test(); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + final fakeLLDB = FakeLLDB(); + fakeLLDB.setIsRunning(true, 123); + final launcher = IOSCoreDeviceLauncher( + coreDeviceControl: fakeCoreDeviceControl, + logger: logger, + xcodeDebug: FakeXcodeDebug(), + fileSystem: MemoryFileSystem.test(), + processUtils: processUtils, + xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), + lldb: fakeLLDB, + ); + final shutdownHooks = FakeShutdownHooks(); + + final bool result = await launcher.launchAppWithLLDBDebugger( + deviceId: 'device-id', + deviceSupport: createDeviceSupport( + operatingSystemVersion: '17.0', + modelCode: 'iPhone15,2', + cpuArchitectureString: 'arm64e', + ), + bundlePath: 'bundle-path', + bundleId: 'bundle-id', + launchArguments: [], + shutdownHooks: shutdownHooks, + mode: BuildMode.debug, + ); + + expect(result, isTrue); + expect(shutdownHooks.registeredHooks, hasLength(1)); + await shutdownHooks.registeredHooks.first(); + expect(fakeCoreDeviceControl.terminateProcessCalled, isTrue); + expect(fakeCoreDeviceControl.processTerminated, 123); + expect(fakeLLDB.exitCalled, isTrue); + }); + + testWithoutContext('shutdown hook catches Exception thrown by stopApp', () async { + final fakeCoreDeviceControl = FakeIOSCoreDeviceControl( + installResult: IOSCoreDeviceInstallResult.fromJson(const { + 'info': {'outcome': 'success'}, + 'result': { + 'installedApplications': [ + {'installationURL': '/asdf'}, + ], + }, + }), + launchResult: IOSCoreDeviceLaunchResult.fromJson(const { + 'info': {'outcome': 'success'}, + 'result': { + 'process': {'processIdentifier': 123}, + }, + }), + runningProcesses: [ + IOSCoreDeviceRunningProcess.fromJson(const { + 'processIdentifier': 123, + 'executable': '/asdf', + }), + ], + ); + fakeCoreDeviceControl.terminateException = Exception('failed to terminate'); + final processManager = FakeProcessManager.any(); + final logger = BufferLogger.test(); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + final fakeLLDB = FakeLLDB(); + fakeLLDB.setIsRunning(true, 123); + final launcher = IOSCoreDeviceLauncher( + coreDeviceControl: fakeCoreDeviceControl, + logger: logger, + xcodeDebug: FakeXcodeDebug(), + fileSystem: MemoryFileSystem.test(), + processUtils: processUtils, + xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), + lldb: fakeLLDB, + ); + final shutdownHooks = FakeShutdownHooks(); + + final bool result = await launcher.launchAppWithLLDBDebugger( + deviceId: 'device-id', + deviceSupport: createDeviceSupport( + operatingSystemVersion: '17.0', + modelCode: 'iPhone15,2', + cpuArchitectureString: 'arm64e', + ), + bundlePath: 'bundle-path', + bundleId: 'bundle-id', + launchArguments: [], + shutdownHooks: shutdownHooks, + mode: BuildMode.debug, + ); + + expect(result, isTrue); + expect(shutdownHooks.registeredHooks, hasLength(1)); + await expectLater(shutdownHooks.registeredHooks.first(), completes); + }); + testWithoutContext('ignores app extension processes', () async { final fakeCoreDeviceControl = FakeIOSCoreDeviceControl( installResult: IOSCoreDeviceInstallResult.fromJson(const { @@ -280,11 +409,13 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: fakeLLDB, ); final bool result = await launcher.launchAppWithLLDBDebugger( deviceId: 'device-id', + deviceSupport: createDeviceSupport(), bundlePath: 'bundle-path', bundleId: 'bundle-id', launchArguments: [], @@ -333,11 +464,13 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: fakeLLDB, ); final bool result = await launcher.launchAppWithLLDBDebugger( deviceId: 'device-id', + deviceSupport: createDeviceSupport(), bundlePath: 'bundle-path', bundleId: 'bundle-id', launchArguments: [], @@ -379,11 +512,13 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: fakeLLDB, ); final bool result = await launcher.launchAppWithLLDBDebugger( deviceId: 'device-id', + deviceSupport: createDeviceSupport(), bundlePath: 'bundle-path', bundleId: 'bundle-id', launchArguments: [], @@ -431,11 +566,13 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: fakeLLDB, ); final bool result = await launcher.launchAppWithLLDBDebugger( deviceId: 'device-id', + deviceSupport: createDeviceSupport(), bundlePath: 'bundle-path', bundleId: 'bundle-id', launchArguments: [], @@ -477,11 +614,13 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: fakeLLDB, ); final bool result = await launcher.launchAppWithLLDBDebugger( deviceId: 'device-id', + deviceSupport: createDeviceSupport(), bundlePath: 'bundle-path', bundleId: 'bundle-id', launchArguments: [], @@ -525,11 +664,13 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: fakeLLDB, ); final bool result = await launcher.launchAppWithLLDBDebugger( deviceId: 'device-id', + deviceSupport: createDeviceSupport(), bundlePath: 'bundle-path', bundleId: 'bundle-id', launchArguments: [], @@ -575,11 +716,13 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: fakeLLDB, ); final bool result = await launcher.launchAppWithLLDBDebugger( deviceId: 'device-id', + deviceSupport: createDeviceSupport(), bundlePath: 'bundle-path', bundleId: 'bundle-id', launchArguments: [], @@ -612,6 +755,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: FakeLLDB(), ); final bool result = await launcher.launchAppWithXcodeDebugger( @@ -646,6 +790,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: FakeLLDB(), ); final bool result = await launcher.launchAppWithXcodeDebugger( @@ -680,6 +825,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: FakeLLDB(), ); final bool result = await launcher.launchAppWithXcodeDebugger( @@ -714,6 +860,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: FakeLLDB(), ); final bool result = await launcher.launchAppWithXcodeDebugger( @@ -747,6 +894,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: fakeLLDB, ); @@ -777,6 +925,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: fakeLLDB, ); @@ -806,6 +955,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: fakeLLDB, ); @@ -834,6 +984,7 @@ void main() { fileSystem: MemoryFileSystem.test(), processUtils: processUtils, xcodeProjectInterpreter: XcodeProjectInterpreter.test(processManager: processManager), + deviceVersion: Version(16, 0, 0), lldb: fakeLLDB, ); @@ -3905,6 +4056,7 @@ class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl { bool launchSuccess; IOSCoreDeviceInstallResult? installResult; bool terminateSuccess; + Exception? terminateException; int? processTerminated; List runningProcesses; bool get terminateProcessCalled => processTerminated != null; @@ -3956,6 +4108,9 @@ class FakeIOSCoreDeviceControl extends Fake implements IOSCoreDeviceControl { @override Future terminateProcess({required String deviceId, required int processId}) async { processTerminated = processId; + if (terminateException != null) { + throw terminateException!; + } return terminateSuccess; } @@ -4056,6 +4211,7 @@ class FakeLLDB extends Fake implements LLDB { required int appProcessId, required LLDBLogForwarder lldbLogForwarder, required BuildMode mode, + required IOSDeviceSupport deviceSupport, }) async { attemptedToAttach = true; attachedProcessId = appProcessId; @@ -4173,3 +4329,27 @@ class FakeShutdownHooks extends Fake implements ShutdownHooks { _isShuttingDown = true; } } + +IOSDeviceSupport createDeviceSupport({ + Logger? logger, + ProcessUtils? processUtils, + Directory? homeDirectory, + String? modelCode, + String? operatingSystemVersion, + String? cpuArchitectureString, + String deviceId = 'device-id', +}) { + final Logger testLogger = logger ?? BufferLogger.test(); + return IOSDeviceSupport( + logger: testLogger, + processUtils: + processUtils ?? + ProcessUtils(processManager: FakeProcessManager.empty(), logger: testLogger), + xcode: null, + homeDirectory: homeDirectory, + modelCode: modelCode, + operatingSystemVersion: operatingSystemVersion, + cpuArchitectureString: cpuArchitectureString, + deviceId: deviceId, + ); +} diff --git a/packages/flutter_tools/test/general.shard/ios/device_support_test.dart b/packages/flutter_tools/test/general.shard/ios/device_support_test.dart new file mode 100644 index 0000000000000..a86724e799487 --- /dev/null +++ b/packages/flutter_tools/test/general.shard/ios/device_support_test.dart @@ -0,0 +1,847 @@ +// 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:fake_async/fake_async.dart'; +import 'package:file/file.dart'; +import 'package:file/memory.dart'; +import 'package:flutter_tools/src/base/logger.dart'; +import 'package:flutter_tools/src/base/process.dart'; +import 'package:flutter_tools/src/base/version.dart'; +import 'package:flutter_tools/src/ios/device_support.dart'; +import 'package:flutter_tools/src/macos/xcode.dart'; +import 'package:test/fake.dart'; + +import '../../src/common.dart'; +import '../../src/fake_process_manager.dart'; + +void main() { + group('IOSDeviceSupport', () { + testWithoutContext('does nothing when Xcode is null', () async { + final processManager = FakeProcessManager.empty(); + final logger = BufferLogger.test(); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + + final deviceSupport = IOSDeviceSupport( + logger: logger, + processUtils: processUtils, + xcode: null, + homeDirectory: null, + modelCode: null, + operatingSystemVersion: null, + cpuArchitectureString: null, + deviceId: 'id-123', + ); + + await deviceSupport.prepareDeviceSupport(); + + expect(processManager, hasNoRemainingExpectations); + expect(logger.statusText, isEmpty); + expect(logger.traceText, isEmpty); + expect(logger.errorText, isEmpty); + }); + + testWithoutContext('does nothing when Xcode version is less than 16.3.0', () async { + final processManager = FakeProcessManager.empty(); + final logger = BufferLogger.test(); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + + final deviceSupport = IOSDeviceSupport( + logger: logger, + processUtils: processUtils, + xcode: FakeXcode(currentVersion: Version(16, 2, 0)), + homeDirectory: null, + modelCode: null, + operatingSystemVersion: null, + cpuArchitectureString: null, + deviceId: 'id-123', + ); + + await deviceSupport.prepareDeviceSupport(); + + expect(processManager, hasNoRemainingExpectations); + expect(logger.statusText, isEmpty); + expect(logger.traceText, isEmpty); + expect(logger.errorText, isEmpty); + }); + + testWithoutContext('does nothing when existingDeviceSupportSymbols is non-null', () async { + final FileSystem fileSystem = MemoryFileSystem.test(); + final Directory homeDir = fileSystem.directory('/Users/username'); + final Directory supportDir = homeDir + .childDirectory('Library') + .childDirectory('Developer') + .childDirectory('Xcode') + .childDirectory('iOS DeviceSupport'); + supportDir + .childDirectory('iPhone15,2 17.0') + .childDirectory('Symbols') + .createSync(recursive: true); + + final processManager = FakeProcessManager.empty(); + final logger = BufferLogger.test(); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + + final deviceSupport = IOSDeviceSupport( + logger: logger, + processUtils: processUtils, + xcode: FakeXcode(currentVersion: Version(16, 3, 0)), + homeDirectory: homeDir, + modelCode: 'iPhone15,2', + operatingSystemVersion: '17.0', + cpuArchitectureString: 'arm64e', + deviceId: 'id-123', + ); + + await deviceSupport.prepareDeviceSupport(); + + expect(processManager, hasNoRemainingExpectations); + expect(logger.statusText, isEmpty); + expect(logger.traceText, isEmpty); + expect(logger.errorText, isEmpty); + }); + + testWithoutContext( + 'runs prepareDeviceSupport and logs stdout to trace when Copying is not present', + () async { + final processManager = FakeProcessManager.empty(); + final logger = BufferLogger.test(); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + + processManager.addCommand( + const FakeCommand( + command: [ + 'xcrun', + 'xcodebuild', + '-prepareDeviceSupport', + '-destination', + 'id=id-123', + ], + stdout: 'Preparing device support...', + ), + ); + + final deviceSupport = IOSDeviceSupport( + logger: logger, + processUtils: processUtils, + xcode: FakeXcode(currentVersion: Version(16, 3, 0)), + homeDirectory: null, + modelCode: null, + operatingSystemVersion: null, + cpuArchitectureString: null, + deviceId: 'id-123', + ); + + await deviceSupport.prepareDeviceSupport(); + + expect(processManager, hasNoRemainingExpectations); + expect(logger.traceText, contains('Preparing device support...')); + expect(logger.statusText, isEmpty); + expect(logger.errorText, isEmpty); + }, + ); + + testWithoutContext('runs prepareDeviceSupport and logs Copying messages to status', () async { + final processManager = FakeProcessManager.empty(); + final logger = BufferLogger.test(); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + + processManager.addCommand( + const FakeCommand( + command: [ + 'xcrun', + 'xcodebuild', + '-prepareDeviceSupport', + '-destination', + 'id=id-123', + ], + stdout: 'Copying symbols...', + ), + ); + + final deviceSupport = IOSDeviceSupport( + logger: logger, + processUtils: processUtils, + xcode: FakeXcode(currentVersion: Version(16, 3, 0)), + homeDirectory: null, + modelCode: null, + operatingSystemVersion: null, + cpuArchitectureString: null, + deviceId: 'id-123', + ); + + await deviceSupport.prepareDeviceSupport(); + + expect(processManager, hasNoRemainingExpectations); + expect( + logger.statusText, + contains( + 'Copying Device Support symbols. This may take several minutes to complete...\n' + 'Please do not connect or disconnect your device or open Xcode until finished.', + ), + ); + expect(logger.statusText, contains('Copying symbols...')); + expect(logger.statusText, endsWith('\n')); + expect( + logger.traceText, + 'executing: xcrun xcodebuild -prepareDeviceSupport -destination id=id-123\n', + ); + expect(logger.errorText, isEmpty); + }); + + testWithoutContext('runs prepareDeviceSupport and logs stderr to error', () async { + final processManager = FakeProcessManager.empty(); + final logger = BufferLogger.test(); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + + processManager.addCommand( + const FakeCommand( + command: [ + 'xcrun', + 'xcodebuild', + '-prepareDeviceSupport', + '-destination', + 'id=id-123', + ], + stderr: 'Error occurred', + ), + ); + + final deviceSupport = IOSDeviceSupport( + logger: logger, + processUtils: processUtils, + xcode: FakeXcode(currentVersion: Version(17, 0, 0)), + homeDirectory: null, + modelCode: null, + operatingSystemVersion: null, + cpuArchitectureString: null, + deviceId: 'id-123', + ); + + await deviceSupport.prepareDeviceSupport(); + + expect(processManager, hasNoRemainingExpectations); + expect(logger.errorText, contains('Error occurred')); + }); + + testWithoutContext( + 'prints error message when prepareDeviceSupport takes longer than 10 seconds', + () { + FakeAsync().run((fakeAsync) { + final processManager = FakeProcessManager.empty(); + final logger = BufferLogger.test(); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + + processManager.addCommand( + const FakeCommand( + command: [ + 'xcrun', + 'xcodebuild', + '-prepareDeviceSupport', + '-destination', + 'id=id-123', + ], + duration: Duration(seconds: 11), + ), + ); + + final deviceSupport = IOSDeviceSupport( + logger: logger, + processUtils: processUtils, + xcode: FakeXcode(currentVersion: Version(16, 3, 0)), + homeDirectory: null, + modelCode: null, + operatingSystemVersion: null, + cpuArchitectureString: null, + deviceId: 'id-123', + ); + + deviceSupport.prepareDeviceSupport(); + + fakeAsync.elapse(const Duration(seconds: 10)); + + expect( + logger.errorText, + contains( + 'Xcode is taking longer than expected to start preparing Device Support symbols...\n' + 'Try closing Xcode, connecting your device via USB, and running the following command:\n' + ' "xcrun xcodebuild -prepareDeviceSupport -destination id=id-123"', + ), + ); + + fakeAsync.elapse(const Duration(seconds: 1)); + }); + }, + ); + + testWithoutContext('does not print error message if Copying is printed within 10 seconds', () { + FakeAsync().run((fakeAsync) { + final processManager = FakeProcessManager.empty(); + final logger = BufferLogger.test(); + final processUtils = ProcessUtils(processManager: processManager, logger: logger); + + processManager.addCommand( + const FakeCommand( + command: [ + 'xcrun', + 'xcodebuild', + '-prepareDeviceSupport', + '-destination', + 'id=id-123', + ], + duration: Duration(seconds: 11), + stdout: 'Copying symbols...', + ), + ); + + final deviceSupport = IOSDeviceSupport( + logger: logger, + processUtils: processUtils, + xcode: FakeXcode(currentVersion: Version(16, 3, 0)), + homeDirectory: null, + modelCode: null, + operatingSystemVersion: null, + cpuArchitectureString: null, + deviceId: 'id-123', + ); + + deviceSupport.prepareDeviceSupport(); + + fakeAsync.elapse(const Duration(seconds: 11)); + + expect(logger.errorText, isEmpty); + }); + }); + + group('missingSymbolsWarning', () { + group('Xcode < 16.3', () { + testWithoutContext('returns unable to find warning when supportDir is null', () { + final deviceSupport = IOSDeviceSupport( + logger: BufferLogger.test(), + processUtils: ProcessUtils( + processManager: FakeProcessManager.empty(), + logger: BufferLogger.test(), + ), + xcode: FakeXcode(currentVersion: Version(16, 2, 0)), + homeDirectory: null, + modelCode: 'iPhone15,2', + operatingSystemVersion: '17.0', + cpuArchitectureString: 'arm64e', + deviceId: 'id-123', + ); + final String? warning = deviceSupport.missingSymbolsWarning(); + expect( + warning, + 'Xcode Device Support was not found for this device. This will likely reduce debugging ' + 'performance and may cause the app to hang on a white screen during launch.\n' + 'To trigger Device Symbols to be copied, connect the device via USB, close and then ' + 're-open Xcode. It may take several minutes for Xcode to copy symbols from the device.\n' + 'Once Device Support symbols are finished being copied, they are expected to be found at ' + '\$HOME/Library/Developer/Xcode/iOS DeviceSupport\n' + 'Please retry "flutter run" once symbols are finished being copied.', + ); + }); + + testWithoutContext('returns unable to find warning when device directory does not exist', () { + final FileSystem fileSystem = MemoryFileSystem.test(); + final Directory homeDir = fileSystem.directory('/Users/username'); + final deviceSupport = IOSDeviceSupport( + logger: BufferLogger.test(), + processUtils: ProcessUtils( + processManager: FakeProcessManager.empty(), + logger: BufferLogger.test(), + ), + xcode: FakeXcode(currentVersion: Version(16, 2, 0)), + homeDirectory: homeDir, + modelCode: 'iPhone15,2', + operatingSystemVersion: '17.0', + cpuArchitectureString: 'arm64e', + deviceId: 'id-123', + ); + final String? warning = deviceSupport.missingSymbolsWarning(); + expect( + warning, + 'Xcode Device Support was not found for this device. This will likely reduce debugging ' + 'performance and may cause the app to hang on a white screen during launch.\n' + 'To trigger Device Symbols to be copied, connect the device via USB, close and then ' + 're-open Xcode. It may take several minutes for Xcode to copy symbols from the device.\n' + 'Once Device Support symbols are finished being copied, they are expected to be found at ' + '/Users/username/Library/Developer/Xcode/iOS DeviceSupport/iPhone15,2 17.0/Symbols or ' + '/Users/username/Library/Developer/Xcode/iOS DeviceSupport/iPhone15,2 17.0/arm64e/Symbols\n' + 'Please retry "flutter run" once symbols are finished being copied.', + ); + }); + + testWithoutContext( + 'returns incomplete copy warning when device directory exists but symbols folder does not', + () { + final FileSystem fileSystem = MemoryFileSystem.test(); + final Directory homeDir = fileSystem.directory('/Users/username'); + final Directory supportDir = homeDir + .childDirectory('Library') + .childDirectory('Developer') + .childDirectory('Xcode') + .childDirectory('iOS DeviceSupport'); + supportDir.childDirectory('iPhone15,2 17.0').createSync(recursive: true); + + final deviceSupport = IOSDeviceSupport( + logger: BufferLogger.test(), + processUtils: ProcessUtils( + processManager: FakeProcessManager.empty(), + logger: BufferLogger.test(), + ), + xcode: FakeXcode(currentVersion: Version(16, 2, 0)), + homeDirectory: homeDir, + modelCode: 'iPhone15,2', + operatingSystemVersion: '17.0', + cpuArchitectureString: 'arm64e', + deviceId: 'id-123', + ); + final String? warning = deviceSupport.missingSymbolsWarning(); + expect( + warning, + 'Xcode has not finished copying Device Support symbols for this device. This will ' + 'likely reduce debugging performance and may cause the app to hang on a white screen ' + 'during launch.\n' + 'To trigger Device Symbols to be copied, connect the device via USB, close and then ' + 're-open Xcode. It may take several minutes for Xcode to copy symbols from the device.\n' + 'Once Device Support symbols are finished being copied, they are expected to be found at ' + '/Users/username/Library/Developer/Xcode/iOS DeviceSupport/iPhone15,2 17.0/Symbols or ' + '/Users/username/Library/Developer/Xcode/iOS DeviceSupport/iPhone15,2 17.0/arm64e/Symbols\n' + 'Please retry "flutter run" once symbols are finished being copied.', + ); + }, + ); + + testWithoutContext('returns null when symbol directory exists', () { + final FileSystem fileSystem = MemoryFileSystem.test(); + final Directory homeDir = fileSystem.directory('/Users/username'); + final Directory supportDir = homeDir + .childDirectory('Library') + .childDirectory('Developer') + .childDirectory('Xcode') + .childDirectory('iOS DeviceSupport'); + supportDir + .childDirectory('iPhone15,2 17.0') + .childDirectory('Symbols') + .createSync(recursive: true); + + final deviceSupport = IOSDeviceSupport( + logger: BufferLogger.test(), + processUtils: ProcessUtils( + processManager: FakeProcessManager.empty(), + logger: BufferLogger.test(), + ), + xcode: FakeXcode(currentVersion: Version(16, 2, 0)), + homeDirectory: homeDir, + modelCode: 'iPhone15,2', + operatingSystemVersion: '17.0', + cpuArchitectureString: 'arm64e', + deviceId: 'id-123', + ); + final String? warning = deviceSupport.missingSymbolsWarning(); + expect(warning, isNull); + }); + + testWithoutContext('returns null when architecture symbol directory exists', () { + final FileSystem fileSystem = MemoryFileSystem.test(); + final Directory homeDir = fileSystem.directory('/Users/username'); + final Directory supportDir = homeDir + .childDirectory('Library') + .childDirectory('Developer') + .childDirectory('Xcode') + .childDirectory('iOS DeviceSupport'); + supportDir + .childDirectory('iPhone15,2 17.0') + .childDirectory('arm64e') + .childDirectory('Symbols') + .createSync(recursive: true); + + final deviceSupport = IOSDeviceSupport( + logger: BufferLogger.test(), + processUtils: ProcessUtils( + processManager: FakeProcessManager.empty(), + logger: BufferLogger.test(), + ), + xcode: FakeXcode(currentVersion: Version(16, 2, 0)), + homeDirectory: homeDir, + modelCode: 'iPhone15,2', + operatingSystemVersion: '17.0', + cpuArchitectureString: 'arm64e', + deviceId: 'id-123', + ); + final String? warning = deviceSupport.missingSymbolsWarning(); + expect(warning, isNull); + }); + + testWithoutContext( + 'returns warning when symbol directory exists and warnWhenSymbolsExist is true', + () { + final FileSystem fileSystem = MemoryFileSystem.test(); + final Directory homeDir = fileSystem.directory('/Users/username'); + final Directory supportDir = homeDir + .childDirectory('Library') + .childDirectory('Developer') + .childDirectory('Xcode') + .childDirectory('iOS DeviceSupport'); + supportDir + .childDirectory('iPhone15,2 17.0') + .childDirectory('Symbols') + .createSync(recursive: true); + + final deviceSupport = IOSDeviceSupport( + logger: BufferLogger.test(), + processUtils: ProcessUtils( + processManager: FakeProcessManager.empty(), + logger: BufferLogger.test(), + ), + xcode: FakeXcode(currentVersion: Version(16, 2, 0)), + homeDirectory: homeDir, + modelCode: 'iPhone15,2', + operatingSystemVersion: '17.0', + cpuArchitectureString: 'arm64e', + deviceId: 'id-123', + ); + final String? warning = deviceSupport.missingSymbolsWarning(warnWhenSymbolsExist: true); + expect( + warning, + 'Xcode Device Support symbols exist for this device, but are being read from ' + 'process memory. This will likely reduce debugging performance and may cause the app to hang on a white screen during launch.\n' + 'To re-copy symbols from your device, complete the following steps:\n' + ' 1. Remove cached symbols:\n' + ' rm -rf "/Users/username/Library/Developer/Xcode/iOS DeviceSupport/iPhone15,2 17.0"\n' + ' 2. Connect the device via USB, close and then re-open Xcode. It may take several ' + 'minutes for Xcode to copy symbols from the device. Once Device Support symbols are finished being copied, they are expected to be found at ' + '/Users/username/Library/Developer/Xcode/iOS DeviceSupport/iPhone15,2 17.0/Symbols', + ); + }, + ); + }); + + group('Xcode 16.3+', () { + testWithoutContext('returns unable to find warning when supportDir is null', () { + final deviceSupport = IOSDeviceSupport( + logger: BufferLogger.test(), + processUtils: ProcessUtils( + processManager: FakeProcessManager.empty(), + logger: BufferLogger.test(), + ), + xcode: FakeXcode(currentVersion: Version(16, 3, 0)), + homeDirectory: null, + modelCode: 'iPhone15,2', + operatingSystemVersion: '17.0', + cpuArchitectureString: 'arm64e', + deviceId: 'id-123', + ); + final String? warning = deviceSupport.missingSymbolsWarning(); + expect( + warning, + 'Xcode Device Support was not found for this device. This will likely reduce debugging ' + 'performance and may cause the app to hang on a white screen during launch.\n' + 'To trigger Device Symbols to be copied, connect the device via USB, and run this command:\n' + ' "xcrun xcodebuild -prepareDeviceSupport -destination id=id-123"\n' + 'Once Device Support symbols are finished being copied, they are expected to be found at ' + '\$HOME/Library/Developer/Xcode/iOS DeviceSupport\n' + 'Please retry "flutter run" once symbols are finished being copied.', + ); + }); + + testWithoutContext('returns unable to find warning when device directory does not exist', () { + final FileSystem fileSystem = MemoryFileSystem.test(); + final Directory homeDir = fileSystem.directory('/Users/username'); + final deviceSupport = IOSDeviceSupport( + logger: BufferLogger.test(), + processUtils: ProcessUtils( + processManager: FakeProcessManager.empty(), + logger: BufferLogger.test(), + ), + xcode: FakeXcode(currentVersion: Version(16, 3, 0)), + homeDirectory: homeDir, + modelCode: 'iPhone15,2', + operatingSystemVersion: '17.0', + cpuArchitectureString: 'arm64e', + deviceId: 'id-123', + ); + final String? warning = deviceSupport.missingSymbolsWarning(); + expect( + warning, + 'Xcode Device Support was not found for this device. This will likely reduce debugging ' + 'performance and may cause the app to hang on a white screen during launch.\n' + 'To trigger Device Symbols to be copied, connect the device via USB, and run this command:\n' + ' "xcrun xcodebuild -prepareDeviceSupport -destination id=id-123"\n' + 'Once Device Support symbols are finished being copied, they are expected to be found at ' + '/Users/username/Library/Developer/Xcode/iOS DeviceSupport/iPhone15,2 17.0/Symbols or ' + '/Users/username/Library/Developer/Xcode/iOS DeviceSupport/iPhone15,2 17.0/arm64e/Symbols\n' + 'Please retry "flutter run" once symbols are finished being copied.', + ); + }); + + testWithoutContext( + 'returns incomplete copy warning when device directory exists but symbols folder does not', + () { + final FileSystem fileSystem = MemoryFileSystem.test(); + final Directory homeDir = fileSystem.directory('/Users/username'); + final Directory supportDir = homeDir + .childDirectory('Library') + .childDirectory('Developer') + .childDirectory('Xcode') + .childDirectory('iOS DeviceSupport'); + supportDir.childDirectory('iPhone15,2 17.0').createSync(recursive: true); + + final deviceSupport = IOSDeviceSupport( + logger: BufferLogger.test(), + processUtils: ProcessUtils( + processManager: FakeProcessManager.empty(), + logger: BufferLogger.test(), + ), + xcode: FakeXcode(currentVersion: Version(16, 3, 0)), + homeDirectory: homeDir, + modelCode: 'iPhone15,2', + operatingSystemVersion: '17.0', + cpuArchitectureString: 'arm64e', + deviceId: 'id-123', + ); + final String? warning = deviceSupport.missingSymbolsWarning(); + expect( + warning, + 'Xcode has not finished copying Device Support symbols for this device. This will ' + 'likely reduce debugging performance and may cause the app to hang on a white screen ' + 'during launch.\n' + 'To trigger Device Symbols to be copied, connect the device via USB, and run this command:\n' + ' "xcrun xcodebuild -prepareDeviceSupport -destination id=id-123"\n' + 'Once Device Support symbols are finished being copied, they are expected to be found at ' + '/Users/username/Library/Developer/Xcode/iOS DeviceSupport/iPhone15,2 17.0/Symbols or ' + '/Users/username/Library/Developer/Xcode/iOS DeviceSupport/iPhone15,2 17.0/arm64e/Symbols\n' + 'Please retry "flutter run" once symbols are finished being copied.', + ); + }, + ); + + testWithoutContext('returns null when symbol directory exists', () { + final FileSystem fileSystem = MemoryFileSystem.test(); + final Directory homeDir = fileSystem.directory('/Users/username'); + final Directory supportDir = homeDir + .childDirectory('Library') + .childDirectory('Developer') + .childDirectory('Xcode') + .childDirectory('iOS DeviceSupport'); + supportDir + .childDirectory('iPhone15,2 17.0') + .childDirectory('Symbols') + .createSync(recursive: true); + + final deviceSupport = IOSDeviceSupport( + logger: BufferLogger.test(), + processUtils: ProcessUtils( + processManager: FakeProcessManager.empty(), + logger: BufferLogger.test(), + ), + xcode: FakeXcode(currentVersion: Version(16, 3, 0)), + homeDirectory: homeDir, + modelCode: 'iPhone15,2', + operatingSystemVersion: '17.0', + cpuArchitectureString: 'arm64e', + deviceId: 'id-123', + ); + final String? warning = deviceSupport.missingSymbolsWarning(); + expect(warning, isNull); + }); + + testWithoutContext('returns null when architecture symbol directory exists', () { + final FileSystem fileSystem = MemoryFileSystem.test(); + final Directory homeDir = fileSystem.directory('/Users/username'); + final Directory supportDir = homeDir + .childDirectory('Library') + .childDirectory('Developer') + .childDirectory('Xcode') + .childDirectory('iOS DeviceSupport'); + supportDir + .childDirectory('iPhone15,2 17.0') + .childDirectory('arm64e') + .childDirectory('Symbols') + .createSync(recursive: true); + + final deviceSupport = IOSDeviceSupport( + logger: BufferLogger.test(), + processUtils: ProcessUtils( + processManager: FakeProcessManager.empty(), + logger: BufferLogger.test(), + ), + xcode: FakeXcode(currentVersion: Version(16, 3, 0)), + homeDirectory: homeDir, + modelCode: 'iPhone15,2', + operatingSystemVersion: '17.0', + cpuArchitectureString: 'arm64e', + deviceId: 'id-123', + ); + final String? warning = deviceSupport.missingSymbolsWarning(); + expect(warning, isNull); + }); + + testWithoutContext( + 'returns warning when symbol directory exists and warnWhenSymbolsExist is true', + () { + final FileSystem fileSystem = MemoryFileSystem.test(); + final Directory homeDir = fileSystem.directory('/Users/username'); + final Directory supportDir = homeDir + .childDirectory('Library') + .childDirectory('Developer') + .childDirectory('Xcode') + .childDirectory('iOS DeviceSupport'); + supportDir + .childDirectory('iPhone15,2 17.0') + .childDirectory('Symbols') + .createSync(recursive: true); + + final deviceSupport = IOSDeviceSupport( + logger: BufferLogger.test(), + processUtils: ProcessUtils( + processManager: FakeProcessManager.empty(), + logger: BufferLogger.test(), + ), + xcode: FakeXcode(currentVersion: Version(16, 3, 0)), + homeDirectory: homeDir, + modelCode: 'iPhone15,2', + operatingSystemVersion: '17.0', + cpuArchitectureString: 'arm64e', + deviceId: 'id-123', + ); + final String? warning = deviceSupport.missingSymbolsWarning(warnWhenSymbolsExist: true); + expect( + warning, + 'Xcode Device Support symbols exist for this device, but are being read from ' + 'process memory. This will likely reduce debugging performance and may cause the app to hang on a white screen during launch.\n' + 'To re-copy symbols from your device, complete the following steps:\n' + ' 1. Remove cached symbols:\n' + ' rm -rf "/Users/username/Library/Developer/Xcode/iOS DeviceSupport/iPhone15,2 17.0"\n' + ' 2. Connect the device via USB and trigger a copy:\n' + ' xcrun xcodebuild -prepareDeviceSupport -destination id=id-123', + ); + }, + ); + }); + }); + + group('existingDeviceSupportSymbols', () { + testWithoutContext('returns null when neither directory exists', () { + final FileSystem fileSystem = MemoryFileSystem.test(); + final Directory homeDir = fileSystem.directory('/Users/username'); + final deviceSupport = IOSDeviceSupport( + logger: BufferLogger.test(), + processUtils: ProcessUtils( + processManager: FakeProcessManager.empty(), + logger: BufferLogger.test(), + ), + xcode: null, + homeDirectory: homeDir, + modelCode: 'iPhone15,2', + operatingSystemVersion: '17.0', + cpuArchitectureString: 'arm64e', + deviceId: 'id-123', + ); + expect(deviceSupport.existingDeviceSupportSymbols, isNull); + }); + + testWithoutContext('is dynamic getter and re-evaluates symbol directories', () { + final FileSystem fileSystem = MemoryFileSystem.test(); + final Directory homeDir = fileSystem.directory('/Users/username'); + final deviceSupport = IOSDeviceSupport( + logger: BufferLogger.test(), + processUtils: ProcessUtils( + processManager: FakeProcessManager.empty(), + logger: BufferLogger.test(), + ), + xcode: null, + homeDirectory: homeDir, + modelCode: 'iPhone15,2', + operatingSystemVersion: '17.0', + cpuArchitectureString: 'arm64e', + deviceId: 'id-123', + ); + expect(deviceSupport.existingDeviceSupportSymbols, isNull); + + final Directory symbolDir = + homeDir + .childDirectory('Library') + .childDirectory('Developer') + .childDirectory('Xcode') + .childDirectory('iOS DeviceSupport') + .childDirectory('iPhone15,2 17.0') + .childDirectory('Symbols') + ..createSync(recursive: true); + + expect(deviceSupport.existingDeviceSupportSymbols?.path, symbolDir.path); + }); + + testWithoutContext('returns symbolDirectory when symbolDirectory exists', () { + final FileSystem fileSystem = MemoryFileSystem.test(); + final Directory homeDir = fileSystem.directory('/Users/username'); + final Directory supportDir = homeDir + .childDirectory('Library') + .childDirectory('Developer') + .childDirectory('Xcode') + .childDirectory('iOS DeviceSupport'); + final Directory symbolDir = + supportDir.childDirectory('iPhone15,2 17.0').childDirectory('Symbols') + ..createSync(recursive: true); + + final deviceSupport = IOSDeviceSupport( + logger: BufferLogger.test(), + processUtils: ProcessUtils( + processManager: FakeProcessManager.empty(), + logger: BufferLogger.test(), + ), + xcode: null, + homeDirectory: homeDir, + modelCode: 'iPhone15,2', + operatingSystemVersion: '17.0', + cpuArchitectureString: 'arm64e', + deviceId: 'id-123', + ); + expect(deviceSupport.existingDeviceSupportSymbols?.path, symbolDir.path); + }); + + testWithoutContext('returns archSymbolDirectory when archSymbolDirectory exists', () { + final FileSystem fileSystem = MemoryFileSystem.test(); + final Directory homeDir = fileSystem.directory('/Users/username'); + final Directory supportDir = homeDir + .childDirectory('Library') + .childDirectory('Developer') + .childDirectory('Xcode') + .childDirectory('iOS DeviceSupport'); + supportDir + .childDirectory('iPhone15,2 17.0') + .childDirectory('Symbols') + .createSync(recursive: true); + final Directory archSymbolDir = + supportDir + .childDirectory('iPhone15,2 17.0') + .childDirectory('arm64e') + .childDirectory('Symbols') + ..createSync(recursive: true); + + final deviceSupport = IOSDeviceSupport( + logger: BufferLogger.test(), + processUtils: ProcessUtils( + processManager: FakeProcessManager.empty(), + logger: BufferLogger.test(), + ), + xcode: null, + homeDirectory: homeDir, + modelCode: 'iPhone15,2', + operatingSystemVersion: '17.0', + cpuArchitectureString: 'arm64e', + deviceId: 'id-123', + ); + expect(deviceSupport.existingDeviceSupportSymbols?.path, archSymbolDir.path); + }); + }); + }); +} + +class FakeXcode extends Fake implements Xcode { + FakeXcode({this.currentVersion}); + + @override + final Version? currentVersion; +} 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 d721d4d0784e4..bdfffffded227 100644 --- a/packages/flutter_tools/test/general.shard/ios/devices_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/devices_test.dart @@ -5,7 +5,6 @@ import 'dart:async'; import 'dart:io' as io; -import 'package:file/file.dart'; import 'package:file/memory.dart'; import 'package:flutter_tools/src/artifacts.dart'; import 'package:flutter_tools/src/base/file_system.dart'; @@ -13,6 +12,7 @@ import 'package:flutter_tools/src/base/io.dart'; import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/base/os.dart'; import 'package:flutter_tools/src/base/platform.dart'; +import 'package:flutter_tools/src/base/process.dart'; import 'package:flutter_tools/src/base/version.dart'; import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/cache.dart'; @@ -46,15 +46,19 @@ void main() { late IOSDeploy iosDeploy; late IMobileDevice iMobileDevice; late FileSystem fileSystem; + late FileSystemUtils fileSystemUtils; late IOSCoreDeviceControl coreDeviceControl; late IOSCoreDeviceLauncher coreDeviceLauncher; late XcodeDebug xcodeDebug; + late ProcessUtils processUtils; setUp(() { final artifacts = Artifacts.test(); cache = Cache.test(processManager: FakeProcessManager.any()); logger = BufferLogger.test(); + processUtils = ProcessUtils(processManager: FakeProcessManager.any(), logger: logger); fileSystem = MemoryFileSystem.test(); + fileSystemUtils = FileSystemUtils(fileSystem: fileSystem, platform: macPlatform); iosDeploy = IOSDeploy( artifacts: artifacts, cache: cache, @@ -78,6 +82,7 @@ void main() { 'device-123', iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, + fileSystemUtils: fileSystemUtils, logger: logger, platform: macPlatform, iosDeploy: iosDeploy, @@ -94,15 +99,69 @@ void main() { isPaired: true, devModeEnabled: true, isCoreDevice: false, + processUtils: processUtils, + xcode: null, ); expect(await device.isSupported(), isTrue); }); + group('shouldAttachLLDBDebugger', () { + testWithoutContext('returns expected values for different BuildInfo and options', () { + final device = IOSDevice( + 'device-123', + iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), + fileSystem: fileSystem, + fileSystemUtils: fileSystemUtils, + logger: logger, + platform: macPlatform, + iosDeploy: iosDeploy, + analytics: FakeAnalytics(), + iMobileDevice: iMobileDevice, + coreDeviceControl: coreDeviceControl, + coreDeviceLauncher: coreDeviceLauncher, + xcodeDebug: xcodeDebug, + name: 'iPhone 1', + sdkVersion: '13.3', + cpuArchitecture: DarwinArch.arm64, + connectionInterface: DeviceConnectionInterface.attached, + isConnected: true, + isPaired: true, + devModeEnabled: true, + isCoreDevice: false, + processUtils: processUtils, + xcode: null, + ); + + expect(device.shouldAttachLLDBDebugger(DebuggingOptions.enabled(BuildInfo.debug)), isTrue); + expect( + device.shouldAttachLLDBDebugger( + DebuggingOptions.enabled(BuildInfo.profile, iosProfileDebugger: true), + ), + isTrue, + ); + expect( + device.shouldAttachLLDBDebugger(DebuggingOptions.enabled(BuildInfo.profile)), + isFalse, + ); + expect( + device.shouldAttachLLDBDebugger( + DebuggingOptions.enabled(BuildInfo.profile, iosProfileDebugger: false), + ), + isFalse, + ); + expect( + device.shouldAttachLLDBDebugger(DebuggingOptions.enabled(BuildInfo.release)), + isFalse, + ); + }); + }); + testWithoutContext('32-bit devices are unsupported', () async { final device = IOSDevice( 'device-123', iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, + fileSystemUtils: fileSystemUtils, logger: logger, analytics: FakeAnalytics(), platform: macPlatform, @@ -118,6 +177,8 @@ void main() { isPaired: true, devModeEnabled: true, isCoreDevice: false, + processUtils: processUtils, + xcode: null, ); expect(await device.isSupported(), isFalse); }); @@ -128,6 +189,7 @@ void main() { 'device-123', iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, + fileSystemUtils: fileSystemUtils, logger: logger, analytics: FakeAnalytics(), platform: macPlatform, @@ -144,6 +206,8 @@ void main() { isPaired: true, devModeEnabled: true, isCoreDevice: false, + processUtils: processUtils, + xcode: null, ).majorSdkVersion, 1, ); @@ -152,6 +216,7 @@ void main() { 'device-123', iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, + fileSystemUtils: fileSystemUtils, logger: logger, analytics: FakeAnalytics(), platform: macPlatform, @@ -168,6 +233,8 @@ void main() { isPaired: true, devModeEnabled: true, isCoreDevice: false, + processUtils: processUtils, + xcode: null, ).majorSdkVersion, 13, ); @@ -176,6 +243,7 @@ void main() { 'device-123', iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, + fileSystemUtils: fileSystemUtils, logger: logger, analytics: FakeAnalytics(), platform: macPlatform, @@ -192,6 +260,8 @@ void main() { isPaired: true, devModeEnabled: true, isCoreDevice: false, + processUtils: processUtils, + xcode: null, ).majorSdkVersion, 10, ); @@ -200,6 +270,7 @@ void main() { 'device-123', iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, + fileSystemUtils: fileSystemUtils, logger: logger, analytics: FakeAnalytics(), platform: macPlatform, @@ -216,6 +287,8 @@ void main() { isPaired: true, devModeEnabled: true, isCoreDevice: false, + processUtils: processUtils, + xcode: null, ).majorSdkVersion, 0, ); @@ -224,6 +297,7 @@ void main() { 'device-123', iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, + fileSystemUtils: fileSystemUtils, logger: logger, analytics: FakeAnalytics(), platform: macPlatform, @@ -240,6 +314,8 @@ void main() { isPaired: true, devModeEnabled: true, isCoreDevice: false, + processUtils: processUtils, + xcode: null, ).majorSdkVersion, 0, ); @@ -250,6 +326,7 @@ void main() { 'device-123', iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, + fileSystemUtils: fileSystemUtils, logger: logger, analytics: FakeAnalytics(), platform: macPlatform, @@ -266,6 +343,8 @@ void main() { isPaired: true, devModeEnabled: true, isCoreDevice: false, + processUtils: processUtils, + xcode: null, ).sdkVersion; var expectedVersion = Version(13, 3, 1, text: '13.3.1'); expect(sdkVersion, isNotNull); @@ -276,6 +355,7 @@ void main() { 'device-123', iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, + fileSystemUtils: fileSystemUtils, logger: logger, analytics: FakeAnalytics(), platform: macPlatform, @@ -292,6 +372,8 @@ void main() { isPaired: true, devModeEnabled: true, isCoreDevice: false, + processUtils: processUtils, + xcode: null, ).sdkVersion; expectedVersion = Version(13, 3, 1, text: '13.3.1 (20ADBC)'); expect(sdkVersion, isNotNull); @@ -302,6 +384,7 @@ void main() { 'device-123', iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, + fileSystemUtils: fileSystemUtils, logger: logger, analytics: FakeAnalytics(), platform: macPlatform, @@ -318,6 +401,8 @@ void main() { isPaired: true, devModeEnabled: true, isCoreDevice: false, + processUtils: processUtils, + xcode: null, ).sdkVersion; expectedVersion = Version(16, 4, 1, text: '16.4.1(a) (20ADBC)'); expect(sdkVersion, isNotNull); @@ -328,6 +413,7 @@ void main() { 'device-123', iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, + fileSystemUtils: fileSystemUtils, logger: logger, analytics: FakeAnalytics(), platform: macPlatform, @@ -344,6 +430,8 @@ void main() { isPaired: true, devModeEnabled: true, isCoreDevice: false, + processUtils: processUtils, + xcode: null, ).sdkVersion; expectedVersion = Version(0, 0, 0, text: '0'); expect(sdkVersion, isNotNull); @@ -354,6 +442,7 @@ void main() { 'device-123', iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, + fileSystemUtils: fileSystemUtils, logger: logger, analytics: FakeAnalytics(), platform: macPlatform, @@ -369,6 +458,8 @@ void main() { isPaired: true, devModeEnabled: true, isCoreDevice: false, + processUtils: processUtils, + xcode: null, ).sdkVersion; expect(sdkVersion, isNull); @@ -376,6 +467,7 @@ void main() { 'device-123', iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, + fileSystemUtils: fileSystemUtils, logger: logger, analytics: FakeAnalytics(), platform: macPlatform, @@ -392,6 +484,8 @@ void main() { isPaired: true, devModeEnabled: true, isCoreDevice: false, + processUtils: processUtils, + xcode: null, ).sdkVersion; expect(sdkVersion, isNull); }); @@ -401,6 +495,7 @@ void main() { 'device-123', iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, + fileSystemUtils: fileSystemUtils, logger: logger, analytics: FakeAnalytics(), platform: macPlatform, @@ -417,6 +512,8 @@ void main() { isPaired: true, devModeEnabled: true, isCoreDevice: false, + processUtils: processUtils, + xcode: null, ); expect(await device.sdkNameAndVersion, 'iOS 13.3 17C54'); @@ -427,6 +524,7 @@ void main() { 'device-123', iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, + fileSystemUtils: fileSystemUtils, logger: logger, analytics: FakeAnalytics(), platform: macPlatform, @@ -443,6 +541,8 @@ void main() { isPaired: true, devModeEnabled: true, isCoreDevice: false, + processUtils: processUtils, + xcode: null, ); expect(device.supportsRuntimeMode(BuildMode.debug), true); @@ -460,6 +560,7 @@ void main() { 'device-123', iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, + fileSystemUtils: fileSystemUtils, logger: logger, analytics: FakeAnalytics(), platform: platform, @@ -476,6 +577,8 @@ void main() { isPaired: true, devModeEnabled: true, isCoreDevice: false, + processUtils: processUtils, + xcode: null, ); }, throwsAssertionError); }, @@ -551,6 +654,7 @@ void main() { '123', iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), fileSystem: fileSystem, + fileSystemUtils: fileSystemUtils, logger: logger, analytics: FakeAnalytics(), platform: macPlatform, @@ -567,6 +671,8 @@ void main() { isPaired: true, devModeEnabled: true, isCoreDevice: false, + processUtils: processUtils, + xcode: null, ); logReader1 = createLogReader(device, appPackage1, process1); logReader2 = createLogReader(device, appPackage2, process2); @@ -622,6 +728,12 @@ void main() { coreDeviceLauncher = FakeIOSCoreDeviceLauncher(); xcodeDebug = FakeXcodeDebug(); + final testFileSystem = MemoryFileSystem.test(); + final testFileSystemUtils = FileSystemUtils( + fileSystem: testFileSystem, + platform: macPlatform, + ); + device1 = IOSDevice( 'd83d5bc53967baa0ee18626ba87b6254b2ab5418', name: 'Paired iPhone', @@ -636,12 +748,15 @@ void main() { xcodeDebug: xcodeDebug, logger: logger, platform: macPlatform, - fileSystem: MemoryFileSystem.test(), + fileSystem: testFileSystem, + fileSystemUtils: testFileSystemUtils, connectionInterface: DeviceConnectionInterface.attached, isConnected: true, isPaired: true, devModeEnabled: true, isCoreDevice: false, + processUtils: ProcessUtils(processManager: fakeProcessManager, logger: logger), + xcode: null, ); device2 = IOSDevice( @@ -658,12 +773,15 @@ void main() { xcodeDebug: xcodeDebug, logger: logger, platform: macPlatform, - fileSystem: MemoryFileSystem.test(), + fileSystem: testFileSystem, + fileSystemUtils: testFileSystemUtils, connectionInterface: DeviceConnectionInterface.attached, isConnected: true, isPaired: true, devModeEnabled: true, isCoreDevice: false, + processUtils: ProcessUtils(processManager: fakeProcessManager, logger: logger), + xcode: null, ); }); @@ -974,6 +1092,11 @@ void main() { coreDeviceControl = FakeIOSCoreDeviceControl(); coreDeviceLauncher = FakeIOSCoreDeviceLauncher(); xcodeDebug = FakeXcodeDebug(); + final testFileSystem = MemoryFileSystem.test(); + final testFileSystemUtils = FileSystemUtils( + fileSystem: testFileSystem, + platform: macPlatform, + ); notConnected1 = IOSDevice( '00000001-0000000000000000', name: 'iPad', @@ -988,12 +1111,15 @@ void main() { xcodeDebug: xcodeDebug, logger: logger, platform: macPlatform, - fileSystem: MemoryFileSystem.test(), + fileSystem: testFileSystem, + fileSystemUtils: testFileSystemUtils, connectionInterface: DeviceConnectionInterface.attached, isConnected: false, isPaired: true, devModeEnabled: true, isCoreDevice: false, + processUtils: ProcessUtils(processManager: fakeProcessManager, logger: logger), + xcode: null, ); }); 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 6f92b9396031f..746c980a181f8 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 @@ -8,6 +8,7 @@ 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/build_info.dart'; import 'package:flutter_tools/src/cache.dart'; import 'package:flutter_tools/src/device.dart'; @@ -360,11 +361,15 @@ IOSDevice setUpIOSDevice({ artifacts: [FakeDyldEnvironmentArtifact()], processManager: FakeProcessManager.any(), ); + final FileSystem testFileSystem = fileSystem ?? MemoryFileSystem.test(); return IOSDevice( '1234', name: 'iPhone 1', logger: logger, - fileSystem: fileSystem ?? MemoryFileSystem.test(), + fileSystem: testFileSystem, + fileSystemUtils: FileSystemUtils(fileSystem: testFileSystem, platform: platform), + processUtils: ProcessUtils(processManager: processManager, logger: logger), + xcode: null, sdkVersion: '13.3', cpuArchitecture: DarwinArch.arm64, platform: platform, 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 d42e12c1e1b80..cda14f5b588a8 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 @@ -7,6 +7,7 @@ import 'package:flutter_tools/src/artifacts.dart'; import 'package:flutter_tools/src/base/file_system.dart'; import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/base/platform.dart'; +import 'package:flutter_tools/src/base/process.dart'; import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/cache.dart'; import 'package:flutter_tools/src/device.dart'; @@ -100,7 +101,10 @@ IOSDevice setUpIOSDevice(FileSystem fileSystem) { return IOSDevice( 'test', fileSystem: fileSystem, + fileSystemUtils: FileSystemUtils(fileSystem: fileSystem, platform: platform), logger: logger, + processUtils: ProcessUtils(processManager: processManager, logger: logger), + xcode: null, iosDeploy: IOSDeploy( platform: platform, logger: logger, 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 740ccb288396c..a24e51a704189 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 @@ -21,6 +21,7 @@ import 'package:flutter_tools/src/device.dart'; import 'package:flutter_tools/src/device_port_forwarder.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/device_support.dart'; import 'package:flutter_tools/src/ios/devices.dart'; import 'package:flutter_tools/src/ios/ios_deploy.dart'; import 'package:flutter_tools/src/ios/iproxy.dart'; @@ -1052,6 +1053,85 @@ void main() { }, ); + testUsingContext( + 'logs warning when missing symbols pattern is detected in log reader', + () async { + final IOSDevice iosDevice = setUpIOSDevice( + fileSystem: fileSystem, + processManager: FakeProcessManager.any(), + logger: logger, + artifacts: artifacts, + isCoreDevice: true, + coreDeviceControl: FakeIOSCoreDeviceControl(), + xcodeDebug: FakeXcodeDebug( + expectedProject: XcodeDebugProject( + scheme: 'Runner', + xcodeWorkspace: fileSystem.directory('/ios/Runner.xcworkspace'), + xcodeProject: fileSystem.directory('/ios/Runner.xcodeproj'), + hostAppProjectName: 'Runner', + ), + expectedDeviceId: '123', + expectedLaunchArguments: ['--enable-dart-profiling'], + ), + ); + + setUpIOSProject(fileSystem); + final FlutterProject flutterProject = FlutterProject.fromDirectory( + fileSystem.currentDirectory, + ); + final buildableIOSApp = BuildableIOSApp( + flutterProject.ios, + 'flutter', + 'My Super Awesome App', + ); + fileSystem + .directory('build/ios/Release-iphoneos/My Super Awesome App.app') + .createSync(recursive: true); + + final deviceLogReader = FakeSharedIOSDeviceLogReader(); + + iosDevice.portForwarder = const NoOpDevicePortForwarder(); + iosDevice.setLogReader(buildableIOSApp, deviceLogReader); + + Timer.run(() { + deviceLogReader.addLine('The Dart VM service is listening on http://127.0.0.1:456'); + deviceLogReader.addLine('warning: libobjc.A.dylib is being read from process memory.'); + }); + + final LaunchResult launchResult = await iosDevice.startApp( + buildableIOSApp, + debuggingOptions: DebuggingOptions.enabled( + const BuildInfo( + BuildMode.debug, + null, + buildName: '1.2.3', + buildNumber: '4', + treeShakeIcons: false, + packageConfigPath: '.dart_tool/package_config.json', + ), + ), + platformArgs: {}, + ); + + expect(launchResult.started, true); + expect( + logger.warningText, + contains('Xcode Device Support was not found for this device.'), + ); + }, + overrides: { + ProcessManager: () => FakeProcessManager.any(), + Pub: () => const ThrowingPub(), + FileSystem: () => fileSystem, + Logger: () => logger, + OperatingSystemUtils: () => os, + Platform: () => macPlatform, + XcodeProjectInterpreter: () => fakeXcodeProjectInterpreter, + Xcode: () => xcode, + Artifacts: () => artifacts, + }, + ); + group('with flavor', () { setUp(() { projectInfo = XcodeProjectInfo( @@ -1475,12 +1555,19 @@ IOSDevice setUpIOSDevice({ ); logger ??= BufferLogger.test(); + final FileSystem testFileSystem = fileSystem ?? MemoryFileSystem.test(); return IOSDevice( '123', name: 'iPhone 1', sdkVersion: sdkVersion, - fileSystem: fileSystem ?? MemoryFileSystem.test(), + fileSystem: testFileSystem, + fileSystemUtils: FileSystemUtils(fileSystem: testFileSystem, platform: macPlatform), platform: macPlatform, + processUtils: ProcessUtils( + processManager: processManager ?? FakeProcessManager.any(), + logger: logger, + ), + xcode: null, iProxy: IProxy.test(logger: logger, processManager: processManager ?? FakeProcessManager.any()), logger: logger, iosDeploy: IOSDeploy( @@ -1539,7 +1626,7 @@ class FakeXcodeProjectInterpreter extends Fake implements XcodeProjectInterprete Future> fetchDependenciesAndGenerateXcodebuildArgs( XcodeBasedProject xcodeProject, Directory buildDirectory, { - bool skipPackageUpdatesAndValidation = true, + bool skipPackageValidation = true, }) async { return ['xcrun', 'xcodebuild']; } @@ -1731,6 +1818,7 @@ class FakeIOSCoreDeviceLauncher extends Fake implements IOSCoreDeviceLauncher { @override Future launchAppWithLLDBDebugger({ required String deviceId, + required IOSDeviceSupport deviceSupport, required String bundlePath, required String bundleId, required List launchArguments, @@ -1778,3 +1866,42 @@ class FakeArtifacts extends Fake implements Artifacts { @override LocalEngineInfo? get localEngineInfo => null; } + +class FakeSharedIOSDeviceLogReader extends SharedIOSDeviceLogReader { + @override + String get name => 'FakeLogReader'; + + bool disposed = false; + + final _lineQueue = []; + late final _linesController = StreamController.broadcast(onListen: _onListen); + + @override + Stream get logLines => _linesController.stream; + + @override + StreamController get linesController => _linesController; + + void _onListen() { + _lineQueue.forEach(addLogToStream); + _lineQueue.clear(); + } + + void addLine(String line) { + if (_linesController.hasListener) { + addLogToStream(line); + } else { + _lineQueue.add(line); + } + } + + @override + Future dispose() async { + _lineQueue.clear(); + await _linesController.close(); + disposed = true; + } + + @override + Future provideVmService(Object? connectedVmService) async {} +} 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 f21c61b02593e..f5c9eea85304d 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 @@ -22,6 +22,7 @@ import 'package:flutter_tools/src/device.dart'; import 'package:flutter_tools/src/device_port_forwarder.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/device_support.dart'; import 'package:flutter_tools/src/ios/devices.dart'; import 'package:flutter_tools/src/ios/ios_deploy.dart'; import 'package:flutter_tools/src/ios/iproxy.dart'; @@ -908,6 +909,72 @@ void main() { }, ); + testUsingContext( + 'calls prepareDeviceSupport when Xcode version is >= 16.3', + () async { + final FileSystem fileSystem = MemoryFileSystem.test(); + final processManager = FakeProcessManager.empty(); + processManager.addCommand( + const FakeCommand( + command: [ + 'xcrun', + 'xcodebuild', + '-prepareDeviceSupport', + '-destination', + 'id=123', + ], + ), + ); + 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, + xcode: FakeXcode(currentVersion: Version(26, 0, 0)), + ); + final IOSApp iosApp = PrebuiltIOSApp( + projectBundleId: 'app', + bundleName: 'Runner', + uncompressedBundle: bundleLocation, + applicationPackage: bundleLocation, + ); + final DeviceLogReader deviceLogReader = IOSDeviceLogReader.test( + iMobileDevice: FakeIMobileDevice(), + xcode: FakeXcode(currentVersion: Version(26, 0, 0)), + isCoreDevice: true, + ); + + device.portForwarder = const NoOpDevicePortForwarder(); + device.setLogReader(iosApp, deviceLogReader); + + // Start writing messages to the log reader. + Timer(const Duration(milliseconds: 50), () { + fakeLauncher.coreDeviceLogForwarder.addLog('Foo'); + fakeLauncher.coreDeviceLogForwarder.addLog( + '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(processManager, hasNoRemainingExpectations); + }, + 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(); @@ -1614,6 +1681,7 @@ void main() { fileSystem: fileSystem, isCoreDevice: true, coreDeviceControl: FakeIOSCoreDeviceControl(), + logger: testLogger, xcodeDebug: FakeXcodeDebug( expectedProject: XcodeDebugProject( scheme: 'Runner', @@ -1859,6 +1927,72 @@ void main() { }, ); + testUsingContext( + 'calls prepareDeviceSupport when iosProfileDebugger is true and Xcode version is >= 16.3', + () async { + final FileSystem fileSystem = MemoryFileSystem.test(); + final processManager = FakeProcessManager.empty(); + processManager.addCommand( + const FakeCommand( + command: [ + 'xcrun', + 'xcodebuild', + '-prepareDeviceSupport', + '-destination', + 'id=123', + ], + ), + ); + 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, + xcode: FakeXcode(currentVersion: Version(26, 0, 0)), + ); + final IOSApp iosApp = PrebuiltIOSApp( + projectBundleId: 'app', + bundleName: 'Runner', + uncompressedBundle: bundleLocation, + applicationPackage: bundleLocation, + ); + final DeviceLogReader deviceLogReader = IOSDeviceLogReader.test( + iMobileDevice: FakeIMobileDevice(), + xcode: FakeXcode(currentVersion: Version(26, 0, 0)), + isCoreDevice: true, + ); + + device.portForwarder = const NoOpDevicePortForwarder(); + device.setLogReader(iosApp, deviceLogReader); + + // Start writing messages to the log reader. + Timer(const Duration(milliseconds: 50), () { + fakeLauncher.coreDeviceLogForwarder.addLog('Foo'); + fakeLauncher.coreDeviceLogForwarder.addLog( + '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.profile, iosProfileDebugger: true), + platformArgs: {}, + ); + + expect(launchResult.started, true); + expect(processManager, hasNoRemainingExpectations); + }, + overrides: { + Xcode: () => FakeXcode(currentVersion: Version(26, 0, 0)), + Analytics: () => FakeAnalytics(), + }, + ); + testUsingContext( 'uses Xcode if less than Xcode 26', () async { @@ -1945,6 +2079,7 @@ IOSDevice setUpIOSDevice({ Analytics? analytics, FakeXcodeDebug? xcodeDebug, FakePlatform? platform, + Xcode? xcode, }) { final artifacts = Artifacts.test(); final FakePlatform macPlatform = @@ -1956,12 +2091,19 @@ IOSDevice setUpIOSDevice({ processManager: FakeProcessManager.any(), ); logger ??= BufferLogger.test(); + final FileSystem testFileSystem = fileSystem ?? MemoryFileSystem.test(); return IOSDevice( '123', name: 'iPhone 1', sdkVersion: sdkVersion, - fileSystem: fileSystem ?? MemoryFileSystem.test(), + fileSystem: testFileSystem, + fileSystemUtils: FileSystemUtils(fileSystem: testFileSystem, platform: macPlatform), platform: macPlatform, + processUtils: ProcessUtils( + processManager: processManager ?? FakeProcessManager.any(), + logger: logger, + ), + xcode: xcode, iProxy: IProxy.test(logger: logger, processManager: processManager ?? FakeProcessManager.any()), logger: logger, iosDeploy: @@ -2136,6 +2278,7 @@ class FakeIOSCoreDeviceLauncher extends Fake implements IOSCoreDeviceLauncher { @override Future launchAppWithLLDBDebugger({ required String deviceId, + required IOSDeviceSupport deviceSupport, required String bundlePath, required String bundleId, required List launchArguments, diff --git a/packages/flutter_tools/test/general.shard/ios/lldb_test.dart b/packages/flutter_tools/test/general.shard/ios/lldb_test.dart index c0a544e172970..46156eae3f879 100644 --- a/packages/flutter_tools/test/general.shard/ios/lldb_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/lldb_test.dart @@ -7,10 +7,14 @@ import 'dart:convert'; import 'dart:io' as io; import 'package:fake_async/fake_async.dart'; +import 'package:file/memory.dart'; +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/process.dart'; +import 'package:flutter_tools/src/base/version.dart'; import 'package:flutter_tools/src/build_info.dart'; +import 'package:flutter_tools/src/ios/device_support.dart'; import 'package:flutter_tools/src/ios/lldb.dart'; import 'package:test/fake.dart'; @@ -18,11 +22,12 @@ import '../../src/common.dart'; import '../../src/context.dart'; import '../../src/fake_process_manager.dart'; +const _deviceId = '123'; +const _appProcessId = 5678; +const _breakpointId = 123; + void main() { testWithoutContext('attachAndStart fails if lldb fails', () async { - const deviceId = '123'; - const appProcessId = 5678; - final processCompleter = Completer(); final lldbCommand = FakeLLDBCommand( command: const ['xcrun', 'lldb'], @@ -42,13 +47,15 @@ void main() { logger: logger, processUtils: processUtils, xcodeProjectInterpreter: FakeXcodeProjectInterpreter(), + deviceVersion: Version(16, 0, 0), ); final bool success = await lldb.attachAndStart( - deviceId: deviceId, - appProcessId: appProcessId, + deviceId: _deviceId, + appProcessId: _appProcessId, lldbLogForwarder: FakeLLDBLogForwarder(), mode: BuildMode.debug, + deviceSupport: createDeviceSupport(), ); expect(success, isFalse); expect(lldb.isRunning, isFalse); @@ -58,20 +65,18 @@ void main() { }); testWithoutContext('attachAndStart returns true on success', () async { - const deviceId = '123'; - const appProcessId = 5678; - const breakpointId = 123; - final breakPointCompleter = Completer>(); final processAttachCompleter = Completer>(); final setupStopHooksCompleter = Completer>(); - final processResumedCompleted = Completer>(); + final platformStatusCompleter = Completer>(); + final processResumedCompleter = Completer>(); final stdoutStream = Stream>.fromFutures([ breakPointCompleter.future, processAttachCompleter.future, setupStopHooksCompleter.future, - processResumedCompleted.future, + platformStatusCompleter.future, + processResumedCompleter.future, ]); final stdinController = StreamController>(); @@ -93,80 +98,56 @@ void main() { logger: logger, processUtils: processUtils, xcodeProjectInterpreter: FakeXcodeProjectInterpreter(), + deviceVersion: Version(16, 0, 0), ); - const breakPointMatcher = r"breakpoint set --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'"; - const processAttachMatcher = 'device process attach --pid $appProcessId'; - const processResumedMatcher = 'process continue'; - const setupStopHooksMatcher = 'target stop-hook add -o "thread backtrace all" -o "detach"'; - final expectedInputs = [ - 'device select $deviceId', - breakPointMatcher, - 'breakpoint command add --script-type python $breakpointId', - 'script lldb.debugger.SetAsync(False)', - processAttachMatcher, - setupStopHooksMatcher, - processResumedMatcher, - ]; + final Map> completer, String out})?> + inputsAndOutputs = buildAttachInputsAndOutputs( + breakPointMatcher: + r"breakpoint set --auto-continue true --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'", + processResumingOutput: '1 location added to breakpoint 1\n', + breakPointCompleter: breakPointCompleter, + processAttachCompleter: processAttachCompleter, + setupStopHooksCompleter: setupStopHooksCompleter, + platformStatusCompleter: platformStatusCompleter, + processResumedCompleter: processResumedCompleter, + ); 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 == setupStopHooksMatcher) { - setupStopHooksCompleter.complete(utf8.encode('Stop hook #1 added.\n')); - } - if (line == processResumedMatcher) { - processResumedCompleted.complete(utf8.encode('1 location added to breakpoint 1\n')); + final ({Completer> completer, String out})? x = inputsAndOutputs.remove(line); + if (x != null) { + x.completer.complete(utf8.encode(x.out)); } }); final bool success = await lldb.attachAndStart( - deviceId: deviceId, - appProcessId: appProcessId, + deviceId: _deviceId, + appProcessId: _appProcessId, lldbLogForwarder: FakeLLDBLogForwarder(), mode: BuildMode.debug, + deviceSupport: createDeviceSupport(), ); expect(success, isTrue); expect(lldb.isRunning, isTrue); - expect(lldb.appProcessId, appProcessId); - expect(expectedInputs, isEmpty); + expect(lldb.appProcessId, _appProcessId); + expect(inputsAndOutputs, isEmpty); expect(processManager.hasRemainingExpectations, isFalse); expect(logger.errorText, isEmpty); }); testWithoutContext('attachAndStart returns true on success for profile mode', () async { - const deviceId = '123'; - const appProcessId = 5678; - final processAttachCompleter = Completer>(); final setupStopHooksCompleter = Completer>(); - final processResumedCompleted = Completer>(); + final platformStatusCompleter = Completer>(); + final processResumedCompleter = Completer>(); final stdoutStream = Stream>.fromFutures([ processAttachCompleter.future, setupStopHooksCompleter.future, - processResumedCompleted.future, + platformStatusCompleter.future, + processResumedCompleter.future, ]); final stdinController = StreamController>(); @@ -188,63 +169,46 @@ Target 0: (Runner) stopped. logger: logger, processUtils: processUtils, xcodeProjectInterpreter: FakeXcodeProjectInterpreter(), + deviceVersion: Version(27, 0, 0), ); - const processAttachMatcher = 'device process attach --pid $appProcessId'; - const processResumedMatcher = 'process continue'; - const setupStopHooksMatcher = 'target stop-hook add -o "thread backtrace all" -o "detach"'; - final expectedInputs = [ - 'device select $deviceId', - processAttachMatcher, - setupStopHooksMatcher, - processResumedMatcher, - ]; + final Map> completer, String out})?> + inputsAndOutputs = buildAttachInputsAndOutputs( + breakPointMatcher: + r"breakpoint set --auto-continue true --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'", + processResumingOutput: 'Process $_appProcessId resuming\n', + breakPointCompleter: null, + processAttachCompleter: processAttachCompleter, + setupStopHooksCompleter: setupStopHooksCompleter, + platformStatusCompleter: platformStatusCompleter, + processResumedCompleter: processResumedCompleter, + ); stdinController.stream.transform(utf8.decoder).transform(const LineSplitter()).listen(( String line, ) { - expectedInputs.remove(line); - 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 == setupStopHooksMatcher) { - setupStopHooksCompleter.complete(utf8.encode('Stop hook #1 added.\n')); - } - if (line == processResumedMatcher) { - processResumedCompleted.complete(utf8.encode('Process 568 resuming\n')); + final ({Completer> completer, String out})? x = inputsAndOutputs.remove(line); + if (x != null) { + x.completer.complete(utf8.encode(x.out)); } }); final bool success = await lldb.attachAndStart( - deviceId: deviceId, - appProcessId: appProcessId, + deviceId: _deviceId, + appProcessId: _appProcessId, lldbLogForwarder: FakeLLDBLogForwarder(), mode: BuildMode.profile, + deviceSupport: createDeviceSupport(), ); expect(success, isTrue); expect(lldb.isRunning, isTrue); - expect(lldb.appProcessId, appProcessId); - expect(expectedInputs, isEmpty); + expect(lldb.appProcessId, _appProcessId); + expect(inputsAndOutputs, isEmpty); expect(processManager.hasRemainingExpectations, isFalse); expect(logger.errorText, isEmpty); }); testWithoutContext('attachAndStart returns false when stderr during log waiter', () async { - const deviceId = '123'; - const appProcessId = 5678; - final breakPointCompleter = Completer>(); final errorCompleter = Completer>(); @@ -271,10 +235,12 @@ Target 0: (Runner) stopped. logger: logger, processUtils: processUtils, xcodeProjectInterpreter: FakeXcodeProjectInterpreter(), + deviceVersion: Version(16, 0, 0), ); - const breakPointMatcher = r"breakpoint set --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'"; - final expectedInputs = ['device select $deviceId', breakPointMatcher]; + const breakPointMatcher = + r"breakpoint set --auto-continue true --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(( @@ -287,10 +253,11 @@ Target 0: (Runner) stopped. }); final bool success = await lldb.attachAndStart( - deviceId: deviceId, - appProcessId: appProcessId, + deviceId: _deviceId, + appProcessId: _appProcessId, lldbLogForwarder: FakeLLDBLogForwarder(), mode: BuildMode.debug, + deviceSupport: createDeviceSupport(), ); expect(success, isFalse); expect(lldb.isRunning, isFalse); @@ -301,9 +268,6 @@ Target 0: (Runner) stopped. }); testWithoutContext('attachAndStart returns false when stderr not during log waiter', () async { - const deviceId = '123'; - const appProcessId = 5678; - final breakPointCompleter = Completer>(); final errorCompleter = Completer>(); @@ -330,10 +294,11 @@ Target 0: (Runner) stopped. logger: logger, processUtils: processUtils, xcodeProjectInterpreter: FakeXcodeProjectInterpreter(), + deviceVersion: Version(16, 0, 0), ); final expectedInputs = [ - 'device select $deviceId', - r"breakpoint set --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'", + 'device select $_deviceId', + r"breakpoint set --auto-continue true --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'", ]; const errorText = "error: 'device' is not a valid command.\n"; @@ -347,10 +312,11 @@ Target 0: (Runner) stopped. }); final bool success = await lldb.attachAndStart( - deviceId: deviceId, - appProcessId: appProcessId, + deviceId: _deviceId, + appProcessId: _appProcessId, lldbLogForwarder: FakeLLDBLogForwarder(), mode: BuildMode.debug, + deviceSupport: createDeviceSupport(), ); expect(success, isFalse); expect(lldb.isRunning, isFalse); @@ -361,9 +327,6 @@ Target 0: (Runner) stopped. }); testWithoutContext('attachAndStart prints warning if takes too long', () async { - const deviceId = '123'; - const appProcessId = 5678; - final stdinController = StreamController>(); final processCompleter = Completer(); @@ -383,6 +346,7 @@ Target 0: (Runner) stopped. logger: logger, processUtils: processUtils, xcodeProjectInterpreter: FakeXcodeProjectInterpreter(), + deviceVersion: Version(16, 0, 0), ); final completer = Completer(); @@ -397,10 +361,11 @@ Target 0: (Runner) stopped. await FakeAsync().run((FakeAsync time) { lldb.attachAndStart( - deviceId: deviceId, - appProcessId: appProcessId, + deviceId: _deviceId, + appProcessId: _appProcessId, lldbLogForwarder: FakeLLDBLogForwarder(), mode: BuildMode.debug, + deviceSupport: createDeviceSupport(), ); time.elapse(const Duration(minutes: 2)); time.flushMicrotasks(); @@ -408,27 +373,25 @@ Target 0: (Runner) stopped. }); expect( - logger.errorText, + logger.warningText, contains('LLDB is taking longer than expected to start debugging the app'), ); }); testWithoutContext('attachAndStart streams logs to LLDBLogForwarder', () async { - const deviceId = '123'; - const appProcessId = 5678; - const breakpointId = 123; - final breakPointCompleter = Completer>(); final processAttachCompleter = Completer>(); final setupStopHooksCompleter = Completer>(); - final processResumedCompleted = Completer>(); + final platformStatusCompleter = Completer>(); + final processResumedCompleter = Completer>(); final logAfterAttachCompleter = Completer>(); final stdoutStream = Stream>.fromFutures([ breakPointCompleter.future, processAttachCompleter.future, setupStopHooksCompleter.future, - processResumedCompleted.future, + platformStatusCompleter.future, + processResumedCompleter.future, logAfterAttachCompleter.future, ]); @@ -451,51 +414,27 @@ Target 0: (Runner) stopped. logger: logger, processUtils: processUtils, xcodeProjectInterpreter: FakeXcodeProjectInterpreter(), + deviceVersion: Version(16, 0, 0), ); - const breakPointMatcher = r"breakpoint set --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'"; - const processAttachMatcher = 'device process attach --pid $appProcessId'; - const processResumedMatcher = 'process continue'; - const setupStopHooksMatcher = 'target stop-hook add -o "thread backtrace all" -o "detach"'; - final expectedInputs = [ - 'device select $deviceId', - breakPointMatcher, - 'breakpoint command add --script-type python $breakpointId', - 'script lldb.debugger.SetAsync(False)', - processAttachMatcher, - setupStopHooksMatcher, - processResumedMatcher, - ]; + final Map> completer, String out})?> + inputsAndOutputs = buildAttachInputsAndOutputs( + breakPointMatcher: + r"breakpoint set --auto-continue true --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'", + processResumingOutput: '1 location added to breakpoint 1\n', + breakPointCompleter: breakPointCompleter, + processAttachCompleter: processAttachCompleter, + setupStopHooksCompleter: setupStopHooksCompleter, + platformStatusCompleter: platformStatusCompleter, + processResumedCompleter: processResumedCompleter, + ); 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 == setupStopHooksMatcher) { - setupStopHooksCompleter.complete(utf8.encode('Stop hook #1 added.\n')); - } - if (line == processResumedMatcher) { - processResumedCompleted.complete(utf8.encode('1 location added to breakpoint 1\n')); + final ({Completer> completer, String out})? x = inputsAndOutputs.remove(line); + if (x != null) { + x.completer.complete(utf8.encode(x.out)); } }); @@ -504,10 +443,11 @@ Target 0: (Runner) stopped. final lldbLogForwarder = FakeLLDBLogForwarder(expectedLog: expectedForwardedLog); final bool success = await lldb.attachAndStart( - deviceId: deviceId, - appProcessId: appProcessId, + deviceId: _deviceId, + appProcessId: _appProcessId, lldbLogForwarder: lldbLogForwarder, mode: BuildMode.debug, + deviceSupport: createDeviceSupport(), ); logAfterAttachCompleter.complete(utf8.encode('$ignoreLog\n$expectedForwardedLog\n')); @@ -515,8 +455,8 @@ Target 0: (Runner) stopped. expect(success, isTrue); expect(lldb.isRunning, isTrue); - expect(lldb.appProcessId, appProcessId); - expect(expectedInputs, isEmpty); + expect(lldb.appProcessId, _appProcessId); + expect(inputsAndOutputs, isEmpty); expect(processManager.hasRemainingExpectations, isFalse); expect(logger.errorText, isEmpty); expect(lldbLogForwarder.logs.length, 1); @@ -524,9 +464,6 @@ Target 0: (Runner) stopped. }); testWithoutContext('exit returns true and kills process', () async { - const deviceId = '123'; - const appProcessId = 5678; - final stdinController = StreamController>(); final processCompleter = Completer(); @@ -546,6 +483,7 @@ Target 0: (Runner) stopped. logger: logger, processUtils: processUtils, xcodeProjectInterpreter: FakeXcodeProjectInterpreter(), + deviceVersion: Version(16, 0, 0), ); final lldbStarted = Completer(); @@ -560,10 +498,11 @@ Target 0: (Runner) stopped. unawaited( lldb.attachAndStart( - deviceId: deviceId, - appProcessId: appProcessId, + deviceId: _deviceId, + appProcessId: _appProcessId, lldbLogForwarder: FakeLLDBLogForwarder(), mode: BuildMode.debug, + deviceSupport: createDeviceSupport(), ), ); @@ -585,6 +524,7 @@ Target 0: (Runner) stopped. logger: logger, processUtils: processUtils, xcodeProjectInterpreter: FakeXcodeProjectInterpreter(), + deviceVersion: Version(16, 0, 0), ); expect(lldb.isRunning, isFalse); final bool exitStatus = lldb.exit(); @@ -593,6 +533,199 @@ Target 0: (Runner) stopped. expect(lldb.appProcessId, isNull); }); + group('addSymbolSearchPaths', () { + testWithoutContext( + 'sends platform select remote-ios sysroot for arch symbol directory', + () async { + final fileSystem = MemoryFileSystem.test(); + final Directory homeDir = fileSystem.directory('/Users/username'); + final Directory archSymbols = + homeDir + .childDirectory('Library') + .childDirectory('Developer') + .childDirectory('Xcode') + .childDirectory('iOS DeviceSupport') + .childDirectory('iPhone15,2 17.0') + .childDirectory('arm64e') + .childDirectory('Symbols') + ..createSync(recursive: true); + + final platformSelectCompleter = Completer>(); + final processAttachCompleter = Completer>(); + final setupStopHooksCompleter = Completer>(); + final platformStatusCompleter = Completer>(); + final processResumedCompleter = Completer>(); + + final stdoutStream = Stream>.fromFutures([ + platformSelectCompleter.future, + processAttachCompleter.future, + setupStopHooksCompleter.future, + platformStatusCompleter.future, + processResumedCompleter.future, + ]); + + final stdinController = StreamController>(); + + final processCompleter = Completer(); + final lldbCommand = FakeLLDBCommand( + command: const ['xcrun', '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, + xcodeProjectInterpreter: FakeXcodeProjectInterpreter(), + deviceVersion: Version(16, 0, 0), + ); + + final Map> completer, String out})?> + inputsAndOutputs = buildAttachInputsAndOutputs( + breakPointMatcher: + r"breakpoint set --auto-continue true --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'", + processResumingOutput: 'Process $_appProcessId resuming\n', + breakPointCompleter: null, + processAttachCompleter: processAttachCompleter, + setupStopHooksCompleter: setupStopHooksCompleter, + platformStatusCompleter: platformStatusCompleter, + processResumedCompleter: processResumedCompleter, + ); + inputsAndOutputs.addAll({ + 'platform select remote-ios --sysroot "${archSymbols.path}"': null, + }); + + stdinController.stream + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((String line) { + final ({Completer> completer, String out})? x = inputsAndOutputs.remove( + line, + ); + if (x != null) { + x.completer.complete(utf8.encode(x.out)); + } + }); + + final bool success = await lldb.attachAndStart( + deviceId: _deviceId, + appProcessId: _appProcessId, + lldbLogForwarder: FakeLLDBLogForwarder(), + mode: BuildMode.profile, + deviceSupport: createDeviceSupport( + modelCode: 'iPhone15,2', + operatingSystemVersion: '17.0', + cpuArchitectureString: 'arm64e', + homeDirectory: homeDir, + ), + ); + + expect(success, isTrue); + expect(inputsAndOutputs, isEmpty); + }, + ); + + testWithoutContext( + 'sends platform select remote-ios sysroot for non-arch symbol directory', + () async { + final fileSystem = MemoryFileSystem.test(); + final Directory homeDir = fileSystem.directory('/Users/username'); + final Directory symbols = + homeDir + .childDirectory('Library') + .childDirectory('Developer') + .childDirectory('Xcode') + .childDirectory('iOS DeviceSupport') + .childDirectory('iPhone15,2 17.0') + .childDirectory('Symbols') + ..createSync(recursive: true); + + final platformSelectCompleter = Completer>(); + final processAttachCompleter = Completer>(); + final setupStopHooksCompleter = Completer>(); + final platformStatusCompleter = Completer>(); + final processResumedCompleter = Completer>(); + + final stdoutStream = Stream>.fromFutures([ + platformSelectCompleter.future, + processAttachCompleter.future, + setupStopHooksCompleter.future, + platformStatusCompleter.future, + processResumedCompleter.future, + ]); + + final stdinController = StreamController>(); + + final processCompleter = Completer(); + final lldbCommand = FakeLLDBCommand( + command: const ['xcrun', '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, + xcodeProjectInterpreter: FakeXcodeProjectInterpreter(), + deviceVersion: Version(16, 0, 0), + ); + + final Map> completer, String out})?> + inputsAndOutputs = buildAttachInputsAndOutputs( + breakPointMatcher: + r"breakpoint set --auto-continue true --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'", + processResumingOutput: 'Process $_appProcessId resuming\n', + breakPointCompleter: null, + processAttachCompleter: processAttachCompleter, + setupStopHooksCompleter: setupStopHooksCompleter, + platformStatusCompleter: platformStatusCompleter, + processResumedCompleter: processResumedCompleter, + ); + inputsAndOutputs.addAll({'platform select remote-ios --sysroot "${symbols.path}"': null}); + + stdinController.stream + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen((String line) { + final ({Completer> completer, String out})? x = inputsAndOutputs.remove( + line, + ); + if (x != null) { + x.completer.complete(utf8.encode(x.out)); + } + }); + + final bool success = await lldb.attachAndStart( + deviceId: _deviceId, + appProcessId: _appProcessId, + lldbLogForwarder: FakeLLDBLogForwarder(), + mode: BuildMode.profile, + deviceSupport: createDeviceSupport( + modelCode: 'iPhone15,2', + operatingSystemVersion: '17.0', + cpuArchitectureString: 'arm64e', + homeDirectory: homeDir, + ), + ); + + expect(success, isTrue); + expect(inputsAndOutputs, isEmpty); + }, + ); + }); + group('LLDBLogForwarder', () { testWithoutContext('addLog', () async { const expectedLog = 'hello world'; @@ -627,6 +760,145 @@ Target 0: (Runner) stopped. lldbLogForwarder.addLog('hello world'); }); }); + + testWithoutContext('Stops are handled manually for iOS 27+', () async { + final breakPointCompleter = Completer>(); + final processAttachCompleter = Completer>(); + final platformStatusCompleter = Completer>(); + final processResumedCompleter = Completer>(); + final breakpointStopCompleter = Completer>(); + final breakpointContinueCompleter = Completer>(); + final normalLogCompleter = Completer>(); + final crashCompleter = Completer>(); + final backtraceCompleter = Completer(); + final detachCompleter = Completer(); + + var isAttached = false; + + final stdoutStream = Stream>.fromFutures([ + breakPointCompleter.future, + processAttachCompleter.future, + platformStatusCompleter.future, + processResumedCompleter.future.whenComplete(() => isAttached = true), + breakpointStopCompleter.future, + breakpointContinueCompleter.future, + normalLogCompleter.future, + crashCompleter.future, + ]); + + final stdinController = StreamController>(); + + final processCompleter = Completer(); + final lldbCommand = FakeLLDBCommand( + command: const ['xcrun', '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, + xcodeProjectInterpreter: FakeXcodeProjectInterpreter(), + deviceVersion: Version(27, 0, 0), + ); + + const breakPointMatcher = r"breakpoint set --func-regex '^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$'"; + final unexpectedInputs = ['Stop hook #1 added.\n']; + final Map> completer, String out})?> inputsAndOutputs = + buildAttachInputsAndOutputs( + breakPointMatcher: breakPointMatcher, + processResumingOutput: '1 location added to breakpoint 1\n', + breakPointCompleter: breakPointCompleter, + processAttachCompleter: processAttachCompleter, + platformStatusCompleter: platformStatusCompleter, + processResumedCompleter: processResumedCompleter, + setupStopHooksCompleter: null, + ); + + stdinController.stream.transform(utf8.decoder).transform(const LineSplitter()).listen(( + String line, + ) { + final ({Completer> completer, String out})? x = inputsAndOutputs.remove(line); + if (x != null) { + x.completer.complete(utf8.encode(x.out)); + } + expect(unexpectedInputs.contains(line), isFalse); + if (isAttached && line == 'process continue') { + breakpointContinueCompleter.complete(utf8.encode('Process $_appProcessId resuming\n')); + } + if (line == 'thread backtrace all') { + backtraceCompleter.complete(); + } + if (line == 'detach') { + detachCompleter.complete(); + } + }); + + final lldbLogForwarder = FakeLLDBLogForwarder(); + final bool success = await lldb.attachAndStart( + deviceId: _deviceId, + appProcessId: _appProcessId, + lldbLogForwarder: lldbLogForwarder, + mode: BuildMode.debug, + deviceSupport: createDeviceSupport(), + ); + expect(success, isTrue); + expect(inputsAndOutputs, isEmpty); + + // Simulate a breakpoint stop after attached. + breakpointStopCompleter.complete( + utf8.encode(''' +Process $_appProcessId stopped +* thread #1, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1 + frame #0: 0x0000000107996d18 Flutter`NOTIFY_DEBUGGER_ABOUT_RX_PAGES +Flutter`NOTIFY_DEBUGGER_ABOUT_RX_PAGES: +-> 0x107996d18 <+0>: ret + +Flutter`dart::VirtualMemory::AllocateAligned: + 0x107996d1c <+0>: stp x28, x27, [sp, #-0x50]! + 0x107996d20 <+4>: stp x24, x23, [sp, #0x10] + 0x107996d24 <+8>: stp x22, x21, [sp, #0x20] +Target 0: (Flutter Gallery) stopped. +'''), + ); + await breakpointContinueCompleter.future; + + // Verify Breakpoint stop logs should not be printed. + expect(lldbLogForwarder.logs, isEmpty); + expect(logger.traceText, isNot(contains('NOTIFY_DEBUGGER_ABOUT_RX_PAGES'))); + + // Verify normal logs are printed + normalLogCompleter.complete(utf8.encode('Hello World\n')); + await pumpEventQueue(); + expect(lldbLogForwarder.logs, contains('Hello World')); + + // Simulate a crash stop while attached. + crashCompleter.complete( + utf8.encode(''' +Process $_appProcessId stopped +* thread #1, stop reason = EXC_BAD_ACCESS (code=1, address=0x0) + frame #0: 0x0000000102c7b240 my_crashed_code +Target 0: (Runner) stopped. +'''), + ); + + await backtraceCompleter.future; + await detachCompleter.future; + + // Verify crash logs are printed + expect(lldbLogForwarder.logs, contains('Process $_appProcessId stopped')); + expect( + lldbLogForwarder.logs, + contains('* thread #1, stop reason = EXC_BAD_ACCESS (code=1, address=0x0)'), + ); + expect(lldbLogForwarder.logs, contains('Target 0: (Runner) stopped.')); + }); } class FakeLLDBProcessManager extends Fake implements ProcessManager { @@ -825,3 +1097,73 @@ class FakeLLDBLogForwarder extends Fake implements LLDBLogForwarder { } } } + +IOSDeviceSupport createDeviceSupport({ + Logger? logger, + ProcessUtils? processUtils, + Directory? homeDirectory, + String? modelCode, + String? operatingSystemVersion, + String? cpuArchitectureString, + String deviceId = '123', +}) { + final Logger testLogger = logger ?? BufferLogger.test(); + return IOSDeviceSupport( + logger: testLogger, + processUtils: + processUtils ?? + ProcessUtils(processManager: FakeProcessManager.empty(), logger: testLogger), + xcode: null, + homeDirectory: homeDirectory, + modelCode: modelCode, + operatingSystemVersion: operatingSystemVersion, + cpuArchitectureString: cpuArchitectureString, + deviceId: _deviceId, + ); +} + +/// Builds a map of expected stdin command inputs sent to LLDB during [LLDB.attachAndStart] +/// and their corresponding simulated stdout outputs and completers. +Map> completer, String out})?> buildAttachInputsAndOutputs({ + required String breakPointMatcher, + required String processResumingOutput, + required Completer>? breakPointCompleter, + required Completer> processAttachCompleter, + required Completer> platformStatusCompleter, + required Completer> processResumedCompleter, + required Completer>? setupStopHooksCompleter, +}) { + const processAttachMatcher = 'device process attach --pid $_appProcessId'; + const processContinueMatcher = 'process continue'; + const setupStopHooksMatcher = 'target stop-hook add -o "thread backtrace all" -o "detach"'; + const platformStatusMatcher = 'platform status'; + return { + 'device select $_deviceId': null, + if (breakPointCompleter != null) ...{ + breakPointMatcher: ( + out: 'Breakpoint $_breakpointId: no locations (pending).\n', + completer: breakPointCompleter, + ), + 'breakpoint command add --script-type python $_breakpointId': null, + 'script lldb.debugger.SetAsync(False)': null, + }, + processAttachMatcher: ( + out: ''' +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. +''', + completer: processAttachCompleter, + ), + if (setupStopHooksCompleter != null) + setupStopHooksMatcher: (out: 'Stop hook #1 added.\n', completer: setupStopHooksCompleter), + platformStatusMatcher: (out: ' Platform: remote-ios\n', completer: platformStatusCompleter), + processContinueMatcher: (out: processResumingOutput, completer: processResumedCompleter), + }; +} diff --git a/packages/flutter_tools/test/general.shard/ios/mac_test.dart b/packages/flutter_tools/test/general.shard/ios/mac_test.dart index 620e20c2fe094..c36e9c96c2222 100644 --- a/packages/flutter_tools/test/general.shard/ios/mac_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/mac_test.dart @@ -487,6 +487,19 @@ Could not build the precompiled application for the device.''', 'plugin_1_name, plugin_2_name.', ), ); + expect( + fakeAnalytics.sentEvents, + containsAll([ + Event.appleUsageEvent( + workflow: 'duplicate-modules-build-failure', + parameter: 'plugin_1_name', + ), + Event.appleUsageEvent( + workflow: 'duplicate-modules-build-failure', + parameter: 'plugin_2_name', + ), + ]), + ); }); testWithoutContext('parses duplicate symbols error with arch and number', () async { @@ -533,6 +546,15 @@ duplicate symbol '_$s29plugin_1_name23PluginNamePluginC9setDouble3key5valueySS_S 'plugin_1_name.', ), ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'duplicate-modules-build-failure', + parameter: 'plugin_1_name', + ), + ), + ); }); testWithoutContext('parses duplicate symbols error with number', () async { @@ -576,6 +598,15 @@ duplicate symbol '_$s29plugin_1_name23PluginNamePluginC9setDouble3key5valueySS_S 'plugin_1_name.', ), ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'duplicate-modules-build-failure', + parameter: 'plugin_1_name', + ), + ), + ); }); testWithoutContext('parses duplicate symbols error without arch and number', () async { @@ -618,6 +649,15 @@ duplicate symbol '_$s29plugin_1_name23PluginNamePluginC9setDouble3key5valueySS_S 'plugin_1_name.', ), ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'duplicate-modules-build-failure', + parameter: 'plugin_1_name', + ), + ), + ); }); testUsingContext( @@ -1412,7 +1452,7 @@ class FakeXcodeProjectInterpreter extends Fake implements XcodeProjectInterprete Future> fetchDependenciesAndGenerateXcodebuildArgs( XcodeBasedProject xcodeProject, Directory buildDirectory, { - bool skipPackageUpdatesAndValidation = true, + bool skipPackageValidation = true, }) async { return ['xcrun', 'xcodebuild']; } diff --git a/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart b/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart index a06c451fd999f..cb1a06c60b6ea 100644 --- a/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart @@ -279,7 +279,6 @@ void main() { 'xcodebuild', '-clonedSourcePackagesDirPath', '/build/ios/SourcePackages', - '-skipPackageUpdates', '-skipPackagePluginValidation', '-skipPackageSignatureValidation', '-project', @@ -325,7 +324,6 @@ void main() { 'xcodebuild', '-clonedSourcePackagesDirPath', '/build/ios/SourcePackages', - '-skipPackageUpdates', '-skipPackagePluginValidation', '-skipPackageSignatureValidation', '-project', @@ -371,7 +369,6 @@ void main() { 'xcodebuild', '-clonedSourcePackagesDirPath', '/build/ios/SourcePackages', - '-skipPackageUpdates', '-skipPackagePluginValidation', '-skipPackageSignatureValidation', '-project', @@ -417,7 +414,6 @@ void main() { 'xcodebuild', '-clonedSourcePackagesDirPath', '/build/ios/SourcePackages', - '-skipPackageUpdates', '-skipPackagePluginValidation', '-skipPackageSignatureValidation', '-project', @@ -463,7 +459,6 @@ void main() { 'xcodebuild', '-clonedSourcePackagesDirPath', '/build/ios/SourcePackages', - '-skipPackageUpdates', '-skipPackagePluginValidation', '-skipPackageSignatureValidation', '-project', @@ -507,7 +502,6 @@ void main() { 'xcodebuild', '-clonedSourcePackagesDirPath', '/build/ios/SourcePackages', - '-skipPackageUpdates', '-skipPackagePluginValidation', '-skipPackageSignatureValidation', '-project', @@ -559,7 +553,6 @@ void main() { 'xcodebuild', '-clonedSourcePackagesDirPath', '/build/macos/SourcePackages', - '-skipPackageUpdates', '-skipPackagePluginValidation', '-skipPackageSignatureValidation', '-project', @@ -605,7 +598,6 @@ void main() { 'xcodebuild', '-clonedSourcePackagesDirPath', '/build/ios/SourcePackages', - '-skipPackageUpdates', '-skipPackagePluginValidation', '-skipPackageSignatureValidation', '-workspace', @@ -644,7 +636,6 @@ void main() { 'xcodebuild', '-clonedSourcePackagesDirPath', '/build/ios/SourcePackages', - '-skipPackageUpdates', '-skipPackagePluginValidation', '-skipPackageSignatureValidation', '-list', @@ -688,7 +679,6 @@ void main() { 'xcodebuild', '-clonedSourcePackagesDirPath', '/build/ios/SourcePackages', - '-skipPackageUpdates', '-skipPackagePluginValidation', '-skipPackageSignatureValidation', '-list', @@ -734,7 +724,6 @@ void main() { 'xcodebuild', '-clonedSourcePackagesDirPath', '/build/ios/SourcePackages', - '-skipPackageUpdates', '-skipPackagePluginValidation', '-skipPackageSignatureValidation', '-list', @@ -763,6 +752,99 @@ void main() { }, ); + testWithoutContext( + 'xcodebuild -list getInfo throws a tool exit when xcodebuild is missing (exit code 72)', + () async { + const workingDirectory = '/'; + final Directory buildDirectory = fileSystem.directory('build/ios'); + const stderr = + 'xcrun: error: unable to find utility "xcodebuild", not a developer tool or in PATH'; + + fakeProcessManager.addCommands(const [ + kWhichSysctlCommand, + kx64CheckCommand, + kResolvePackagesCommand, + FakeCommand( + command: [ + 'xcrun', + 'xcodebuild', + '-clonedSourcePackagesDirPath', + '/build/ios/SourcePackages', + '-skipPackagePluginValidation', + '-skipPackageSignatureValidation', + '-list', + ], + exitCode: 72, + stderr: stderr, + ), + ]); + + final xcodeProjectInterpreter = XcodeProjectInterpreter( + logger: logger, + fileSystem: fileSystem, + platform: platform, + processManager: fakeProcessManager, + analytics: const NoOpAnalytics(), + ); + + await expectLater( + () => xcodeProjectInterpreter.getInfo( + FakeXcodeBasedProject(workingDirectory, fileSystem), + buildDirectory: buildDirectory, + ), + throwsToolExit(message: stderr), + ); + expect(fakeProcessManager, hasNoRemainingExpectations); + }, + ); + + testWithoutContext( + 'xcodebuild -list getInfo throws a tool exit when process execution throws ProcessException', + () async { + const workingDirectory = '/'; + final Directory buildDirectory = fileSystem.directory('build/ios'); + const errorMessage = 'xcrun: error: unable to find utility "xcodebuild"'; + const processException = ProcessException('xcrun', ['xcodebuild'], errorMessage, 72); + + fakeProcessManager.addCommands([ + kWhichSysctlCommand, + kx64CheckCommand, + kResolvePackagesCommand, + FakeCommand( + command: const [ + 'xcrun', + 'xcodebuild', + '-clonedSourcePackagesDirPath', + '/build/ios/SourcePackages', + '-skipPackagePluginValidation', + '-skipPackageSignatureValidation', + '-list', + ], + onRun: (_) { + throw processException; + }, + ), + ]); + + final xcodeProjectInterpreter = XcodeProjectInterpreter( + logger: logger, + fileSystem: fileSystem, + platform: platform, + processManager: fakeProcessManager, + analytics: const NoOpAnalytics(), + ); + + await expectLater( + () => xcodeProjectInterpreter.getInfo( + FakeXcodeBasedProject(workingDirectory, fileSystem), + buildDirectory: buildDirectory, + ), + throwsToolExit(message: processException.toString()), + ); + expect(fakeProcessManager, hasNoRemainingExpectations); + }, + ); + testWithoutContext('Xcode project properties from default project can be parsed', () { const output = ''' Information about project "Runner": @@ -838,7 +920,6 @@ Information about project "Runner": 'xcodebuild', '-clonedSourcePackagesDirPath', '/build/ios/SourcePackages', - '-skipPackageUpdates', '-skipPackagePluginValidation', '-skipPackageSignatureValidation', '-list', diff --git a/packages/flutter_tools/test/general.shard/isolated/native_assets_test.dart b/packages/flutter_tools/test/general.shard/isolated/native_assets_test.dart index 6926e7ece5988..de19fba961649 100644 --- a/packages/flutter_tools/test/general.shard/isolated/native_assets_test.dart +++ b/packages/flutter_tools/test/general.shard/isolated/native_assets_test.dart @@ -393,6 +393,50 @@ CMAKE_LINKER:FILEPATH=/usr/bin/ld.ldd expect(target.didSetCCompilerConfig, isTrue); }, ); + + testUsingContext( + 'installCodeAssets cleans up existing stale files in target directory without crashing ' + '(regression test for https://github.com/flutter/flutter/issues/190234)', + overrides: {ProcessManager: () => FakeProcessManager.empty()}, + () async { + final File packageConfig = environment.projectDir.childFile('.dart_tool/package_config.json'); + final Uri nonFlutterTesterAssetUri = environment.buildDir + .childFile(InstallCodeAssets.nativeAssetsFilename) + .uri; + await packageConfig.parent.create(); + await packageConfig.create(); + + final environmentDefines = {kBuildMode: BuildMode.debug.cliName}; + final DartHooksResult dartHookResult = await runFlutterSpecificHooks( + environmentDefines: environmentDefines, + targetPlatform: TargetPlatform.windows_x64, + projectUri: projectUri, + fileSystem: fileSystem, + buildRunner: FakeFlutterNativeAssetsBuildRunner( + packagesWithNativeAssetsResult: ['bar'], + ), + buildCodeAssets: const BuildCodeAssetsOptions(appBuildDirectory: null), + buildDataAssets: true, + recordedUsesFile: null, + ); + final Directory targetDirectory = environment.buildDir.childDirectory('native_assets'); + await targetDirectory.create(recursive: true); + final File staleFile = targetDirectory.childFile('stale.txt'); + staleFile.writeAsStringSync('stale'); + + await installCodeAssets( + dartHookResult: dartHookResult, + environmentDefines: environmentDefines, + targetPlatform: TargetPlatform.windows_x64, + projectUri: projectUri, + fileSystem: fileSystem, + nativeAssetsFileUri: nonFlutterTesterAssetUri, + targetUri: targetDirectory.uri, + ); + expect(targetDirectory, exists); + expect(staleFile, isNot(exists)); + }, + ); } class _SetCCompilerConfigTarget extends FakeFlutterNativeAssetsBuildRunner { diff --git a/packages/flutter_tools/test/general.shard/macos/cocoapod_utils_test.dart b/packages/flutter_tools/test/general.shard/macos/cocoapod_utils_test.dart index b31b284e0ad5c..a2fc6b6204294 100644 --- a/packages/flutter_tools/test/general.shard/macos/cocoapod_utils_test.dart +++ b/packages/flutter_tools/test/general.shard/macos/cocoapod_utils_test.dart @@ -502,8 +502,11 @@ class FakeMacOSProject extends Fake implements MacOSProject { hostAppRoot.childDirectory('Runner.xcodeproj').childFile('project.pbxproj'); @override - Directory get flutterSwiftPackagesDirectory => - hostAppRoot.childDirectory('Flutter').childDirectory('ephemeral').childDirectory('Packages'); + Directory get ephemeralDirectory => + hostAppRoot.childDirectory('Flutter').childDirectory('ephemeral'); + + @override + Directory get flutterSwiftPackagesDirectory => ephemeralDirectory.childDirectory('Packages'); @override Directory get relativeSwiftPackagesDirectory => @@ -551,8 +554,11 @@ class FakeIosProject extends Fake implements IosProject { hostAppRoot.childDirectory('Runner.xcodeproj').childFile('project.pbxproj'); @override - Directory get flutterSwiftPackagesDirectory => - hostAppRoot.childDirectory('Flutter').childDirectory('ephemeral').childDirectory('Packages'); + Directory get ephemeralDirectory => + hostAppRoot.childDirectory('Flutter').childDirectory('ephemeral'); + + @override + Directory get flutterSwiftPackagesDirectory => ephemeralDirectory.childDirectory('Packages'); @override Directory get relativeSwiftPackagesDirectory => diff --git a/packages/flutter_tools/test/general.shard/macos/cocoapods_test.dart b/packages/flutter_tools/test/general.shard/macos/cocoapods_test.dart index 1c693c87d2592..745c49e74b54d 100644 --- a/packages/flutter_tools/test/general.shard/macos/cocoapods_test.dart +++ b/packages/flutter_tools/test/general.shard/macos/cocoapods_test.dart @@ -1625,6 +1625,16 @@ end'''); throwsToolExit(), ); expect(logger.errorText, contains('Error: A dependency conflict has occurred because')); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'cocoapod-swiftpm-interdependency-failure', + parameter: 'plugin_2_name', + result: 'plugin_1_name', + ), + ), + ); }, overrides: { FileSystem: () => fileSystem, diff --git a/packages/flutter_tools/test/general.shard/macos/darwin_dependency_management_test.dart b/packages/flutter_tools/test/general.shard/macos/darwin_dependency_management_test.dart index 6534a5882bd70..1401ba39dbe82 100644 --- a/packages/flutter_tools/test/general.shard/macos/darwin_dependency_management_test.dart +++ b/packages/flutter_tools/test/general.shard/macos/darwin_dependency_management_test.dart @@ -1100,6 +1100,10 @@ flutter: final fileSystem = MemoryFileSystem.test(); final logger = BufferLogger.test(); final cocoapods = FakeCocoaPods(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: fileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeIosProject(fileSystem: fileSystem, usesSwiftPackageManager: true); final plugins = [FakePlugin(name: 'plugin', platforms: {})]; @@ -1110,20 +1114,22 @@ flutter: fileSystem: fileSystem, logger: logger, cocoapods: cocoapods, + analytics: fakeAnalytics, ); expect(logger.warningText, isEmpty); + expect(fakeAnalytics.sentEvents, isEmpty); }); testWithoutContext('filters plugins for the correct implementation', () async { final fileSystem = MemoryFileSystem.test(); final logger = BufferLogger.test(); final cocoapods = FakeCocoaPods(); - final project = FakeIosProject( - fileSystem: fileSystem, - usesSwiftPackageManager: false, - compatibleWithSwiftPackageManager: true, + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: fileSystem, + fakeFlutterVersion: FakeFlutterVersion(), ); + final project = FakeIosProject(fileSystem: fileSystem, usesSwiftPackageManager: true); final appFacingPlugin = Plugin( name: 'foo', @@ -1199,15 +1205,21 @@ flutter: fileSystem: fileSystem, logger: logger, cocoapods: cocoapods, + analytics: fakeAnalytics, ); expect(logger.warningText, isEmpty); expect(logger.errorText, isEmpty); + expect(fakeAnalytics.sentEvents, isEmpty); }); testWithoutContext('when plugin supports platform but has no podspec or manifest', () async { final fileSystem = MemoryFileSystem.test(); final logger = BufferLogger.test(); final cocoapods = FakeCocoaPods(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: fileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeIosProject(fileSystem: fileSystem, usesSwiftPackageManager: true); final plugins = [ FakePlugin( @@ -1223,15 +1235,21 @@ flutter: fileSystem: fileSystem, logger: logger, cocoapods: cocoapods, + analytics: fakeAnalytics, ); expect(logger.warningText, isEmpty); + expect(fakeAnalytics.sentEvents, isEmpty); }); testWithoutContext('throws when using SwiftPM-only plugin but SwiftPM is disabled', () async { final fileSystem = MemoryFileSystem.test(); final logger = BufferLogger.test(); final cocoapods = FakeCocoaPods(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: fileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeIosProject( fileSystem: fileSystem, usesSwiftPackageManager: false, @@ -1253,6 +1271,7 @@ flutter: fileSystem: fileSystem, logger: logger, cocoapods: cocoapods, + analytics: fakeAnalytics, ), throwsToolExit( message: @@ -1271,6 +1290,10 @@ flutter: final fileSystem = MemoryFileSystem.test(); final logger = BufferLogger.test(); final cocoapods = FakeCocoaPods(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: fileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeIosProject( fileSystem: fileSystem, usesSwiftPackageManager: false, @@ -1292,6 +1315,7 @@ flutter: fileSystem: fileSystem, logger: logger, cocoapods: cocoapods, + analytics: fakeAnalytics, ), throwsToolExit( message: @@ -1310,6 +1334,10 @@ flutter: final fileSystem = MemoryFileSystem.test(); final logger = BufferLogger.test(); final cocoapods = FakeCocoaPods(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: fileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeIosProject(fileSystem: fileSystem, usesSwiftPackageManager: true); final plugins = [ FakePlugin( @@ -1326,6 +1354,7 @@ flutter: fileSystem: fileSystem, logger: logger, cocoapods: cocoapods, + analytics: fakeAnalytics, ); expect( @@ -1335,11 +1364,67 @@ flutter: 'This will become an error in a future version of Flutter. Please contact the plugin ' 'maintainers to request Swift Package Manager adoption.\n', ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent(workflow: 'cocoapod-only-plugin-warning', parameter: 'plugin'), + ), + ); + }); + + testWithoutContext('warns and sends analytics for multiple CocoaPod-only plugins', () async { + final fileSystem = MemoryFileSystem.test(); + final logger = BufferLogger.test(); + final cocoapods = FakeCocoaPods(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: fileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); + final project = FakeIosProject(fileSystem: fileSystem, usesSwiftPackageManager: true); + final plugins = [ + FakePlugin( + name: 'plugin_a', + platforms: {IOSPlugin.kConfigKey: FakePluginPlatform()}, + pluginPodspecPath: '/plugin_a/ios/plugin_a.podspec', + ), + FakePlugin( + name: 'plugin_b', + platforms: {IOSPlugin.kConfigKey: FakePluginPlatform()}, + pluginPodspecPath: '/plugin_b/ios/plugin_b.podspec', + ), + ]; + + await DarwinDependencyManagement.validatePluginSupport( + platform: FlutterDarwinPlatform.ios, + xcodeProject: project, + plugins: plugins, + fileSystem: fileSystem, + logger: logger, + cocoapods: cocoapods, + analytics: fakeAnalytics, + ); + + expect( + logger.warningText, + 'The following plugins do not support Swift Package Manager for ios:\n' + ' - plugin_a\n' + ' - plugin_b\n' + 'This will become an error in a future version of Flutter. Please contact the plugin ' + 'maintainers to request Swift Package Manager adoption.\n', + ); + expect(fakeAnalytics.sentEvents, [ + Event.appleUsageEvent(workflow: 'cocoapod-only-plugin-warning', parameter: 'plugin_a'), + Event.appleUsageEvent(workflow: 'cocoapod-only-plugin-warning', parameter: 'plugin_b'), + ]); }); testWithoutContext('warns when CocoaPod is removeable and podfile matches original', () async { final fileSystem = MemoryFileSystem.test(); final logger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: fileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeIosProject(fileSystem: fileSystem, usesSwiftPackageManager: true); project.xcodeProjectInfoFile.createSync(recursive: true); project.xcodeProjectInfoFile.writeAsStringSync('FlutterGeneratedPluginSwiftPackage'); @@ -1368,6 +1453,7 @@ flutter: fileSystem: fileSystem, logger: logger, cocoapods: cocoapods, + analytics: fakeAnalytics, ); expect( @@ -1378,11 +1464,16 @@ flutter: ' * Also in the ios/ directory, delete the Podfile\n\n' "Removing CocoaPods integration will improve the project's build time.\n", ); + expect(fakeAnalytics.sentEvents, isEmpty); }); testWithoutContext('warns when CocoaPod is removeable and podfile does not match original', () async { final fileSystem = MemoryFileSystem.test(); final logger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: fileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeIosProject(fileSystem: fileSystem, usesSwiftPackageManager: true); project.xcodeProjectInfoFile.createSync(recursive: true); project.xcodeProjectInfoFile.writeAsStringSync('FlutterGeneratedPluginSwiftPackage'); @@ -1411,6 +1502,7 @@ flutter: fileSystem: fileSystem, logger: logger, cocoapods: cocoapods, + analytics: fakeAnalytics, ); expect( @@ -1423,11 +1515,16 @@ flutter: ' * Transition any custom logic\n\n' "Removing CocoaPods integration will improve the project's build time.\n", ); + expect(fakeAnalytics.sentEvents, isEmpty); }); testWithoutContext('warning when CocoaPod is removeable includes step to edit xcconfig', () async { final fileSystem = MemoryFileSystem.test(); final logger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: fileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeIosProject(fileSystem: fileSystem, usesSwiftPackageManager: true); project.xcodeProjectInfoFile.createSync(recursive: true); project.xcodeProjectInfoFile.writeAsStringSync('FlutterGeneratedPluginSwiftPackage'); @@ -1455,6 +1552,7 @@ flutter: fileSystem: fileSystem, logger: logger, cocoapods: cocoapods, + analytics: fakeAnalytics, ); expect( @@ -1467,6 +1565,7 @@ flutter: ' * Remove the include to "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" in your ios/Flutter/Release.xcconfig\n\n' "Removing CocoaPods integration will improve the project's build time.\n", ); + expect(fakeAnalytics.sentEvents, isEmpty); }); }); } diff --git a/packages/flutter_tools/test/general.shard/macos/swift_package_manager_test.dart b/packages/flutter_tools/test/general.shard/macos/swift_package_manager_test.dart index c928b0c37bbae..8ef2f11e06a61 100644 --- a/packages/flutter_tools/test/general.shard/macos/swift_package_manager_test.dart +++ b/packages/flutter_tools/test/general.shard/macos/swift_package_manager_test.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:convert'; import 'dart:io' as io; import 'package:file/file.dart'; @@ -738,6 +739,173 @@ let package = Package( ); }); }); + + group('concurrency and optimization', () { + late MemoryFileSystem fs; + late LockTrackingFileSystem trackingFs; + late FakeProcessManager processManager; + late BufferLogger logger; + late FakeXcodeProject project; + late SwiftPackageManager spm; + + setUp(() { + fs = MemoryFileSystem.test(); + trackingFs = LockTrackingFileSystem(fs); + processManager = FakeProcessManager.any(); + logger = BufferLogger.test(); + project = FakeXcodeProject(platform: platform.name, fileSystem: trackingFs); + + spm = SwiftPackageManager( + fileSystem: trackingFs, + templateRenderer: const MustacheTemplateRenderer(), + processUtils: ProcessUtils(processManager: processManager, logger: logger), + config: FakeConfig(), + logger: logger, + ); + }); + + testWithoutContext('acquires and releases lock successfully', () async { + project.xcodeProjectInfoFile.createSync(recursive: true); + project.xcodeProjectInfoFile.writeAsStringSync('FlutterGeneratedPluginSwiftPackage'); + + await spm.generatePluginsSwiftPackage([], platform, project); + + expect(trackingFs.lockCount, 1); + expect(trackingFs.unlockCount, 1); + expect(trackingFs.lockAttempts, 1); + }); + + testWithoutContext( + 'retries lock on FileSystemException and eventually succeeds', + () async { + project.xcodeProjectInfoFile.createSync(recursive: true); + project.xcodeProjectInfoFile.writeAsStringSync('FlutterGeneratedPluginSwiftPackage'); + + trackingFs.throwErrorOnLock = true; + trackingFs.throwErrorOnLockTimes = 2; // Fail twice, succeed on 3rd attempt + + await spm.generatePluginsSwiftPackage([], platform, project); + + expect(trackingFs.lockCount, 1); + expect(trackingFs.unlockCount, 3); // Closed on every retry + final release + expect(trackingFs.lockAttempts, 3); // 2 failures + 1 success + + // Verify the warning was printed to the logger + expect( + logger.warningText, + contains( + 'Waiting for another flutter command to release the Swift Package Manager lock...', + ), + ); + }, + ); + + testWithoutContext('proceeds without lock on UnimplementedError', () async { + project.xcodeProjectInfoFile.createSync(recursive: true); + project.xcodeProjectInfoFile.writeAsStringSync('FlutterGeneratedPluginSwiftPackage'); + + trackingFs.throwUnimplementedOnLock = true; + + await spm.generatePluginsSwiftPackage([], platform, project); + + expect(trackingFs.lockCount, 0); // No successful locks recorded + expect(trackingFs.unlockCount, 1); // Still closed + expect(trackingFs.lockAttempts, 1); // 1 attempt that threw + }); + + testWithoutContext( + 'non-destructive symlink update: preserves active, creates new, deletes obsolete', + () async { + project.xcodeProjectInfoFile.createSync(recursive: true); + project.xcodeProjectInfoFile.writeAsStringSync('FlutterGeneratedPluginSwiftPackage'); + + final plugin1 = FakePlugin( + name: 'plugin_1', + platforms: {platform.name: FakePluginPlatform()}, + ); + final plugin2 = FakePlugin( + name: 'plugin_2', + platforms: {platform.name: FakePluginPlatform()}, + ); + final plugin3 = FakePlugin( + name: 'plugin_3', + platforms: {platform.name: FakePluginPlatform()}, + ); + + // Pre-populate symlink directory with plugin_1 (active), plugin_3 (obsolete), and a stale file + final Directory symlinkDir = project.relativeSwiftPackagesDirectory; + symlinkDir.createSync(recursive: true); + + final Link link1 = symlinkDir.childLink('plugin_1-1.0.0'); + link1.createSync('${plugin1.path}/${platform.name}/plugin_1'); + + final Link link3 = symlinkDir.childLink('plugin_3-1.0.0'); + link3.createSync('${plugin3.path}/${platform.name}/plugin_3'); + + final File staleFile = symlinkDir.childFile('stale_file.txt'); + staleFile.createSync(); + staleFile.writeAsStringSync('stale content'); + + // Create fake Package.swift manifests for active plugins to prevent them from being skipped + trackingFs + .file('${plugin1.path}/${platform.name}/${plugin1.name}/Package.swift') + .createSync(recursive: true); + trackingFs + .file('${plugin2.path}/${platform.name}/${plugin2.name}/Package.swift') + .createSync(recursive: true); + + // Reset tracking counters after pre-population + trackingFs.linkCreateCount.clear(); + trackingFs.linkDeleteCount.clear(); + + // Run with plugin_1 and plugin_2 + await spm.generatePluginsSwiftPackage([plugin1, plugin2], platform, project); + + // Verify plugin_1 was preserved (not deleted, not recreated) + expect(trackingFs.linkDeleteCount['plugin_1-1.0.0'], isNull); + expect(trackingFs.linkCreateCount['plugin_1-1.0.0'], isNull); + + // Verify plugin_2 was created + expect(trackingFs.linkCreateCount['plugin_2-1.0.0'], 1); + + // Verify plugin_3 was deleted + expect(trackingFs.linkDeleteCount['plugin_3-1.0.0'], 1); + expect(symlinkDir.childLink('plugin_3-1.0.0').existsSync(), isFalse); + + // Verify stale file was deleted + expect(staleFile.existsSync(), isFalse); + }, + ); + + testWithoutContext( + 'optimizes Package.swift generation: does not rewrite if identical', + () async { + project.xcodeProjectInfoFile.createSync(recursive: true); + project.xcodeProjectInfoFile.writeAsStringSync('FlutterGeneratedPluginSwiftPackage'); + + final plugin1 = FakePlugin( + name: 'plugin_1', + platforms: {platform.name: FakePluginPlatform()}, + ); + trackingFs + .file('${plugin1.path}/${platform.name}/${plugin1.name}/Package.swift') + .createSync(recursive: true); + + // Reset write count after setup to only track writes during SPM generation + trackingFs.writeCount = 0; + + // First run: generates Package.swift and placeholders + await spm.generatePluginsSwiftPackage([plugin1], platform, project); + expect(trackingFs.writeCount, 4); // 2 Package.swift + 2 placeholder .swift files + + trackingFs.writeCount = 0; + + // Second run with same plugin: should skip writing everything + await spm.generatePluginsSwiftPackage([plugin1], platform, project); + expect(trackingFs.writeCount, 0); // All skipped! + }, + ); + }); }); } }); @@ -760,8 +928,11 @@ class FakeXcodeProject extends Fake implements IosProject { String hostAppProjectName = 'Runner'; @override - Directory get flutterSwiftPackagesDirectory => - hostAppRoot.childDirectory('Flutter').childDirectory('ephemeral').childDirectory('Packages'); + Directory get ephemeralDirectory => + hostAppRoot.childDirectory('Flutter').childDirectory('ephemeral'); + + @override + Directory get flutterSwiftPackagesDirectory => ephemeralDirectory.childDirectory('Packages'); @override Directory get relativeSwiftPackagesDirectory => @@ -870,3 +1041,163 @@ class _ErrorInjectingLink extends ForwardingFileSystemEntity with super.createSync(target, recursive: recursive); } } + +class LockTrackingFileSystem extends ForwardingFileSystem { + LockTrackingFileSystem(super.delegate); + + int lockCount = 0; + int unlockCount = 0; + int lockAttempts = 0; + int writeCount = 0; + bool throwErrorOnLock = false; + int throwErrorOnLockTimes = 0; + bool throwUnimplementedOnLock = false; + + final Map linkDeleteCount = {}; + final Map linkCreateCount = {}; + + @override + Directory directory(dynamic path) => _TrackingDirectory(this, delegate.directory(path)); + + @override + File file(dynamic path) => _TrackingFile(this, delegate.file(path)); + + @override + Link link(dynamic path) => _TrackingLink(this, delegate.link(path)); +} + +class _TrackingDirectory extends ForwardingFileSystemEntity + with ForwardingDirectory { + _TrackingDirectory(this._fileSystem, this.delegate); + + final LockTrackingFileSystem _fileSystem; + + @override + final io.Directory delegate; + + @override + FileSystem get fileSystem => _fileSystem; + + @override + File wrapFile(io.File delegate) => _fileSystem.file(delegate.path); + + @override + Directory wrapDirectory(io.Directory delegate) => _fileSystem.directory(delegate.path); + + @override + Link wrapLink(io.Link delegate) => _fileSystem.link(delegate.path); + + @override + Directory childDirectory(String basename) => + fileSystem.directory(fileSystem.path.join(path, basename)); + + @override + File childFile(String basename) => fileSystem.file(fileSystem.path.join(path, basename)); + + @override + Link childLink(String basename) => fileSystem.link(fileSystem.path.join(path, basename)); +} + +class _TrackingFile extends ForwardingFileSystemEntity with ForwardingFile { + _TrackingFile(this._fileSystem, this.delegate); + + final LockTrackingFileSystem _fileSystem; + + @override + final io.File delegate; + + @override + FileSystem get fileSystem => _fileSystem; + + @override + File wrapFile(io.File delegate) => _fileSystem.file(delegate.path); + + @override + Directory wrapDirectory(io.Directory delegate) => _fileSystem.directory(delegate.path); + + @override + Link wrapLink(io.Link delegate) => _fileSystem.link(delegate.path); + + @override + void writeAsStringSync( + String contents, { + FileMode mode = FileMode.write, + Encoding encoding = utf8, + bool flush = false, + }) { + _fileSystem.writeCount++; + super.writeAsStringSync(contents, mode: mode, encoding: encoding, flush: flush); + } + + @override + RandomAccessFile openSync({FileMode mode = FileMode.read}) { + final RandomAccessFile delegateOpened = super.openSync(mode: mode); + if (path.endsWith('.swift_pm.lock')) { + return _LockTrackingRandomAccessFile(_fileSystem, delegateOpened); + } + return delegateOpened; + } +} + +class _LockTrackingRandomAccessFile extends Fake implements RandomAccessFile { + _LockTrackingRandomAccessFile(this._fileSystem, this._delegate); + + final LockTrackingFileSystem _fileSystem; + final RandomAccessFile _delegate; + + @override + void lockSync([FileLock mode = FileLock.exclusive, int start = 0, int end = -1]) { + _fileSystem.lockAttempts++; + if (_fileSystem.throwUnimplementedOnLock) { + throw UnimplementedError('Lock not supported'); + } + if (_fileSystem.throwErrorOnLock) { + if (_fileSystem.throwErrorOnLockTimes > 0) { + _fileSystem.throwErrorOnLockTimes--; + throw const FileSystemException('Lock failed'); + } + } + _fileSystem.lockCount++; + } + + @override + void closeSync() { + _fileSystem.unlockCount++; + _delegate.closeSync(); + } +} + +class _TrackingLink extends ForwardingFileSystemEntity with ForwardingLink { + _TrackingLink(this._fileSystem, this.delegate); + + final LockTrackingFileSystem _fileSystem; + + @override + final io.Link delegate; + + @override + FileSystem get fileSystem => _fileSystem; + + @override + File wrapFile(io.File delegate) => _fileSystem.file(delegate.path); + + @override + Directory wrapDirectory(io.Directory delegate) => _fileSystem.directory(delegate.path); + + @override + Link wrapLink(io.Link delegate) => _fileSystem.link(delegate.path); + + @override + void createSync(String target, {bool recursive = false}) { + final String name = _fileSystem.path.basename(path); + _fileSystem.linkCreateCount[name] = (_fileSystem.linkCreateCount[name] ?? 0) + 1; + super.createSync(target, recursive: recursive); + } + + @override + void deleteSync({bool recursive = false}) { + final String name = _fileSystem.path.basename(path); + _fileSystem.linkDeleteCount[name] = (_fileSystem.linkDeleteCount[name] ?? 0) + 1; + super.deleteSync(recursive: recursive); + } +} diff --git a/packages/flutter_tools/test/general.shard/migrations/analysis_options_migration_test.dart b/packages/flutter_tools/test/general.shard/migrations/analysis_options_migration_test.dart index 73ecb6a133b51..ed90920c37190 100644 --- a/packages/flutter_tools/test/general.shard/migrations/analysis_options_migration_test.dart +++ b/packages/flutter_tools/test/general.shard/migrations/analysis_options_migration_test.dart @@ -8,10 +8,13 @@ import 'package:flutter_tools/src/base/logger.dart'; import 'package:flutter_tools/src/base/terminal.dart'; import 'package:flutter_tools/src/migrations/analysis_options_migration.dart'; import 'package:flutter_tools/src/project.dart'; +import 'package:package_config/package_config.dart'; import 'package:test/fake.dart'; import '../../src/common.dart'; +const _allPlatforms = ['android', 'ios', 'web', 'windows', 'macos', 'linux']; + void main() { group('Analysis options migration', () { testWithoutContext('skipped if analysis_options.yaml file is missing', () async { @@ -160,6 +163,47 @@ analyzer: expect(migratedContents, contains('- linux/**')); }); + testWithoutContext( + 'skipped entirely for a Dart-only package (no flutter dependency)', + () async { + final _TestContext context = _createTestContext( + isFlutterProject: false, + platforms: ['web'], + ); + const analysisOptionsContents = ''' +include: package:lints/recommended.yaml +'''; + context.analysisOptionsFile.writeAsStringSync(analysisOptionsContents); + + final migration = AnalysisOptionsMigration(context.mockProject, context.testLogger); + await migration.migrate(); + + expect(context.analysisOptionsFile.readAsStringSync(), analysisOptionsContents); + expect(context.testLogger.statusText, isEmpty); + }, + ); + + testWithoutContext('only excludes platform directories that actually exist', () async { + final _TestContext context = _createTestContext(platforms: ['android', 'ios']); + const analysisOptionsContents = ''' +include: package:flutter_lints/flutter.yaml +'''; + + context.analysisOptionsFile.writeAsStringSync(analysisOptionsContents); + + final migration = AnalysisOptionsMigration(context.mockProject, context.testLogger); + await migration.migrate(); + + final String migratedContents = context.analysisOptionsFile.readAsStringSync(); + expect(migratedContents, contains('- build/**')); + expect(migratedContents, contains('- android/**')); + expect(migratedContents, contains('- ios/**')); + expect(migratedContents, isNot(contains('- web/**'))); + expect(migratedContents, isNot(contains('- windows/**'))); + expect(migratedContents, isNot(contains('- macos/**'))); + expect(migratedContents, isNot(contains('- linux/**'))); + }); + testWithoutContext('migrates and preserves comments inside exclude list', () async { final _TestContext context = _createTestContext(); const analysisOptionsContents = ''' @@ -202,6 +246,258 @@ analyzer: expect(migratedContents, contains('macos/**')); expect(migratedContents, contains('linux/**')); }); + + testWithoutContext( + 'migrates and merges excludes when list is in flow style with newlines', + () async { + final _TestContext context = _createTestContext(); + const analysisOptionsContents = ''' +analyzer: + exclude: + [ + foo/**, + build/**, + ] +'''; + + context.analysisOptionsFile.writeAsStringSync(analysisOptionsContents); + + final migration = AnalysisOptionsMigration(context.mockProject, context.testLogger); + await migration.migrate(); + + final String migratedContents = context.analysisOptionsFile.readAsStringSync(); + expect(migratedContents, contains('foo/**')); + expect(migratedContents, contains('build/**')); + expect(migratedContents, contains('android/**')); + expect(migratedContents, contains('ios/**')); + expect(migratedContents, contains('web/**')); + expect(migratedContents, contains('windows/**')); + expect(migratedContents, contains('macos/**')); + expect(migratedContents, contains('linux/**')); + }, + ); + + testWithoutContext('skipped if exclusions are inherited via relative include', () async { + final _TestContext context = _createTestContext(); + const analysisOptionsContents = ''' +include: shared_options.yaml +'''; + const sharedOptionsContents = ''' +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** +'''; + + context.analysisOptionsFile.writeAsStringSync(analysisOptionsContents); + context.memoryFileSystem.file('shared_options.yaml').writeAsStringSync(sharedOptionsContents); + + final migration = AnalysisOptionsMigration(context.mockProject, context.testLogger); + await migration.migrate(); + + expect(context.analysisOptionsFile.readAsStringSync(), analysisOptionsContents); + expect(context.testLogger.statusText, isEmpty); + }); + + testWithoutContext('skipped if exclusions are inherited via package include', () async { + final _TestContext context = _createTestContext(); + const analysisOptionsContents = ''' +include: package:shared_options/analysis_options.yaml +'''; + const sharedOptionsContents = ''' +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** +'''; + + context.analysisOptionsFile.writeAsStringSync(analysisOptionsContents); + + final Directory sharedPackageDir = context.memoryFileSystem.directory('/shared_options'); + final File sharedOptionsFile = sharedPackageDir.childFile('lib/analysis_options.yaml'); + sharedOptionsFile.createSync(recursive: true); + sharedOptionsFile.writeAsStringSync(sharedOptionsContents); + + final packageConfig = FakePackageConfig({ + 'shared_options': sharedPackageDir.childDirectory('lib').uri, + }); + + final migration = AnalysisOptionsMigration( + context.mockProject, + context.testLogger, + packageConfig: packageConfig, + ); + await migration.migrate(); + + expect(context.analysisOptionsFile.readAsStringSync(), analysisOptionsContents); + expect(context.testLogger.statusText, isEmpty); + }); + + testWithoutContext('skipped if exclusions are inherited via multiple includes', () async { + final _TestContext context = _createTestContext(); + const analysisOptionsContents = ''' +include: + - shared_options_1.yaml + - shared_options_2.yaml +'''; + const sharedOptions1Contents = ''' +analyzer: + exclude: + - build/** + - android/** + - ios/** +'''; + const sharedOptions2Contents = ''' +analyzer: + exclude: + - web/** + - windows/** + - macos/** + - linux/** +'''; + + context.analysisOptionsFile.writeAsStringSync(analysisOptionsContents); + context.memoryFileSystem + .file('shared_options_1.yaml') + .writeAsStringSync(sharedOptions1Contents); + context.memoryFileSystem + .file('shared_options_2.yaml') + .writeAsStringSync(sharedOptions2Contents); + + final migration = AnalysisOptionsMigration(context.mockProject, context.testLogger); + await migration.migrate(); + + expect(context.analysisOptionsFile.readAsStringSync(), analysisOptionsContents); + expect(context.testLogger.statusText, isEmpty); + }); + + testWithoutContext('handles cyclic includes without crashing', () async { + final _TestContext context = _createTestContext(); + const analysisOptionsContents = ''' +include: shared_options_1.yaml +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** +'''; + const sharedOptions1Contents = ''' +include: shared_options_2.yaml +'''; + const sharedOptions2Contents = ''' +include: shared_options_1.yaml +'''; + + context.analysisOptionsFile.writeAsStringSync(analysisOptionsContents); + context.memoryFileSystem + .file('shared_options_1.yaml') + .writeAsStringSync(sharedOptions1Contents); + context.memoryFileSystem + .file('shared_options_2.yaml') + .writeAsStringSync(sharedOptions2Contents); + + final migration = AnalysisOptionsMigration(context.mockProject, context.testLogger); + + // Should not throw StackOverflowError or hang + await migration.migrate(); + + expect(context.analysisOptionsFile.readAsStringSync(), analysisOptionsContents); + expect(context.testLogger.statusText, isEmpty); + }); + + testWithoutContext('handles invalid exclude types without crashing', () async { + final _TestContext context = _createTestContext(); + const analysisOptionsContents = ''' +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** + - 123 # Invalid exclude type (int) +'''; + + context.analysisOptionsFile.writeAsStringSync(analysisOptionsContents); + + final migration = AnalysisOptionsMigration(context.mockProject, context.testLogger); + + // Should not throw TypeError + await migration.migrate(); + + expect(context.analysisOptionsFile.readAsStringSync(), analysisOptionsContents); + expect(context.testLogger.statusText, isEmpty); + }); + + testWithoutContext('handles unreadable include file without crashing', () async { + final _TestContext context = _createTestContext(); + const analysisOptionsContents = ''' +include: some_dir +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** +'''; + + context.analysisOptionsFile.writeAsStringSync(analysisOptionsContents); + // Create 'some_dir' as a directory so reading it throws FileSystemException + context.memoryFileSystem.directory('some_dir').createSync(); + + final migration = AnalysisOptionsMigration(context.mockProject, context.testLogger); + await migration.migrate(); + + expect(context.analysisOptionsFile.readAsStringSync(), analysisOptionsContents); + expect(context.testLogger.statusText, isEmpty); + }); + + testWithoutContext('handles cyclic includes with different casing without crashing', () async { + final _TestContext context = _createTestContext(); + const analysisOptionsContents = ''' +include: shared_options.yaml +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** +'''; + const sharedOptionsContents = ''' +include: SHARED_OPTIONS.YAML +'''; + + context.analysisOptionsFile.writeAsStringSync(analysisOptionsContents); + context.memoryFileSystem.file('shared_options.yaml').writeAsStringSync(sharedOptionsContents); + + final migration = AnalysisOptionsMigration(context.mockProject, context.testLogger); + await migration.migrate(); + + expect(context.analysisOptionsFile.readAsStringSync(), analysisOptionsContents); + expect(context.testLogger.statusText, isEmpty); + }); }); } @@ -209,17 +505,63 @@ typedef _TestContext = ({ MemoryFileSystem memoryFileSystem, File analysisOptionsFile, BufferLogger testLogger, - FakeFlutterProject mockProject, + FlutterProject mockProject, }); -_TestContext _createTestContext() { +/// Builds a test context backed by a real [FlutterProject] view of an +/// in-memory directory, so that platform existence checks (`android.existsSync()`, +/// etc.) reflect the directories actually created here, matching production +/// behavior instead of being separately mocked. +_TestContext _createTestContext({ + bool isFlutterProject = true, + List platforms = _allPlatforms, +}) { final memoryFileSystem = MemoryFileSystem.test(); - final File analysisOptionsFile = memoryFileSystem.file('analysis_options.yaml'); + final Directory projectDirectory = memoryFileSystem.currentDirectory; + final File analysisOptionsFile = projectDirectory.childFile('analysis_options.yaml'); final testLogger = BufferLogger( terminal: Terminal.test(), outputPreferences: OutputPreferences.test(), ); - final mockProject = FakeFlutterProject(directory: memoryFileSystem.currentDirectory); + + projectDirectory + .childFile('pubspec.yaml') + .writeAsStringSync( + isFlutterProject + ? ''' +name: test_project +dependencies: + flutter: + sdk: flutter +''' + : ''' +name: test_project +''', + ); + + if (platforms.contains('android')) { + projectDirectory.childDirectory('android').createSync(recursive: true); + } + if (platforms.contains('ios')) { + projectDirectory.childDirectory('ios').createSync(recursive: true); + } + if (platforms.contains('web')) { + projectDirectory.childDirectory('web').childFile('index.html').createSync(recursive: true); + } + if (platforms.contains('windows')) { + projectDirectory + .childDirectory('windows') + .childFile('CMakeLists.txt') + .createSync(recursive: true); + } + if (platforms.contains('macos')) { + projectDirectory.childDirectory('macos').createSync(recursive: true); + } + if (platforms.contains('linux')) { + projectDirectory.childDirectory('linux').createSync(recursive: true); + } + + final FlutterProject mockProject = FlutterProject.fromDirectoryTest(projectDirectory); return ( memoryFileSystem: memoryFileSystem, analysisOptionsFile: analysisOptionsFile, @@ -228,9 +570,27 @@ _TestContext _createTestContext() { ); } -class FakeFlutterProject extends Fake implements FlutterProject { - FakeFlutterProject({required this.directory}); +class FakePackageConfig extends Fake implements PackageConfig { + FakePackageConfig(this._packages); + + final Map _packages; @override - final Directory directory; + Uri? resolve(Uri packageUri) { + if (packageUri.scheme != 'package') { + return null; + } + final String path = packageUri.path; + final int slashIndex = path.indexOf('/'); + if (slashIndex == -1) { + return null; + } + final String packageName = path.substring(0, slashIndex); + final String packagePath = path.substring(slashIndex + 1); + final Uri? packageRoot = _packages[packageName]; + if (packageRoot == null) { + return null; + } + return packageRoot.resolve(packagePath); + } } diff --git a/packages/flutter_tools/test/general.shard/migrations/swift_package_manager_integration_migration_test.dart b/packages/flutter_tools/test/general.shard/migrations/swift_package_manager_integration_migration_test.dart index 3df6840f32cc1..e9847a1362a72 100644 --- a/packages/flutter_tools/test/general.shard/migrations/swift_package_manager_integration_migration_test.dart +++ b/packages/flutter_tools/test/general.shard/migrations/swift_package_manager_integration_migration_test.dart @@ -7,18 +7,22 @@ import 'package:file/memory.dart'; import 'package:flutter_tools/src/base/common.dart'; import 'package:flutter_tools/src/base/config.dart'; import 'package:flutter_tools/src/base/logger.dart'; +import 'package:flutter_tools/src/base/os.dart'; +import 'package:flutter_tools/src/base/platform.dart'; import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/darwin/darwin.dart'; import 'package:flutter_tools/src/ios/plist_parser.dart'; import 'package:flutter_tools/src/ios/xcodeproj.dart'; import 'package:flutter_tools/src/migrations/swift_package_manager_integration_migration.dart'; import 'package:flutter_tools/src/plugins.dart'; - import 'package:flutter_tools/src/project.dart'; +import 'package:flutter_tools/src/version.dart'; import 'package:test/fake.dart'; +import 'package:unified_analytics/unified_analytics.dart'; import '../../src/common.dart'; import '../../src/context.dart'; +import '../../src/fakes.dart'; const pluginName = 'my_plugin'; const supportedPlatforms = [ @@ -31,6 +35,10 @@ void main() { testWithoutContext('skips if swift package manager is off', () async { final memoryFileSystem = MemoryFileSystem(); final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeXcodeProject( platform: FlutterDarwinPlatform.ios.name, fileSystem: memoryFileSystem, @@ -48,6 +56,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(), config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await projectMigration.migrate(); expect( @@ -55,11 +68,16 @@ void main() { contains('Skipping the migration that adds Swift Package Manager integration...'), ); expect(testLogger.statusText, isEmpty); + expect(fakeAnalytics.sentEvents, isEmpty); }); testWithoutContext("skips if there's no generated swift package", () async { final memoryFileSystem = MemoryFileSystem(); final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final projectMigration = SwiftPackageManagerIntegrationMigration( FakeXcodeProject( @@ -74,6 +92,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(), config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await projectMigration.migrate(); expect( @@ -82,11 +105,16 @@ void main() { ); expect(testLogger.traceText, contains('The tool did not generate a Swift package.')); expect(testLogger.statusText, isEmpty); + expect(fakeAnalytics.sentEvents, isEmpty); }); testWithoutContext('fails if Xcode project not found', () async { final memoryFileSystem = MemoryFileSystem(); final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeXcodeProject( platform: FlutterDarwinPlatform.ios.name, fileSystem: memoryFileSystem, @@ -104,11 +132,26 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(), config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( () => projectMigration.migrate(), throwsToolExit(message: 'Xcode project not found.'), ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-failure', + parameter: 'full', + result: 'Xcode project not found.', + ), + ), + ); expect(testLogger.traceText, isEmpty); expect(testLogger.statusText, isEmpty); }); @@ -117,6 +160,10 @@ void main() { testWithoutContext('fails if Xcode project info not found', () async { final memoryFileSystem = MemoryFileSystem(); final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeXcodeProject( platform: FlutterDarwinPlatform.ios.name, fileSystem: memoryFileSystem, @@ -134,11 +181,26 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(), config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( () => projectMigration.migrate(), throwsToolExit(message: 'Unable to get Xcode project info.'), ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-failure', + parameter: 'full', + result: 'Unable to get Xcode project info.', + ), + ), + ); expect(testLogger.traceText, isEmpty); expect(testLogger.statusText, isEmpty); }); @@ -146,6 +208,10 @@ void main() { testWithoutContext('fails if Xcode workspace not found', () async { final memoryFileSystem = MemoryFileSystem(); final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeXcodeProject( platform: FlutterDarwinPlatform.ios.name, fileSystem: memoryFileSystem, @@ -163,6 +229,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(), config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( () => projectMigration.migrate(), @@ -175,6 +246,10 @@ void main() { testWithoutContext('fails if scheme not found', () async { final memoryFileSystem = MemoryFileSystem(); final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeXcodeProject( platform: FlutterDarwinPlatform.ios.name, fileSystem: memoryFileSystem, @@ -192,6 +267,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(), config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( () => projectMigration.migrate(), @@ -199,6 +279,7 @@ void main() { message: 'You must specify a --flavor option to select one of the available schemes.', ), ); + expect(fakeAnalytics.sentEvents, isEmpty); expect(testLogger.traceText, isEmpty); expect(testLogger.statusText, isEmpty); }); @@ -206,6 +287,10 @@ void main() { testWithoutContext('fails if scheme file not found', () async { final memoryFileSystem = MemoryFileSystem(); final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeXcodeProject( platform: FlutterDarwinPlatform.ios.name, fileSystem: memoryFileSystem, @@ -227,11 +312,90 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(), config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( () => projectMigration.migrate(), throwsToolExit(message: 'Unable to get scheme file for Runner.'), ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-failure', + parameter: 'full', + result: 'Unable to get scheme file for Runner.', + ), + ), + ); + expect(testLogger.traceText, isEmpty); + expect(testLogger.statusText, isEmpty); + }); + + testWithoutContext('fails if custom scheme file not found', () async { + final memoryFileSystem = MemoryFileSystem(); + final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); + final project = FakeXcodeProject( + platform: FlutterDarwinPlatform.ios.name, + fileSystem: memoryFileSystem, + logger: testLogger, + ); + project._projectInfo = XcodeProjectInfo( + ['custom_flavor'], + ['Debug', 'Release', 'Profile'], + ['custom_flavor'], + testLogger, + ); + _createProjectFiles( + project, + FlutterDarwinPlatform.ios, + createSchemeFile: false, + schemeMigrated: false, + scheme: 'custom_flavor', + ); + + final projectMigration = SwiftPackageManagerIntegrationMigration( + project, + FlutterDarwinPlatform.ios, + const BuildInfo( + BuildMode.debug, + 'custom_flavor', + treeShakeIcons: false, + packageConfigPath: '', + ), + xcodeProjectInterpreter: FakeXcodeProjectInterpreter(), + logger: testLogger, + fileSystem: memoryFileSystem, + plistParser: FakePlistParser(), + config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, + ); + await expectLater( + () => projectMigration.migrate(), + throwsToolExit(message: 'Unable to get scheme file for custom_flavor.'), + ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-failure', + parameter: 'full', + result: 'Unable to get scheme file for custom.', + ), + ), + ); expect(testLogger.traceText, isEmpty); expect(testLogger.statusText, isEmpty); }); @@ -240,6 +404,10 @@ void main() { testWithoutContext('does not migrate if already migrated', () async { final memoryFileSystem = MemoryFileSystem(); final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeXcodeProject( platform: FlutterDarwinPlatform.ios.name, fileSystem: memoryFileSystem, @@ -262,12 +430,18 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(), config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await projectMigration.migrate(); expect(testLogger.traceText, isEmpty); expect(testLogger.statusText, isEmpty); expect(testLogger.warningText, isEmpty); expect(testLogger.errorText, isEmpty); + expect(fakeAnalytics.sentEvents, isEmpty); }); group('migrate scheme', () { @@ -300,6 +474,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(json: _plutilOutput(settingsAsJsonBeforeMigration)), config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater(() => projectMigration.migrate(), throwsToolExit()); expect(testLogger.traceText, contains('Runner.xcscheme already migrated. Skipping...')); @@ -331,6 +510,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(), config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( () => projectMigration.migrate(), @@ -346,6 +530,10 @@ void main() { () async { final memoryFileSystem = MemoryFileSystem(); final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeXcodeProject( platform: platform.name, fileSystem: memoryFileSystem, @@ -370,6 +558,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(), config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( @@ -378,6 +571,16 @@ void main() { message: 'Failed to parse Runner.xcscheme: Could not find BuildableName', ), ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-failure', + parameter: 'full', + result: 'Failed to parse Runner.xcscheme: Could not find BuildableName.', + ), + ), + ); }, ); @@ -386,6 +589,10 @@ void main() { () async { final memoryFileSystem = MemoryFileSystem(); final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeXcodeProject( platform: platform.name, fileSystem: memoryFileSystem, @@ -410,6 +617,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(), config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( @@ -418,6 +630,16 @@ void main() { message: 'Failed to parse Runner.xcscheme: Could not find BlueprintName', ), ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-failure', + parameter: 'full', + result: 'Failed to parse Runner.xcscheme: Could not find BlueprintName.', + ), + ), + ); }, ); @@ -426,6 +648,10 @@ void main() { () async { final memoryFileSystem = MemoryFileSystem(); final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeXcodeProject( platform: platform.name, fileSystem: memoryFileSystem, @@ -450,6 +676,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(), config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( @@ -458,12 +689,26 @@ void main() { message: 'Failed to parse Runner.xcscheme: Could not find ReferencedContainer', ), ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-failure', + parameter: 'full', + result: 'Failed to parse Runner.xcscheme: Could not find ReferencedContainer.', + ), + ), + ); }, ); testWithoutContext('fails if cannot find BuildAction in scheme', () async { final memoryFileSystem = MemoryFileSystem(); final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeXcodeProject( platform: platform.name, fileSystem: memoryFileSystem, @@ -481,6 +726,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(), config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( @@ -489,11 +739,25 @@ void main() { message: 'Failed to parse Runner.xcscheme: Could not find BuildAction', ), ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-failure', + parameter: 'full', + result: 'Failed to parse Runner.xcscheme: Could not find BuildAction.', + ), + ), + ); }); - testWithoutContext('fails if updated scheme is not valid xml', () async { + testWithoutContext('fails if scheme is not valid xml', () async { final memoryFileSystem = MemoryFileSystem(); final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeXcodeProject( platform: platform.name, fileSystem: memoryFileSystem, @@ -512,12 +776,27 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(), config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( () => projectMigration.migrate(), throwsToolExit(message: 'Failed to parse Runner.xcscheme: Invalid xml:'), ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-failure', + parameter: 'full', + result: 'Failed to parse Runner.xcscheme: Invalid xml', + ), + ), + ); }); testWithoutContext('successfully updates scheme with no BuildActionEntries', () async { @@ -549,6 +828,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: plistParser, config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await projectMigration.migrate(); @@ -586,6 +870,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: plistParser, config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await projectMigration.migrate(); @@ -600,6 +889,10 @@ void main() { () async { final memoryFileSystem = MemoryFileSystem(); final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeXcodeProject( platform: platform.name, fileSystem: memoryFileSystem, @@ -624,6 +917,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: plistParser, config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await projectMigration.migrate(); @@ -631,6 +929,12 @@ void main() { project.xcodeProjectSchemeFile().readAsStringSync(), _validBuildActions(platform, hasFrameworkScript: true), ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent(workflow: 'swiftpm-migration-success', parameter: 'full'), + ), + ); }, ); @@ -696,6 +1000,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: plistParser, config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await projectMigration.migrate(); @@ -787,6 +1096,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(json: _plutilOutput(settingsAsJsonBeforeMigration)), config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await projectMigration.migrate(); expect(testLogger.traceText, isEmpty); @@ -796,6 +1110,10 @@ void main() { testWithoutContext('fails plutil command', () async { final memoryFileSystem = MemoryFileSystem(); final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeXcodeProject( platform: FlutterDarwinPlatform.ios.name, fileSystem: memoryFileSystem, @@ -812,16 +1130,35 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(), config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( () => projectMigration.migrate(), throwsToolExit(message: 'Failed to parse project settings.'), ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-failure', + parameter: 'full', + result: 'Failed to parse project settings.', + ), + ), + ); }); testWithoutContext('returns unexpected JSON', () async { final memoryFileSystem = MemoryFileSystem(); final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeXcodeProject( platform: FlutterDarwinPlatform.ios.name, fileSystem: memoryFileSystem, @@ -838,16 +1175,35 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(json: '[]'), config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( () => projectMigration.migrate(), throwsToolExit(message: 'project.pbxproj returned unexpected JSON response'), ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-failure', + parameter: 'full', + result: 'project.pbxproj returned unexpected JSON response', + ), + ), + ); }); testWithoutContext('returns non-JSON', () async { final memoryFileSystem = MemoryFileSystem(); final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeXcodeProject( platform: FlutterDarwinPlatform.ios.name, fileSystem: memoryFileSystem, @@ -864,11 +1220,26 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(json: 'this is not json'), config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( () => projectMigration.migrate(), throwsToolExit(message: 'project.pbxproj returned non-JSON response'), ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-failure', + parameter: 'full', + result: 'project.pbxproj returned non-JSON response', + ), + ), + ); }); }); @@ -876,6 +1247,10 @@ void main() { testWithoutContext('for PBXBuildFile', () async { final memoryFileSystem = MemoryFileSystem(); final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeXcodeProject( platform: FlutterDarwinPlatform.ios.name, fileSystem: memoryFileSystem, @@ -893,16 +1268,35 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(), config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); - expect( + await expectLater( () => projectMigration.migrate(), throwsToolExit(message: 'Duplicate id found for PBXBuildFile'), ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-failure', + parameter: 'full', + result: 'Duplicate id found for PBXBuildFile.', + ), + ), + ); }); testWithoutContext('for XCSwiftPackageProductDependency', () async { final memoryFileSystem = MemoryFileSystem(); final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeXcodeProject( platform: FlutterDarwinPlatform.ios.name, fileSystem: memoryFileSystem, @@ -920,16 +1314,35 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(), config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); - expect( + await expectLater( () => projectMigration.migrate(), throwsToolExit(message: 'Duplicate id found for XCSwiftPackageProductDependency'), ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-failure', + parameter: 'full', + result: 'Duplicate id found for XCSwiftPackageProductDependency.', + ), + ), + ); }); testWithoutContext('for XCLocalSwiftPackageReference', () async { final memoryFileSystem = MemoryFileSystem(); final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeXcodeProject( platform: FlutterDarwinPlatform.ios.name, fileSystem: memoryFileSystem, @@ -947,16 +1360,35 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(), config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); - expect( + await expectLater( () => projectMigration.migrate(), throwsToolExit(message: 'Duplicate id found for XCLocalSwiftPackageReference'), ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-failure', + parameter: 'full', + result: 'Duplicate id found for XCLocalSwiftPackageReference.', + ), + ), + ); }); testWithoutContext('for FlutterGeneratedPluginSwiftPackage PBXFileReference', () async { final memoryFileSystem = MemoryFileSystem(); final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeXcodeProject( platform: FlutterDarwinPlatform.ios.name, fileSystem: memoryFileSystem, @@ -974,13 +1406,29 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(), config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); - expect( + await expectLater( () => projectMigration.migrate(), throwsToolExit( message: 'Duplicate id found for FlutterGeneratedPluginSwiftPackage PBXFileReference', ), ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-failure', + parameter: 'full', + result: + 'Duplicate id found for FlutterGeneratedPluginSwiftPackage PBXFileReference.', + ), + ), + ); }); group('for plugin', () { @@ -993,6 +1441,10 @@ void main() { 'PBXFileReference', () async { final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeXcodeProject( platform: FlutterDarwinPlatform.ios.name, fileSystem: memoryFileSystem, @@ -1016,11 +1468,26 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(), config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); - expect( + await expectLater( () => projectMigration.migrate(), throwsToolExit(message: 'Duplicate id found for $pluginName PBXFileReference'), ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-failure', + parameter: 'full', + result: 'Duplicate id found for plugin PBXFileReference.', + ), + ), + ); }, overrides: { FileSystem: () => memoryFileSystem, @@ -1032,6 +1499,10 @@ void main() { 'FlutterFramework PBXFileReference', () async { final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeXcodeProject( platform: FlutterDarwinPlatform.ios.name, fileSystem: memoryFileSystem, @@ -1055,11 +1526,26 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(), config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); - expect( + await expectLater( () => projectMigration.migrate(), throwsToolExit(message: 'Duplicate id found for FlutterFramework PBXFileReference'), ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-failure', + parameter: 'full', + result: 'Duplicate id found for FlutterFramework PBXFileReference.', + ), + ), + ); }, overrides: { FileSystem: () => memoryFileSystem, @@ -1091,6 +1577,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(json: _plutilOutput([])), config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); expect( () => projectMigration.migrate(), @@ -1130,6 +1621,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(json: _plutilOutput([])), config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); expect( () => projectMigration.migrate(), @@ -1170,6 +1666,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(json: _plutilOutput([])), config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); expect( () => projectMigration.migrate(), @@ -1182,6 +1683,10 @@ void main() { testWithoutContext('successfully added', () async { final memoryFileSystem = MemoryFileSystem(); final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeXcodeProject( platform: platform.name, fileSystem: memoryFileSystem, @@ -1214,6 +1719,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: plistParser, config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await projectMigration.migrate(); expect(testLogger.errorText, isEmpty); @@ -1227,6 +1737,12 @@ void main() { _projectSettings(settingsBeforeMigration), ); expect(plistParser.hasRemainingExpectations, isFalse); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent(workflow: 'swiftpm-migration-success', parameter: 'full'), + ), + ); }); }); @@ -1260,6 +1776,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(json: _plutilOutput(settingsAsJsonBeforeMigration)), config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); expect( () => projectMigration.migrate(), @@ -1299,6 +1820,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(json: _plutilOutput(settingsAsJsonBeforeMigration)), config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); expect( () => projectMigration.migrate(), @@ -1339,6 +1865,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(json: _plutilOutput(settingsAsJsonBeforeMigration)), config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); expect( () => projectMigration.migrate(), @@ -1383,6 +1914,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: plistParser, config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await projectMigration.migrate(); expect(testLogger.errorText, isEmpty); @@ -1454,6 +1990,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: plistParser, config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await projectMigration.migrate(); expect(testLogger.errorText, isEmpty); @@ -1507,6 +2048,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(json: _plutilOutput(settingsAsJsonBeforeMigration)), config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( () => projectMigration.migrate(), @@ -1550,6 +2096,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(json: _plutilOutput(settingsAsJsonBeforeMigration)), config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( () => projectMigration.migrate(), @@ -1599,6 +2150,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(json: _plutilOutput(settingsAsJsonBeforeMigration)), config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( () => projectMigration.migrate(), @@ -1612,6 +2168,10 @@ void main() { testWithoutContext('fails if missing Runner target in parsed settings', () async { final memoryFileSystem = MemoryFileSystem(); final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeXcodeProject( platform: platform.name, fileSystem: memoryFileSystem, @@ -1639,6 +2199,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(json: _plutilOutput(settingsAsJsonBeforeMigration)), config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( () => projectMigration.migrate(), @@ -1646,8 +2211,80 @@ void main() { message: 'Unable to find parsed PBXFrameworksBuildPhase for Runner target', ), ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-failure', + parameter: 'full', + result: 'Unable to find parsed PBXFrameworksBuildPhase for Runner target.', + ), + ), + ); }); + testWithoutContext( + 'fails and anonymizes names if missing CustomApp target in parsed settings', + () async { + final memoryFileSystem = MemoryFileSystem(); + final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); + final project = FakeXcodeProject( + platform: platform.name, + fileSystem: memoryFileSystem, + logger: testLogger, + ); + project.hostAppProjectName = 'CustomApp'; + _createProjectFiles(project, platform); + + final settingsBeforeMigration = [..._allSectionsUnmigrated(platform)]; + settingsBeforeMigration[_frameworksBuildPhaseSectionIndex] = + unmigratedFrameworksBuildPhaseSection(platform); + project.xcodeProjectInfoFile.writeAsStringSync( + _projectSettings(settingsBeforeMigration), + ); + final settingsAsJsonBeforeMigration = [ + ..._allSectionsMigratedAsJson(platform), + ]; + settingsAsJsonBeforeMigration.removeAt(_frameworksBuildPhaseSectionIndex); + + final projectMigration = SwiftPackageManagerIntegrationMigration( + project, + platform, + BuildInfo.debug, + xcodeProjectInterpreter: FakeXcodeProjectInterpreter(), + logger: testLogger, + fileSystem: memoryFileSystem, + plistParser: FakePlistParser(json: _plutilOutput(settingsAsJsonBeforeMigration)), + config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, + ); + await expectLater( + () => projectMigration.migrate(), + throwsToolExit( + message: 'Unable to find parsed PBXFrameworksBuildPhase for CustomApp target', + ), + ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-failure', + parameter: 'full', + result: 'Unable to find parsed PBXFrameworksBuildPhase for custom target.', + ), + ), + ); + }, + ); + testWithoutContext('successfully added when files field is missing', () async { final memoryFileSystem = MemoryFileSystem(); final testLogger = BufferLogger.test(); @@ -1687,6 +2324,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: plistParser, config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await projectMigration.migrate(); expect(testLogger.errorText, isEmpty); @@ -1735,6 +2377,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: plistParser, config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await projectMigration.migrate(); expect(testLogger.errorText, isEmpty); @@ -1790,6 +2437,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: plistParser, config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await projectMigration.migrate(); expect(testLogger.errorText, isEmpty); @@ -1837,6 +2489,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(json: _plutilOutput(settingsAsJsonBeforeMigration)), config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( () => projectMigration.migrate(), @@ -1873,6 +2530,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(json: _plutilOutput(settingsAsJsonBeforeMigration)), config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( () => projectMigration.migrate(), @@ -1913,6 +2575,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(json: _plutilOutput(settingsAsJsonBeforeMigration)), config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( () => projectMigration.migrate(), @@ -1959,6 +2626,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(json: _plutilOutput(settingsAsJsonBeforeMigration)), config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( () => projectMigration.migrate(), @@ -2012,6 +2684,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: plistParser, config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await projectMigration.migrate(); expect(testLogger.errorText, isEmpty); @@ -2061,6 +2738,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: plistParser, config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await projectMigration.migrate(); expect(testLogger.errorText, isEmpty); @@ -2136,6 +2818,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: plistParser, config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await projectMigration.migrate(); expect(testLogger.errorText, isEmpty); @@ -2187,6 +2874,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(json: _plutilOutput(settingsAsJsonBeforeMigration)), config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( () => projectMigration.migrate(), @@ -2197,6 +2889,10 @@ void main() { testWithoutContext('fails if missing Runner target in parsed settings', () async { final memoryFileSystem = MemoryFileSystem(); final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeXcodeProject( platform: platform.name, fileSystem: memoryFileSystem, @@ -2225,13 +2921,91 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(json: _plutilOutput(settingsAsJsonBeforeMigration)), config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( () => projectMigration.migrate(), throwsToolExit(message: 'Unable to find parsed PBXNativeTarget for Runner target'), ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-failure', + parameter: 'full', + result: 'Unable to find parsed PBXNativeTarget for Runner target.', + ), + ), + ); }); + testWithoutContext( + 'fails and anonymizes names if missing CustomApp target in parsed settings', + () async { + final memoryFileSystem = MemoryFileSystem(); + final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); + final project = FakeXcodeProject( + platform: platform.name, + fileSystem: memoryFileSystem, + logger: testLogger, + ); + project.hostAppProjectName = 'CustomApp'; + _createProjectFiles(project, platform); + + final settingsBeforeMigration = [..._allSectionsUnmigrated(platform)]; + settingsBeforeMigration[_nativeTargetSectionIndex] = unmigratedNativeTargetSection( + platform, + ); + project.xcodeProjectInfoFile.writeAsStringSync( + _projectSettings(settingsBeforeMigration), + ); + final settingsAsJsonBeforeMigration = [ + ..._allSectionsUnmigratedAsJson(platform), + ]; + settingsAsJsonBeforeMigration.removeAt(_nativeTargetSectionIndex); + + final projectMigration = SwiftPackageManagerIntegrationMigration( + project, + platform, + BuildInfo.debug, + xcodeProjectInterpreter: FakeXcodeProjectInterpreter(), + logger: testLogger, + fileSystem: memoryFileSystem, + plistParser: FakePlistParser(json: _plutilOutput(settingsAsJsonBeforeMigration)), + config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, + ); + await expectLater( + () => projectMigration.migrate(), + throwsToolExit( + message: 'Unable to find parsed PBXNativeTarget for CustomApp target', + ), + ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-failure', + parameter: 'full', + result: 'Unable to find parsed PBXNativeTarget for custom target.', + ), + ), + ); + }, + ); + testWithoutContext( 'fails if missing Runner target subsection following PBXNativeTarget begin header', () async { @@ -2265,6 +3039,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(json: _plutilOutput(settingsAsJsonBeforeMigration)), config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( () => projectMigration.migrate(), @@ -2311,6 +3090,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(json: _plutilOutput(settingsAsJsonBeforeMigration)), config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( () => projectMigration.migrate(), @@ -2367,6 +3151,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: plistParser, config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await projectMigration.migrate(); expect(testLogger.errorText, isEmpty); @@ -2419,6 +3208,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: plistParser, config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await projectMigration.migrate(); expect(testLogger.errorText, isEmpty); @@ -2477,6 +3271,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: plistParser, config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await projectMigration.migrate(); expect(testLogger.errorText, isEmpty); @@ -2523,6 +3322,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(json: _plutilOutput(settingsAsJsonBeforeMigration)), config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( () => projectMigration.migrate(), @@ -2565,6 +3369,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(json: _plutilOutput(settingsAsJsonBeforeMigration)), config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( () => projectMigration.migrate(), @@ -2613,6 +3422,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(json: _plutilOutput(settingsAsJsonBeforeMigration)), config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( () => projectMigration.migrate(), @@ -2624,6 +3438,10 @@ void main() { testWithoutContext('fails if missing Runner project in parsed settings', () async { final memoryFileSystem = MemoryFileSystem(); final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeXcodeProject( platform: platform.name, fileSystem: memoryFileSystem, @@ -2650,13 +3468,87 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(json: _plutilOutput(settingsAsJsonBeforeMigration)), config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( () => projectMigration.migrate(), throwsToolExit(message: 'Unable to find parsed PBXProject for Runner'), ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-failure', + parameter: 'full', + result: 'Unable to find parsed PBXProject for Runner.', + ), + ), + ); }); + testWithoutContext( + 'fails and anonymizes names if missing CustomApp project in parsed settings', + () async { + final memoryFileSystem = MemoryFileSystem(); + final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); + final project = FakeXcodeProject( + platform: platform.name, + fileSystem: memoryFileSystem, + logger: testLogger, + ); + project.hostAppProjectName = 'CustomApp'; + _createProjectFiles(project, platform); + + final settingsBeforeMigration = [..._allSectionsUnmigrated(platform)]; + settingsBeforeMigration[_projectSectionIndex] = unmigratedProjectSection(platform); + project.xcodeProjectInfoFile.writeAsStringSync( + _projectSettings(settingsBeforeMigration), + ); + final settingsAsJsonBeforeMigration = [ + ..._allSectionsUnmigratedAsJson(platform), + ]; + settingsAsJsonBeforeMigration.removeAt(_projectSectionIndex); + + final projectMigration = SwiftPackageManagerIntegrationMigration( + project, + platform, + BuildInfo.debug, + xcodeProjectInterpreter: FakeXcodeProjectInterpreter(), + logger: testLogger, + fileSystem: memoryFileSystem, + plistParser: FakePlistParser(json: _plutilOutput(settingsAsJsonBeforeMigration)), + config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, + ); + await expectLater( + () => projectMigration.migrate(), + throwsToolExit(message: 'Unable to find parsed PBXProject for CustomApp'), + ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-failure', + parameter: 'full', + result: 'Unable to find parsed PBXProject for custom.', + ), + ), + ); + }, + ); + testWithoutContext( 'successfully added when packageReferences field is missing', () async { @@ -2704,6 +3596,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: plistParser, config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await projectMigration.migrate(); expect(testLogger.errorText, isEmpty); @@ -2754,6 +3651,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: plistParser, config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await projectMigration.migrate(); expect(testLogger.errorText, isEmpty); @@ -2812,6 +3714,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: plistParser, config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await projectMigration.migrate(); expect(testLogger.errorText, isEmpty); @@ -2853,6 +3760,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(json: _plutilOutput(settingsAsJsonBeforeMigration)), config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( () => projectMigration.migrate(), @@ -2896,6 +3808,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: plistParser, config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await projectMigration.migrate(); expect(testLogger.errorText, isEmpty); @@ -2946,6 +3863,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: plistParser, config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await projectMigration.migrate(); expect(testLogger.errorText, isEmpty); @@ -2999,6 +3921,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: plistParser, config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await projectMigration.migrate(); expect(testLogger.errorText, isEmpty); @@ -3041,6 +3968,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: FakePlistParser(json: _plutilOutput(settingsAsJsonBeforeMigration)), config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( () => projectMigration.migrate(), @@ -3081,6 +4013,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: plistParser, config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await projectMigration.migrate(); expect(testLogger.errorText, isEmpty); @@ -3131,6 +4068,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: plistParser, config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await projectMigration.migrate(); expect(testLogger.errorText, isEmpty); @@ -3184,6 +4126,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: plistParser, config: FakeConfig(), + analytics: const NoOpAnalytics(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await projectMigration.migrate(); expect(testLogger.errorText, isEmpty); @@ -3204,6 +4151,10 @@ void main() { testWithoutContext('throw if settings not updated correctly', () async { final memoryFileSystem = MemoryFileSystem(); final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeXcodeProject( platform: platform.name, fileSystem: memoryFileSystem, @@ -3228,6 +4179,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: plistParser, config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( () => projectMigration.migrate(), @@ -3261,6 +4217,16 @@ void main() { 'XCSwiftPackageProductDependency was not migrated or was migrated incorrectly.', ), ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-failure', + parameter: 'full', + result: 'Settings were not updated correctly.', + ), + ), + ); }); testWithoutContext( @@ -3268,6 +4234,10 @@ void main() { () async { final memoryFileSystem = MemoryFileSystem(); final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeXcodeProject( platform: platform.name, fileSystem: memoryFileSystem, @@ -3304,6 +4274,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: plistParser, config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await projectMigration.migrate(); expect( @@ -3318,6 +4293,15 @@ void main() { _projectSettings([..._allSectionsMigrated(platform)]), ); expect(plistParser.hasRemainingExpectations, isFalse); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-success', + parameter: 'optional', + ), + ), + ); }, ); @@ -3331,6 +4315,10 @@ void main() { 'successfully added', () async { final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeXcodeProject( platform: platform.name, fileSystem: memoryFileSystem, @@ -3372,6 +4360,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: plistParser, config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await projectMigration.migrate(); expect( @@ -3388,6 +4381,15 @@ void main() { _projectSettings(expectedSettings), ); expect(plistParser.hasRemainingExpectations, isFalse); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-success', + parameter: 'optional', + ), + ), + ); }, overrides: { FileSystem: () => memoryFileSystem, @@ -3399,6 +4401,10 @@ void main() { 'failure does not throw', () async { final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); final project = FakeXcodeProject( platform: platform.name, fileSystem: memoryFileSystem, @@ -3425,7 +4431,6 @@ void main() { final plistParser = FakePlistParser.multiple([ _plutilOutput(settingsAsJsonBeforeMigration), - _plutilOutput(['something went wrong']), ]); final projectMigration = SwiftPackageManagerIntegrationMigration( @@ -3437,6 +4442,11 @@ void main() { fileSystem: memoryFileSystem, plistParser: plistParser, config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await projectMigration.migrate(); expect(testLogger.errorText, isEmpty); @@ -3451,6 +4461,16 @@ void main() { _projectSettings(settingsBeforeMigration), ); expect(plistParser.hasRemainingExpectations, isFalse); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-failure', + parameter: 'optional', + result: 'Failed to parse project settings.', + ), + ), + ); }, overrides: { FileSystem: () => memoryFileSystem, @@ -3466,6 +4486,10 @@ void main() { testWithoutContext('throw if settings fail to compile', () async { final memoryFileSystem = MemoryFileSystem(); final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); const FlutterDarwinPlatform platform = FlutterDarwinPlatform.ios; final project = FakeXcodeProject( platform: platform.name, @@ -3491,11 +4515,139 @@ void main() { fileSystem: memoryFileSystem, plistParser: plistParser, config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, ); await expectLater( () => projectMigration.migrate(), throwsToolExit(message: 'Unable to get Xcode project information'), ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-failure', + parameter: 'full', + result: 'Xcode failed for unknown reason.', + ), + ), + ); + expect(testLogger.traceText, contains('Sending crash report to Google.')); + }); + + testWithoutContext('throw if settings fail to compile due to package dependencies', () async { + final memoryFileSystem = MemoryFileSystem(); + final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); + const FlutterDarwinPlatform platform = FlutterDarwinPlatform.ios; + final project = FakeXcodeProject( + platform: platform.name, + fileSystem: memoryFileSystem, + logger: testLogger, + ); + _createProjectFiles(project, platform); + project.xcodeProjectInfoFile.writeAsStringSync( + _projectSettings(_allSectionsUnmigrated(platform)), + ); + + final plistParser = FakePlistParser.multiple([ + _plutilOutput(_allSectionsUnmigratedAsJson(platform)), + _plutilOutput(_allSectionsMigratedAsJson(platform)), + ]); + + final projectMigration = SwiftPackageManagerIntegrationMigration( + project, + platform, + BuildInfo.debug, + xcodeProjectInterpreter: FakeXcodeProjectInterpreter( + errorMessageOnGetInfo: 'Could not resolve package dependencies: network error', + ), + logger: testLogger, + fileSystem: memoryFileSystem, + plistParser: plistParser, + config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: true, + ); + await expectLater( + () => projectMigration.migrate(), + throwsToolExit(message: 'Could not resolve package dependencies: network error'), + ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-failure', + parameter: 'full', + result: 'Xcode could not resolve package dependencies.', + ), + ), + ); + expect(testLogger.traceText, contains('Sending crash report to Google.')); + }); + + testWithoutContext('does not send crash report if reportCrashes is false', () async { + final memoryFileSystem = MemoryFileSystem(); + final testLogger = BufferLogger.test(); + final FakeAnalytics fakeAnalytics = getInitializedFakeAnalyticsInstance( + fs: memoryFileSystem, + fakeFlutterVersion: FakeFlutterVersion(), + ); + const FlutterDarwinPlatform platform = FlutterDarwinPlatform.ios; + final project = FakeXcodeProject( + platform: platform.name, + fileSystem: memoryFileSystem, + logger: testLogger, + ); + _createProjectFiles(project, platform); + project.xcodeProjectInfoFile.writeAsStringSync( + _projectSettings(_allSectionsUnmigrated(platform)), + ); + + final plistParser = FakePlistParser.multiple([ + _plutilOutput(_allSectionsUnmigratedAsJson(platform)), + _plutilOutput(_allSectionsMigratedAsJson(platform)), + ]); + + final projectMigration = SwiftPackageManagerIntegrationMigration( + project, + platform, + BuildInfo.debug, + xcodeProjectInterpreter: FakeXcodeProjectInterpreter(throwErrorOnGetInfo: true), + logger: testLogger, + fileSystem: memoryFileSystem, + plistParser: plistParser, + config: FakeConfig(), + analytics: fakeAnalytics, + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), + reportCrashes: false, + ); + await expectLater( + () => projectMigration.migrate(), + throwsToolExit(message: 'Unable to get Xcode project information'), + ); + expect( + fakeAnalytics.sentEvents, + contains( + Event.appleUsageEvent( + workflow: 'swiftpm-migration-failure', + parameter: 'full', + result: 'Xcode failed for unknown reason.', + ), + ), + ); + expect(testLogger.traceText, isNot(contains('Sending crash report to Google.'))); }); testWithoutContext('restore project settings from backup on failure', () async { @@ -3528,6 +4680,9 @@ void main() { plistParser: plistParser, validateBackup: true, config: FakeConfig(), + hostPlatform: FakePlatform(), + operatingSystemUtils: FakeOperatingSystemUtils(), + flutterVersion: FakeFlutterVersion(), ); await expectLater(() async => projectMigration.migrate(), throwsToolExit()); expect(testLogger.traceText, contains('Restoring project settings from backup file...')); @@ -3535,6 +4690,80 @@ void main() { expect(project.xcodeProjectSchemeFile().readAsStringSync(), originalSchemeContents); }); }); + + group('SwiftPackageManagerMigrationException', () { + test('sets user and machine message', () { + final exception = SwiftPackageManagerMigrationException( + 'user message', + analyticsMessage: 'machine message', + ); + expect(exception.userMessage, 'user message'); + expect(exception.analyticsMessage, 'machine message'); + expect(exception.toString(), 'user message'); + }); + + test('sets user message when machine message is null', () { + final exception = SwiftPackageManagerMigrationException('user message'); + expect(exception.userMessage, 'user message'); + expect(exception.analyticsMessage, isNull); + expect(exception.toString(), 'user message'); + }); + + test('sanitized creates sanitized analyticsMessage', () { + final exception = SwiftPackageManagerMigrationException.sanitized( + (sanitize) => + 'Error for ${sanitize('MyName', SanitizeType.name)}: ${sanitize('some error details', SanitizeType.error)}', + ); + expect(exception.userMessage, 'Error for MyName: some error details'); + expect(exception.analyticsMessage, 'Error for custom: '); + expect(exception.toString(), 'Error for MyName: some error details'); + }); + + test('sanitized preserves Runner for name', () { + final exception = SwiftPackageManagerMigrationException.sanitized( + (sanitize) => 'Error for ${sanitize('Runner', SanitizeType.name)}', + ); + expect(exception.userMessage, 'Error for Runner'); + expect(exception.analyticsMessage, 'Error for Runner'); + expect(exception.toString(), 'Error for Runner'); + }); + + test('sanitized preserves Runner.xcscheme for basename', () { + final exception = SwiftPackageManagerMigrationException.sanitized( + (sanitize) => 'Error for ${sanitize('Runner.xcscheme', SanitizeType.basename)}', + ); + expect(exception.userMessage, 'Error for Runner.xcscheme'); + expect(exception.analyticsMessage, 'Error for Runner.xcscheme'); + expect(exception.toString(), 'Error for Runner.xcscheme'); + }); + + test('sanitized replaces custom scheme for basename', () { + final exception = SwiftPackageManagerMigrationException.sanitized( + (sanitize) => 'Error for ${sanitize('MyName.xcscheme', SanitizeType.basename)}', + ); + expect(exception.userMessage, 'Error for MyName.xcscheme'); + expect(exception.analyticsMessage, 'Error for custom.xcscheme'); + expect(exception.toString(), 'Error for MyName.xcscheme'); + }); + + test('sanitized replaces custom scheme for unexpected basename', () { + // Verify basename with multiple "." in the path + final exception = SwiftPackageManagerMigrationException.sanitized( + (sanitize) => 'Error for ${sanitize('MyName.debug.xcscheme', SanitizeType.basename)}', + ); + expect(exception.userMessage, 'Error for MyName.debug.xcscheme'); + expect(exception.analyticsMessage, 'Error for custom.xcscheme'); + expect(exception.toString(), 'Error for MyName.debug.xcscheme'); + + // Verify basename with no "." in the path + final exception2 = SwiftPackageManagerMigrationException.sanitized( + (sanitize) => 'Error for ${sanitize('MyName', SanitizeType.basename)}', + ); + expect(exception2.userMessage, 'Error for MyName'); + expect(exception2.analyticsMessage, 'Error for custom'); + expect(exception2.toString(), 'Error for MyName'); + }); + }); }); } @@ -4507,7 +5736,7 @@ const migratedSwiftPackageProductDependencySectionAsJson = ''' }'''; class FakeXcodeProjectInterpreter extends Fake implements XcodeProjectInterpreter { - FakeXcodeProjectInterpreter({this.throwErrorOnGetInfo = false}); + FakeXcodeProjectInterpreter({this.throwErrorOnGetInfo = false, this.errorMessageOnGetInfo}); @override bool isInstalled = false; @@ -4516,6 +5745,7 @@ class FakeXcodeProjectInterpreter extends Fake implements XcodeProjectInterprete List xcrunCommand() => ['xcrun']; final bool throwErrorOnGetInfo; + final String? errorMessageOnGetInfo; @override Future getInfo( @@ -4523,8 +5753,8 @@ class FakeXcodeProjectInterpreter extends Fake implements XcodeProjectInterprete String? projectFilename, required Directory buildDirectory, }) async { - if (throwErrorOnGetInfo) { - throwToolExit('Unable to get Xcode project information'); + if (throwErrorOnGetInfo || errorMessageOnGetInfo != null) { + throwToolExit(errorMessageOnGetInfo ?? 'Unable to get Xcode project information'); } return null; } @@ -4667,7 +5897,17 @@ class FakeSwiftPackageManagerIntegrationMigration extends SwiftPackageManagerInt required super.plistParser, this.validateBackup = false, required super.config, - }) : _xcodeProject = project; + super.analytics = const NoOpAnalytics(), + Platform? hostPlatform, + OperatingSystemUtils? operatingSystemUtils, + FlutterVersion? flutterVersion, + super.reportCrashes = true, + }) : _xcodeProject = project, + super( + hostPlatform: hostPlatform ?? FakePlatform(), + operatingSystemUtils: operatingSystemUtils ?? FakeOperatingSystemUtils(), + flutterVersion: flutterVersion ?? FakeFlutterVersion(), + ); final XcodeBasedProject _xcodeProject; diff --git a/packages/flutter_tools/test/general.shard/plugins_test.dart b/packages/flutter_tools/test/general.shard/plugins_test.dart index 1d2e0632488dd..5f9d3c7e5d970 100644 --- a/packages/flutter_tools/test/general.shard/plugins_test.dart +++ b/packages/flutter_tools/test/general.shard/plugins_test.dart @@ -2020,6 +2020,81 @@ flutter: ); }); + testUsingContext( + 'Plugin.fromYaml rejects a plugin class with code-injection characters', + () async { + // A (possibly transitive) dependency must not be able to smuggle + // arbitrary source into the generated GeneratedPluginRegistrant by + // declaring a pluginClass that is not a plain identifier. + const maliciousYaml = ''' +platforms: + macos: + pluginClass: "SomePlugin(); evilInjectedCall(); if (false) { SomePlugin" +'''; + expect( + () => Plugin.fromYaml( + 'evil_plugin', + '', + loadYaml(maliciousYaml) as YamlMap, + null, + const [], + fileSystem: globals.fs, + isDevDependency: false, + ), + throwsToolExit(message: 'Invalid plugin specification evil_plugin'), + ); + }, + ); + + testUsingContext( + 'Plugin.fromYaml rejects a web plugin whose pluginClass/fileName contain injection', + () async { + const maliciousYaml = ''' +platforms: + web: + pluginClass: "P; void pwn() {} //" + fileName: some_file.dart +'''; + expect( + () => Plugin.fromYaml( + 'evil_web_plugin', + '', + loadYaml(maliciousYaml) as YamlMap, + null, + const [], + fileSystem: globals.fs, + isDevDependency: false, + ), + throwsToolExit(), + ); + }, + ); + + testUsingContext( + 'Plugin.fromYaml rejects a dartPluginClass with code-injection characters', + () async { + // A dart plugin class is also interpolated into generated registrant + // source, so it must be a plain identifier like the native one. + const maliciousYaml = ''' +platforms: + android: + dartPluginClass: "Evil(); evilInjectedCall(); class Evil" +'''; + expect( + () => Plugin.fromYaml( + 'evil_dart_plugin', + '', + loadYaml(maliciousYaml) as YamlMap, + null, + const [], + fileSystem: globals.fs, + isDevDependency: false, + ), + throwsToolExit(message: 'Invalid plugin specification evil_dart_plugin'), + ); + }, + ); + testUsingContext('createPlatformsYamlMap should create the correct map', () async { final YamlMap map = Plugin.createPlatformsYamlMap( ['ios', 'android', 'linux'], diff --git a/packages/flutter_tools/test/general.shard/project_file_invalidator_test.dart b/packages/flutter_tools/test/general.shard/project_file_invalidator_test.dart index d1c74e8238485..1a85a53bc0cfb 100644 --- a/packages/flutter_tools/test/general.shard/project_file_invalidator_test.dart +++ b/packages/flutter_tools/test/general.shard/project_file_invalidator_test.dart @@ -132,5 +132,85 @@ void main() { ); }, ); + + testWithoutContext( + 'File edited in same second as last compile is invalidated on Windows, asyncScanning: $asyncScanning', + () async { + final FileSystem fileSystem = MemoryFileSystem.test(); + final projectFileInvalidator = ProjectFileInvalidator( + fileSystem: fileSystem, + platform: FakePlatform(operatingSystem: 'windows'), + logger: BufferLogger.test(), + ); + + final lastCompiled = DateTime(2026, 8, 18, 3, 0, 0, 500); // 3:00:00.500 + final fileMtime = DateTime(2026, 8, 18, 3); // 3:00:00.000 (truncated) + + final File file = fileSystem.file('foo.dart') + ..createSync(recursive: true) + ..writeAsStringSync('content'); + file.setLastModifiedSync(fileMtime); + + fileSystem.file('.dart_tool/package_config.json') + ..createSync(recursive: true) + ..writeAsStringSync(''' +{ + "packages": [], + "configVersion": 2 +} +'''); + + expect( + (await projectFileInvalidator.findInvalidated( + lastCompiled: lastCompiled, + urisToMonitor: [file.uri], + packagesPath: '.dart_tool/package_config.json', + asyncScanning: asyncScanning, + packageConfig: PackageConfig.empty, + )).uris, + contains(file.uri), + ); + }, + ); + + testWithoutContext( + 'File edited in same second as last compile is NOT invalidated on non-Windows, asyncScanning: $asyncScanning', + () async { + final FileSystem fileSystem = MemoryFileSystem.test(); + final projectFileInvalidator = ProjectFileInvalidator( + fileSystem: fileSystem, + platform: FakePlatform(), + logger: BufferLogger.test(), + ); + + final lastCompiled = DateTime(2026, 8, 18, 3, 0, 0, 500); // 3:00:00.500 + final fileMtime = DateTime(2026, 8, 18, 3); // 3:00:00.000 + + final File file = fileSystem.file('foo.dart') + ..createSync(recursive: true) + ..writeAsStringSync('content'); + file.setLastModifiedSync(fileMtime); + + fileSystem.file('.dart_tool/package_config.json') + ..createSync(recursive: true) + ..writeAsStringSync(''' +{ + "packages": [], + "configVersion": 2 +} +'''); + + expect( + (await projectFileInvalidator.findInvalidated( + lastCompiled: lastCompiled, + urisToMonitor: [file.uri], + packagesPath: '.dart_tool/package_config.json', + asyncScanning: asyncScanning, + packageConfig: PackageConfig.empty, + )).uris, + isEmpty, + ); + }, + ); } } diff --git a/packages/flutter_tools/test/general.shard/resident_web_runner_test.dart b/packages/flutter_tools/test/general.shard/resident_web_runner_test.dart index 05767c435a5e5..6a552691295e8 100644 --- a/packages/flutter_tools/test/general.shard/resident_web_runner_test.dart +++ b/packages/flutter_tools/test/general.shard/resident_web_runner_test.dart @@ -275,12 +275,36 @@ name: my_app 'Does not crash if the application exits during DDS startup', () async { // Regression test for https://github.com/flutter/flutter/issues/178151 - final ResidentRunner residentWebRunner = setUpResidentRunner(flutterDevice); + final logger = BufferLogger.test(); + final ResidentRunner residentWebRunner = setUpResidentRunner(flutterDevice, logger: logger); fakeVmServiceHost = FakeVmServiceHost(requests: kAttachExpectations.toList()); setupMocks(); webDevFS.exception = DartDevelopmentServiceException.failedToStart(); await expectLater(residentWebRunner.run(), throwsToolExit()); + expect(logger.errorText, isEmpty); + }, + overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => processManager, + Pub: ThrowingPub.new, + }, + ); + + testUsingContext( + 'Does not crash if DDS fails to upgrade WebSocket during startup', + () async { + final logger = BufferLogger.test(); + final ResidentRunner residentWebRunner = setUpResidentRunner(flutterDevice, logger: logger); + fakeVmServiceHost = FakeVmServiceHost(requests: kAttachExpectations.toList()); + setupMocks(); + const errorMessage = + 'WebSocketChannelException: WebSocketException: Connection to ' + "'http://127.0.0.1:62932/9fVOXxsamo0=/ws#' was not upgraded to websocket"; + webDevFS.exception = DartDevelopmentServiceException.connectionIssue(errorMessage); + + await expectLater(residentWebRunner.run(), throwsToolExit()); + expect(logger.errorText, contains(errorMessage)); }, overrides: { FileSystem: () => fileSystem, @@ -1142,7 +1166,7 @@ name: my_app expect(debugConnectionInfo, isNotNull); final OperationResult result = await residentWebRunner.restart(); - expect(logger.statusText, contains(kNoClientConnectedMessage)); + expect(logger.statusText, contains('Reloaded application in')); expect(result.code, 0); }, overrides: { diff --git a/packages/flutter_tools/test/general.shard/vmservice_test.dart b/packages/flutter_tools/test/general.shard/vmservice_test.dart index 53a7e0228394d..6a48b8bb7284f 100644 --- a/packages/flutter_tools/test/general.shard/vmservice_test.dart +++ b/packages/flutter_tools/test/general.shard/vmservice_test.dart @@ -108,6 +108,37 @@ void main() { }); }, overrides: {WebSocketConnector: () => failingWebSocketConnector}); + testUsingContext( + 'VM Service prints messages for HttpException connection failures and retries', + () { + final logger = BufferLogger.test(); + FakeAsync().run((FakeAsync time) { + final Uri uri = Uri.parse('ws://127.0.0.1:12345/QqL7EFEDNG0=/ws'); + unawaited(connectToVmService(uri, logger: logger)); + + time.elapse(const Duration(seconds: 5)); + expect(logger.statusText, isEmpty); + + time.elapse(const Duration(minutes: 2)); + + final String statusText = logger.statusText; + expect( + statusText, + containsIgnoringWhitespace( + 'Connecting to the VM Service is taking longer than expected...', + ), + ); + expect(statusText, containsIgnoringWhitespace('try re-running with --host-vmservice-port')); + expect( + statusText, + containsIgnoringWhitespace('Exception attempting to connect to the VM Service:'), + ); + expect(statusText, containsIgnoringWhitespace('This was attempt #50. Will retry')); + }); + }, + overrides: {WebSocketConnector: () => httpFailingWebSocketConnector}, + ); + testWithoutContext('setAssetDirectory forwards arguments correctly', () async { final mockVMService = FakeVMService(); final flutterVmService = FlutterVmService(mockVMService); @@ -738,3 +769,14 @@ Future failingWebSocketConnector( }) { throw const io.SocketException('Failed WebSocket connection'); } + +/// A [WebSocketConnector] that always throws an [io.HttpException]. +Future httpFailingWebSocketConnector( + String url, { + io.CompressionOptions? compression, + Logger? logger, +}) { + throw const io.HttpException( + 'Connection closed before full header was received, uri = http://127.0.0.1:63745/TjwUKCgX5S8=/ws', + ); +} diff --git a/packages/flutter_tools/test/integration.shard/android_gradle_legacy_flutter_plugins_strings_in_comments_test.dart b/packages/flutter_tools/test/integration.shard/android_gradle_legacy_flutter_plugins_strings_in_comments_test.dart index c94ad86ade563..7b7d70c7a5a46 100644 --- a/packages/flutter_tools/test/integration.shard/android_gradle_legacy_flutter_plugins_strings_in_comments_test.dart +++ b/packages/flutter_tools/test/integration.shard/android_gradle_legacy_flutter_plugins_strings_in_comments_test.dart @@ -70,8 +70,8 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.7.0" apply false - id "org.jetbrains.kotlin.android" version "2.1.0" apply false + id "com.android.application" version "8.11.1" apply false + id "org.jetbrains.kotlin.android" version "2.2.20" apply false } include ":app" diff --git a/packages/flutter_tools/test/integration.shard/debug_adapter/test_adapter_test.dart b/packages/flutter_tools/test/integration.shard/debug_adapter/test_adapter_test.dart index 66f8ddffc2bed..6b3e5adad25a3 100644 --- a/packages/flutter_tools/test/integration.shard/debug_adapter/test_adapter_test.dart +++ b/packages/flutter_tools/test/integration.shard/debug_adapter/test_adapter_test.dart @@ -119,6 +119,16 @@ void main() { standardTests(toolArgs: toolArgs); }); + + group('fail fast', () { + test('launch fails if process exits early', () async { + await client.initialize(); + expect( + client.launch(program: 'non_existent_file.dart', cwd: tempDir.path), + throwsA(isA().having((Response r) => r.success, 'success', isFalse)), + ); + }); + }); } /// Matchers for the expected console output of [TestsProject]. diff --git a/packages/flutter_tools/test/integration.shard/debug_adapter/test_client.dart b/packages/flutter_tools/test/integration.shard/debug_adapter/test_client.dart index fbd6cf8c199b7..4441406bf64be 100644 --- a/packages/flutter_tools/test/integration.shard/debug_adapter/test_client.dart +++ b/packages/flutter_tools/test/integration.shard/debug_adapter/test_client.dart @@ -43,6 +43,9 @@ class DapTestClient { ), ); _pendingRequests.clear(); + if (!_eventController.isClosed) { + unawaited(_eventController.close()); + } }, ); } diff --git a/packages/flutter_tools/test/integration.shard/debug_adapter/test_support.dart b/packages/flutter_tools/test/integration.shard/debug_adapter/test_support.dart index c176761ab8b13..c5fd15b69e77f 100644 --- a/packages/flutter_tools/test/integration.shard/debug_adapter/test_support.dart +++ b/packages/flutter_tools/test/integration.shard/debug_adapter/test_support.dart @@ -134,12 +134,13 @@ class DapTestSession { } static Future setUp({List? additionalArgs}) async { - final DapTestServer server = await _startServer(additionalArgs: additionalArgs); + // ignore: avoid_print + final Logger? logger = verboseLogging ? print : null; + final DapTestServer server = await _startServer(logger: logger, additionalArgs: additionalArgs); final DapTestClient client = await DapTestClient.connect( server, captureVmServiceTraffic: verboseLogging, - // ignore: avoid_print - logger: verboseLogging ? print : null, + logger: logger, ); return DapTestSession._(server, client); } diff --git a/packages/flutter_tools/test/integration.shard/isolated/native_assets_flutter_build_test.dart b/packages/flutter_tools/test/integration.shard/isolated/native_assets_flutter_build_test.dart index a2616d62a6dda..fac8ba2aeade0 100644 --- a/packages/flutter_tools/test/integration.shard/isolated/native_assets_flutter_build_test.dart +++ b/packages/flutter_tools/test/integration.shard/isolated/native_assets_flutter_build_test.dart @@ -155,7 +155,7 @@ void _expectDylibIsBundledIos(Directory appDirectory, String buildMode) { CFBundleVersion 1.0 MinimumOSVersion - 13.0 + 15.0 '''); } diff --git a/packages/flutter_tools/test/integration.shard/swift_package_manager_add2app_test.dart b/packages/flutter_tools/test/integration.shard/swift_package_manager_add2app_test.dart index a5b3a954ef5cb..47649121fd472 100644 --- a/packages/flutter_tools/test/integration.shard/swift_package_manager_add2app_test.dart +++ b/packages/flutter_tools/test/integration.shard/swift_package_manager_add2app_test.dart @@ -200,9 +200,10 @@ void main() { ], codesign: codesign, expectedOutput: [ - "Build of product 'flutter-prebuild-tool' complete!", + RegExp(r'Gatekeeper check failed.*\.build\/prebuild\/release\/flutter-prebuild-tool'), + RegExp(r'Gatekeeper check failed.*\.build\/assemble\/release\/flutter-assemble-tool'), + RegExp(r'Build(?: of product .*)? complete!'), 'FlutterPluginRegistrant symlink updated to ./Release', - "Build of product 'flutter-assemble-tool' complete!", 'flutter --verbose assemble', 'release_unpack_${targetPlatform.name.toLowerCase()}: Starting', ], @@ -225,8 +226,9 @@ void main() { 'Verification complete.', ], unexpectedOutput: [ - "Build of product 'flutter-prebuild-tool' complete!", - "Build of product 'flutter-assemble-tool' complete!", + RegExp(r'Gatekeeper check failed.*\.build\/prebuild\/release\/flutter-prebuild-tool'), + RegExp(r'Gatekeeper check failed.*\.build\/assemble\/release\/flutter-assemble-tool'), + RegExp(r'Build(?: of product .*)? complete!'), 'flutter --verbose assemble', 'note: Transfer starting', ], @@ -255,8 +257,9 @@ void main() { 'warning: Alternatively, you can remove FLUTTER_APPLICATION_PATH from your build settings to dismiss this warning.', ], unexpectedOutput: [ - "Build of product 'flutter-prebuild-tool' complete!", - "Build of product 'flutter-assemble-tool' complete!", + RegExp(r'Gatekeeper check failed.*\.build\/prebuild\/release\/flutter-prebuild-tool'), + RegExp(r'Gatekeeper check failed.*\.build\/assemble\/release\/flutter-assemble-tool'), + RegExp(r'Build(?: of product .*)? complete!'), 'flutter --verbose assemble', 'note: Transfer starting', ], @@ -276,8 +279,9 @@ void main() { expectedOutput: ['Verification complete.'], unexpectedOutput: [ 'FlutterPluginRegistrant symlink updated to ./Release', - "Build of product 'flutter-prebuild-tool' complete!", - "Build of product 'flutter-assemble-tool' complete!", + RegExp(r'Gatekeeper check failed.*\.build\/prebuild\/release\/flutter-prebuild-tool'), + RegExp(r'Gatekeeper check failed.*\.build\/assemble\/release\/flutter-assemble-tool'), + RegExp(r'Build(?: of product .*)? complete!'), 'flutter --verbose assemble', 'note: Transfer starting', ], @@ -401,7 +405,8 @@ void main() { 'FLUTTER_APPLICATION_PATH=\$SRCROOT/../$appName', ], expectedOutput: [ - "Build of product 'flutter-assemble-tool' complete!", + RegExp(r'Gatekeeper check failed.*\.build\/assemble\/release\/flutter-assemble-tool'), + RegExp(r'Build(?: of product .*)? complete!'), 'FlutterPluginRegistrant symlink updated to ./Release', 'flutter --verbose assemble', 'release_unpack_${targetPlatform.name.toLowerCase()}: Starting', @@ -452,8 +457,8 @@ Future _buildNativeProject({ required FlutterDarwinPlatform platform, required List buildSettings, bool expectFailure = false, - List expectedOutput = const [], - List unexpectedOutput = const [], + List expectedOutput = const [], + List unexpectedOutput = const [], required bool codesign, }) async { final Map environment = Platform.environment; diff --git a/packages/flutter_tools/test/integration.shard/test_data/deferred_components_project.dart b/packages/flutter_tools/test/integration.shard/test_data/deferred_components_project.dart index 247654bfc380b..bfaec31a28e33 100644 --- a/packages/flutter_tools/test/integration.shard/test_data/deferred_components_project.dart +++ b/packages/flutter_tools/test/integration.shard/test_data/deferred_components_project.dart @@ -111,8 +111,8 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" - id "com.android.application" version "8.9.1" apply false - id "org.jetbrains.kotlin.android" version "2.1.0" apply false + id "com.android.application" version "8.11.1" apply false + id "org.jetbrains.kotlin.android" version "2.2.20" apply false } include ":app", ":component1" diff --git a/packages/flutter_tools/test/src/context.dart b/packages/flutter_tools/test/src/context.dart index 8d5ec2b1a98aa..179b1a74e56cf 100644 --- a/packages/flutter_tools/test/src/context.dart +++ b/packages/flutter_tools/test/src/context.dart @@ -351,17 +351,32 @@ class NoopIOSSimulatorUtils implements IOSSimulatorUtils { } class FakeXcodeProjectInterpreter implements XcodeProjectInterpreter { + FakeXcodeProjectInterpreter({ + bool isInstalled = true, + String? versionText = 'Xcode 15', + Version? version = const Version.withText(15, 0, 0, '15.0.0'), + String? build = '15A240D', + }) : _isInstalled = isInstalled, + _versionText = versionText, + _version = version, + _build = build; + + final bool _isInstalled; + final String? _versionText; + final Version? _version; + final String? _build; + @override - bool get isInstalled => true; + bool get isInstalled => _isInstalled; @override - String get versionText => 'Xcode 15'; + String? get versionText => _versionText; @override - Version get version => Version(15, 0, 0); + Version? get version => _version; @override - String get build => '15A240D'; + String? get build => _build; @override Future> getBuildSettings( @@ -413,7 +428,7 @@ class FakeXcodeProjectInterpreter implements XcodeProjectInterpreter { Future> fetchDependenciesAndGenerateXcodebuildArgs( XcodeBasedProject xcodeProject, Directory buildDirectory, { - bool skipPackageUpdatesAndValidation = true, + bool skipPackageValidation = true, }) async { return ['xcrun', 'xcodebuild']; } diff --git a/pubspec.lock b/pubspec.lock index c34e5a9be7b0e..ad0a8297b3cc9 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -263,10 +263,10 @@ packages: dependency: transitive description: name: dds - sha256: "6673c5b29e502fd44dbf5836685e7c7c16b11c0fa7f91ef241e0cbf0638c8794" + sha256: f8e875dcd7b5e5073a7bfe54095144c70487f18e2f8e47fa51c09dd67eccfb0c url: "https://pub.dev" source: hosted - version: "5.3.0" + version: "5.4.0" dds_service_extensions: dependency: transitive description: @@ -279,10 +279,10 @@ packages: dependency: transitive description: name: devtools_shared - sha256: "2daf7a9fba6a470668b26ecbd04200f7bf992aad81a2c31d12457c7791419dea" + sha256: "13df2c17d5f21dd7c92d08145386d45e1153364c04e531e994afb0ee7b67554b" url: "https://pub.dev" source: hosted - version: "12.1.0" + version: "13.1.0" dtd: dependency: transitive description: