diff --git a/.agents/skills/engine-whats-new/SKILL.md b/.agents/skills/engine-whats-new/SKILL.md new file mode 100644 index 0000000000000..b6adc9e9b9f26 --- /dev/null +++ b/.agents/skills/engine-whats-new/SKILL.md @@ -0,0 +1,72 @@ +--- +name: engine-whats-new +description: > + Generates the "what's new" release summary and diff file for changes in the Flutter engine (//engine/src/flutter) between two releases (e.g., 3.47 vs 3.44). + + When to use: + - Only activate when explicitly asked by the user to generate what's new in the engine, diff Flutter engine releases, or produce an engine release summary. + + When not to use: + - Do not use for general questions about commits, individual file history, or codebase searches unless explicitly requested to generate the engine release diff or summary. +--- + +# Flutter Engine What's New & Diff Skill + +## Workflow + +### 1. Identify Inputs + +Extract the following from the user's request: +* **Target Release (``):** The Flutter release version to analyze (e.g., `3.47`, `3.47.0`, or `flutter-3.47-candidate.0`). +* **Base Release (``):** Optional. The prior Flutter release to compare against (e.g., `3.44`). If omitted by the user, the script will automatically calculate the predecessor release (e.g., for `3.47` it automatically selects `3.44`). + +### 2. Run the Generator Tool + +Execute the Dart script from the Flutter repository root: + +```bash +dart .agents/skills/engine-whats-new/scripts/generate_engine_whats_new.dart --release +``` + +If the user specifies a custom base release or output paths: + +```bash +dart .agents/skills/engine-whats-new/scripts/generate_engine_whats_new.dart --release --from --output-diff engine_diff__to_.diff --output-summary engine_whats_new_.md +``` + +To output structured JSON for programmatic consumption: + +```bash +dart .agents/skills/engine-whats-new/scripts/generate_engine_whats_new.dart --release --format json +``` + +### 3. Review Generated Artifacts & Present to User + +The tool produces two primary artifacts in the repository root: +1. **Diff File (`engine_diff__to_.diff`):** The full unified git diff of all changes in //engine/src/flutter between the two releases. +2. **Summary Document (`engine_whats_new_.md`):** A categorized Markdown summary covering: + * **Overview & Statistics:** Total engine commits, files changed, additions, and deletions. + * **🚀 Impeller & Graphics Rendering:** Vulkan, Metal, OpenGL, shaders, and display list updates. + * **🌐 Web Engine & Wasm:** Web SDK, CanvasKit, and WebAssembly changes. + * **📱 Android Embedding:** Gradle/AGP updates, Android view rendering, and Java/Kotlin embedding changes. + * **🍎 iOS & macOS Embeddings:** Darwin platform view lifecycle, Metal views, and macOS/iOS updates. + * **🪟 Windows & Linux Desktop Embeddings:** Win32 compositor, Linux GTK embedding, and desktop shell fixes. + * **🔤 Text, Typography & Accessibility:** Semantics, IME, font fallback, and text layout improvements. + * **🔄 Dependency Rolls:** Skia, Dart SDK, ICU, HarfBuzz, and ANGLE rolls. + * **🛠️ Build System, CI & Tooling:** GN build files, luci scripts, and testing utilities. + +Provide the user with a concise overview of the results and links to the generated diff and markdown summary files. + +## Examples + +- **User:** "Generate what's new in the engine for Flutter 3.47." +- **Agent:** + 1. Identifies target release `3.47`. + 2. Runs `dart .agents/skills/engine-whats-new/scripts/generate_engine_whats_new.dart --release 3.47`. + 3. Reports the summary statistics (e.g., 354 commits, 1179 files changed) and shares the generated `engine_diff_3.44_to_3.47.diff` and `engine_whats_new_3.47.md`. + +- **User:** "Diff the engine changes between Flutter 3.41 and 3.44." +- **Agent:** + 1. Identifies base release `3.41` and target release `3.44`. + 2. Runs `dart .agents/skills/engine-whats-new/scripts/generate_engine_whats_new.dart --from 3.41 --to 3.44`. + 3. Presents the breakdown of engine changes and the diff file. diff --git a/.agents/skills/engine-whats-new/scripts/README.md b/.agents/skills/engine-whats-new/scripts/README.md new file mode 100644 index 0000000000000..d0775acd8f844 --- /dev/null +++ b/.agents/skills/engine-whats-new/scripts/README.md @@ -0,0 +1,22 @@ +# Flutter Engine What's New Tool + +This directory contains helper scripts for the `engine-whats-new` skill. + +## `generate_engine_whats_new.dart` + +Generates the "What's New" summary and diff file for changes in the Flutter engine directory (`//engine/src/flutter`) between two Flutter releases. + +### Usage + +```bash +dart .agents/skills/engine-whats-new/scripts/generate_engine_whats_new.dart --release [--from ] +``` + +### Options + +* `--release`, `--to`, `--target `: Target Flutter release version (e.g., `3.47`). +* `--from`, `--base `: Base Flutter release version (e.g., `3.44`). If omitted, the tool automatically deduces the preceding quarterly release. +* `--engine-path `: Path to the Flutter engine directory relative to the repository root (defaults to `engine/src/flutter`). +* `--output-diff `: Filepath to write the generated diff (default: `engine_diff__to_.diff`). +* `--output-summary `: Filepath to write the Markdown summary (default: `engine_whats_new_.md`). +* `--format `: Stdout output format (default: `markdown`). diff --git a/.agents/skills/engine-whats-new/scripts/generate_engine_whats_new.dart b/.agents/skills/engine-whats-new/scripts/generate_engine_whats_new.dart new file mode 100644 index 0000000000000..debbbc8e4daed --- /dev/null +++ b/.agents/skills/engine-whats-new/scripts/generate_engine_whats_new.dart @@ -0,0 +1,553 @@ +// 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:convert'; +import 'dart:io'; + +class CommitInfo { + CommitInfo({ + required this.hash, + required this.shortHash, + required this.author, + required this.date, + required this.title, + this.prNumber, + }); + + final String hash; + final String shortHash; + final String author; + final String date; + final String title; + final String? prNumber; + + Map toJson() => { + 'hash': hash, + 'shortHash': shortHash, + 'author': author, + 'date': date, + 'title': title, + 'prNumber': prNumber, + }; +} + +class ReleaseAnalysis { + ReleaseAnalysis({ + required this.baseRelease, + required this.baseRef, + required this.targetRelease, + required this.targetRef, + required this.enginePath, + required this.diffPath, + required this.summaryPath, + required this.totalCommits, + required this.filesChanged, + required this.insertions, + required this.deletions, + required this.categorizedCommits, + }); + + final String baseRelease; + final String baseRef; + final String targetRelease; + final String targetRef; + final String enginePath; + final String diffPath; + final String summaryPath; + final int totalCommits; + final int filesChanged; + final int insertions; + final int deletions; + final Map> categorizedCommits; + + Map toJson() => { + 'baseRelease': baseRelease, + 'baseRef': baseRef, + 'targetRelease': targetRelease, + 'targetRef': targetRef, + 'enginePath': enginePath, + 'diffPath': diffPath, + 'summaryPath': summaryPath, + 'stats': { + 'totalCommits': totalCommits, + 'filesChanged': filesChanged, + 'insertions': insertions, + 'deletions': deletions, + }, + 'categories': categorizedCommits.map( + (String key, List value) => + MapEntry(key, value.map((CommitInfo c) => c.toJson()).toList()), + ), + }; +} + +Directory findRepoRoot() { + Directory dir = Directory.current; + while (dir.path != dir.parent.path) { + if (Directory('${dir.path}/.git').existsSync()) { + return dir; + } + dir = dir.parent; + } + return Directory.current; +} + +ProcessResult runGit(List args, {required String workingDirectory}) { + final ProcessResult result = Process.runSync('git', args, workingDirectory: workingDirectory); + if (result.exitCode != 0) { + throw ProcessException('git', args, result.stderr.toString(), result.exitCode); + } + return result; +} + +String? tryResolveGitRef(String version, String repoRoot) { + final String cleanVersion = version.trim(); + final candidates = [ + cleanVersion, + if (!cleanVersion.contains('.')) '3.$cleanVersion.0', + if (cleanVersion.startsWith('3.') && + !cleanVersion.contains('-') && + cleanVersion.split('.').length == 2) + '$cleanVersion.0', + 'origin/flutter-$cleanVersion-candidate.0', + 'flutter-$cleanVersion-candidate.0', + if (cleanVersion.startsWith('3.')) + 'origin/flutter-${cleanVersion.split('.').take(2).join('.')}-candidate.0', + if (cleanVersion.startsWith('3.')) + 'flutter-${cleanVersion.split('.').take(2).join('.')}-candidate.0', + 'v$cleanVersion', + 'v$cleanVersion.0', + ]; + + for (final candidate in candidates) { + try { + final ProcessResult res = Process.runSync('git', [ + 'rev-parse', + '--verify', + candidate, + ], workingDirectory: repoRoot); + if (res.exitCode == 0) { + return candidate; + } + } catch (_) {} + } + + try { + final ProcessResult tagResult = Process.runSync('git', [ + 'tag', + '-l', + '*$cleanVersion*', + ], workingDirectory: repoRoot); + if (tagResult.exitCode == 0) { + final tagOutput = tagResult.stdout as String; + final List tags = tagOutput + .split('\n') + .map((String s) => s.trim()) + .where((String s) => s.isNotEmpty) + .toList(); + if (tags.isNotEmpty) { + for (final tag in tags) { + if (tag == cleanVersion || tag == '$cleanVersion.0') { + return tag; + } + } + return tags.first; + } + } + } catch (_) {} + + return null; +} + +String deducePreviousRelease(String targetRelease) { + final String normalized = targetRelease + .replaceFirst('flutter-', '') + .replaceFirst('-candidate.0', ''); + final List parts = normalized.split('.'); + if (parts.length >= 2 && parts[0] == '3') { + final int? minor = int.tryParse(parts[1]); + if (minor != null) { + int prevMinor = minor - 3; + if (prevMinor == 28) { + prevMinor = 27; + } + if (prevMinor > 0) { + return '3.$prevMinor'; + } + } + } + return ''; +} + +String? extractPrNumber(String title) { + final RegExpMatch? match = RegExp(r'#(\d+)').firstMatch(title); + return match?.group(1); +} + +String categorizeCommit(String title) { + final String lower = title.toLowerCase(); + + if (title.startsWith('Roll Skia') || + title.startsWith('Roll Dart SDK') || + title.startsWith('Roll ICU') || + title.startsWith('Roll HarfBuzz') || + title.startsWith('Roll ANGLE') || + lower.contains('roll skia') || + lower.contains('roll dart sdk') || + lower.contains('roll icu')) { + return '🔄 Dependency Rolls'; + } + + if (lower.contains('impeller') || + lower.contains('ubersdf') || + lower.contains('flutter gpu') || + lower.contains('display_list') || + lower.contains('displaylist') || + lower.contains('vulkan') || + lower.contains('metal') || + lower.contains('opengl') || + lower.contains('shader') || + lower.contains('render') || + lower.contains('flow')) { + return '🚀 Impeller & Graphics Rendering'; + } + + if (lower.contains('[web]') || + lower.contains('web_ui') || + lower.contains('web_sdk') || + lower.contains('wasm') || + lower.contains('skwasm') || + lower.contains('html') || + lower.contains('canvaskit')) { + return '🌐 Web Engine & Wasm'; + } + + if (lower.contains('[android]') || + lower.contains('android') || + lower.contains('agp') || + lower.contains('gradle') || + lower.contains('embedding/engine')) { + return '📱 Android Embedding'; + } + + if (lower.contains('[ios]') || + lower.contains('[macos]') || + lower.contains('[darwin]') || + lower.contains('darwin') || + lower.contains('ios') || + lower.contains('macos') || + lower.contains('xcode') || + lower.contains('metalview')) { + return '🍎 iOS & macOS Embeddings'; + } + + if (lower.contains('[windows]') || + lower.contains('[linux]') || + lower.contains('windows') || + lower.contains('linux') || + lower.contains('win32') || + lower.contains('embedder')) { + return '🪟 Windows & Linux Desktop Embeddings'; + } + + if (lower.contains('[a11y]') || + lower.contains('semantics') || + lower.contains('accessibility') || + lower.contains('typography') || + lower.contains('txt') || + lower.contains('font') || + lower.contains('text input') || + lower.contains('autofill')) { + return '🔤 Text, Typography & Accessibility'; + } + + if (lower.contains('[ci]') || + lower.contains('ci:') || + lower.contains('build.gn') || + lower.contains('tools') || + lower.contains('testing') || + lower.contains('header_guard') || + lower.contains('license') || + lower.contains('format')) { + return '🛠️ Build System, CI & Tooling'; + } + + return '⚙️ Core Runtime & Shell'; +} + +ReleaseAnalysis analyzeEngineDiff({ + required String repoRoot, + required String baseRelease, + required String targetRelease, + String enginePath = 'engine/src/flutter', + String? outputDiffPath, + String? outputSummaryPath, +}) { + final String? baseRef = tryResolveGitRef(baseRelease, repoRoot); + if (baseRef == null) { + throw ArgumentError('Could not resolve git reference for base release "$baseRelease".'); + } + + final String? targetRef = tryResolveGitRef(targetRelease, repoRoot); + if (targetRef == null) { + throw ArgumentError('Could not resolve git reference for target release "$targetRelease".'); + } + + final String resolvedDiffPath = + outputDiffPath ?? + 'engine_diff_${baseRelease.replaceAll('/', '_')}_to_${targetRelease.replaceAll('/', '_')}.diff'; + final String resolvedSummaryPath = + outputSummaryPath ?? 'engine_whats_new_${targetRelease.replaceAll('/', '_')}.md'; + + final ProcessResult diffResult = runGit([ + 'diff', + '$baseRef..$targetRef', + '--', + enginePath, + ], workingDirectory: repoRoot); + File('$repoRoot/$resolvedDiffPath').writeAsStringSync(diffResult.stdout as String); + + var filesChanged = 0; + var insertions = 0; + var deletions = 0; + + final ProcessResult statResult = runGit([ + 'diff', + '--shortstat', + '$baseRef..$targetRef', + '--', + enginePath, + ], workingDirectory: repoRoot); + final String statStr = (statResult.stdout as String).trim(); + if (statStr.isNotEmpty) { + final RegExpMatch? filesMatch = RegExp(r'(\d+)\s+files? changed').firstMatch(statStr); + final RegExpMatch? insMatch = RegExp(r'(\d+)\s+insertions?\(\+\)').firstMatch(statStr); + final RegExpMatch? delMatch = RegExp(r'(\d+)\s+deletions?\(-\)').firstMatch(statStr); + + if (filesMatch != null) { + filesChanged = int.parse(filesMatch.group(1)!); + } + if (insMatch != null) { + insertions = int.parse(insMatch.group(1)!); + } + if (delMatch != null) { + deletions = int.parse(delMatch.group(1)!); + } + } + + final ProcessResult logResult = runGit([ + 'log', + '--pretty=format:%H%x09%h%x09%an%x09%ad%x09%s', + '--date=short', + '$baseRef..$targetRef', + '--', + enginePath, + ], workingDirectory: repoRoot); + + final List lines = (logResult.stdout as String) + .split('\n') + .map((String s) => s.trim()) + .where((String s) => s.isNotEmpty) + .toList(); + + final categorized = >{ + '🚀 Impeller & Graphics Rendering': [], + '🌐 Web Engine & Wasm': [], + '📱 Android Embedding': [], + '🍎 iOS & macOS Embeddings': [], + '🪟 Windows & Linux Desktop Embeddings': [], + '🔤 Text, Typography & Accessibility': [], + '🔄 Dependency Rolls': [], + '🛠️ Build System, CI & Tooling': [], + '⚙️ Core Runtime & Shell': [], + }; + + for (final line in lines) { + final List parts = line.split('\t'); + if (parts.length >= 5) { + final String hash = parts[0]; + final String shortHash = parts[1]; + final String author = parts[2]; + final String date = parts[3]; + final String title = parts.sublist(4).join('\t'); + final String? pr = extractPrNumber(title); + + final commit = CommitInfo( + hash: hash, + shortHash: shortHash, + author: author, + date: date, + title: title, + prNumber: pr, + ); + + final String cat = categorizeCommit(title); + categorized.putIfAbsent(cat, () => []).add(commit); + } + } + + final analysis = ReleaseAnalysis( + baseRelease: baseRelease, + baseRef: baseRef, + targetRelease: targetRelease, + targetRef: targetRef, + enginePath: enginePath, + diffPath: resolvedDiffPath, + summaryPath: resolvedSummaryPath, + totalCommits: lines.length, + filesChanged: filesChanged, + insertions: insertions, + deletions: deletions, + categorizedCommits: categorized, + ); + + final String summaryContent = generateMarkdownSummary(analysis); + File('$repoRoot/$resolvedSummaryPath').writeAsStringSync(summaryContent); + + return analysis; +} + +String generateMarkdownSummary(ReleaseAnalysis analysis) { + final buffer = StringBuffer(); + + buffer.writeln("# What's New in Flutter Engine (Release ${analysis.targetRelease})"); + buffer.writeln(); + buffer.writeln( + 'Comparing changes in `//${analysis.enginePath}` from **${analysis.baseRelease}** (`${analysis.baseRef}`) to **${analysis.targetRelease}** (`${analysis.targetRef}`).', + ); + buffer.writeln(); + buffer.writeln('## 📊 Overview & Statistics'); + buffer.writeln(); + buffer.writeln('- **Diff File Generated:** [${analysis.diffPath}](${analysis.diffPath})'); + buffer.writeln('- **Total Engine Commits:** ${analysis.totalCommits}'); + buffer.writeln('- **Files Changed:** ${analysis.filesChanged}'); + buffer.writeln('- **Lines Added:** +${analysis.insertions}'); + buffer.writeln('- **Lines Removed:** -${analysis.deletions}'); + buffer.writeln(); + + buffer.writeln('## 🌟 Subsystem Breakdown'); + buffer.writeln(); + + for (final MapEntry> entry in analysis.categorizedCommits.entries) { + if (entry.value.isEmpty) { + continue; + } + buffer.writeln('### ${entry.key} (${entry.value.length} commits)'); + buffer.writeln(); + for (final CommitInfo c in entry.value) { + final String prLink = c.prNumber != null + ? '[#${c.prNumber}](https://github.com/flutter/flutter/pull/${c.prNumber})' + : c.shortHash; + buffer.writeln('- ${c.title} ($prLink by *${c.author}*)'); + } + buffer.writeln(); + } + + return buffer.toString(); +} + +void printHelp() { + stdout.writeln("Flutter Engine What's New & Diff Generator"); + stdout.writeln(); + stdout.writeln('Usage:'); + stdout.writeln( + ' dart generate_engine_whats_new.dart --release [--from ]', + ); + stdout.writeln(); + stdout.writeln('Options:'); + stdout.writeln( + ' --release, --to, --target Target Flutter release version (e.g. 3.47)', + ); + stdout.writeln( + ' --from, --base Base Flutter release version (e.g. 3.44). If omitted, automatically determined.', + ); + stdout.writeln( + ' --engine-path Engine directory relative to repo root (default: engine/src/flutter)', + ); + stdout.writeln( + ' --output-diff Output diff file path (default: engine_diff__to_.diff)', + ); + stdout.writeln( + ' --output-summary Output summary markdown file path (default: engine_whats_new_.md)', + ); + stdout.writeln( + ' --format Stdout output format (default: markdown)', + ); + stdout.writeln(' -h, --help Show this help message'); +} + +void main(List args) { + if (args.isEmpty || args.contains('-h') || args.contains('--help')) { + printHelp(); + exit(args.isEmpty ? 1 : 0); + } + + String? targetRelease; + String? baseRelease; + var enginePath = 'engine/src/flutter'; + String? outputDiff; + String? outputSummary; + var format = 'markdown'; + + for (var i = 0; i < args.length; i++) { + final String arg = args[i]; + if ((arg == '--release' || arg == '--to' || arg == '--target') && i + 1 < args.length) { + targetRelease = args[++i]; + } else if ((arg == '--from' || arg == '--base') && i + 1 < args.length) { + baseRelease = args[++i]; + } else if (arg == '--engine-path' && i + 1 < args.length) { + enginePath = args[++i]; + } else if (arg == '--output-diff' && i + 1 < args.length) { + outputDiff = args[++i]; + } else if (arg == '--output-summary' && i + 1 < args.length) { + outputSummary = args[++i]; + } else if (arg == '--format' && i + 1 < args.length) { + format = args[++i].toLowerCase(); + } else if (!arg.startsWith('-') && targetRelease == null) { + targetRelease = arg; + } + } + + if (targetRelease == null) { + stderr.writeln('Error: Target release version is required (e.g. --release 3.47).'); + printHelp(); + exit(1); + } + + final String repoRoot = findRepoRoot().path; + + if (baseRelease == null || baseRelease.isEmpty) { + baseRelease = deducePreviousRelease(targetRelease); + if (baseRelease.isEmpty) { + stderr.writeln( + 'Error: Could not automatically deduce previous release for "$targetRelease". Please specify --from .', + ); + exit(1); + } + } + + try { + final ReleaseAnalysis analysis = analyzeEngineDiff( + repoRoot: repoRoot, + baseRelease: baseRelease, + targetRelease: targetRelease, + enginePath: enginePath, + outputDiffPath: outputDiff, + outputSummaryPath: outputSummary, + ); + + if (format == 'json') { + stdout.writeln(jsonEncode(analysis.toJson())); + } else { + stdout.writeln('Generated Engine Diff: ${analysis.diffPath}'); + stdout.writeln("Generated What's New Summary: ${analysis.summaryPath}"); + stdout.writeln( + 'Total Commits: ${analysis.totalCommits} | Files Changed: ${analysis.filesChanged} (+${analysis.insertions} / -${analysis.deletions})', + ); + } + } catch (e) { + stderr.writeln("Error generating engine what's new: $e"); + exit(1); + } +} diff --git a/.ci.yaml b/.ci.yaml index bcd7286136359..d8f3a738f017c 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:145.0.7632.117"}, {"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:145.0.7632.117"}, {"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:145.0.7632.117"}, {"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:145.0.7632.117"}, {"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:145.0.7632.117"}, {"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:145.0.7632.117"}, {"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:145.0.7632.117"}, {"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:145.0.7632.117"}, {"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:145.0.7632.117"}, {"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"} ] @@ -1805,7 +1805,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"} ] @@ -1832,7 +1832,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"} ] @@ -3935,7 +3935,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"} @@ -3957,7 +3957,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"} @@ -3979,7 +3979,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"} @@ -4001,7 +4001,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"} @@ -4023,7 +4023,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"} @@ -4040,7 +4040,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"} @@ -4057,7 +4057,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"} @@ -4074,7 +4074,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"} @@ -4091,7 +4091,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"} @@ -4108,7 +4108,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"} @@ -4258,7 +4258,7 @@ targets: recipe: flutter/flutter_drone timeout: 60 properties: - cpu: x86 # https://github.com/flutter/flutter/issues/119880 + cpu: arm64 # https://github.com/flutter/flutter/issues/119880 dependencies: >- [ {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"} @@ -4286,7 +4286,7 @@ targets: recipe: flutter/flutter_drone timeout: 60 properties: - cpu: x86 # https://github.com/flutter/flutter/issues/119880 + cpu: arm64 # https://github.com/flutter/flutter/issues/119880 dependencies: >- [ {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"} @@ -4322,7 +4322,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 @@ -4353,7 +4353,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 @@ -4379,7 +4379,7 @@ targets: recipe: flutter/flutter_drone timeout: 60 properties: - cpu: x86 # https://github.com/flutter/flutter/issues/119880 + cpu: arm64 # https://github.com/flutter/flutter/issues/119880 dependencies: >- [ {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"} @@ -4409,7 +4409,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] tags: > @@ -4457,7 +4457,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] tags: > @@ -4477,7 +4477,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] tags: > @@ -4497,7 +4497,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] tags: > @@ -4518,7 +4518,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] tags: > @@ -4600,7 +4600,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"} ] @@ -4665,7 +4665,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] tags: > @@ -4685,7 +4685,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] tags: > @@ -4782,6 +4782,9 @@ targets: - name: Mac_arm64 tool_host_cross_arch_tests recipe: flutter/flutter_drone timeout: 60 + enabled_branches: + - beta # TODO(flutter/flutter#189302): Remove after 3.48.0-0.1.pre is released. + - stable # TODO(flutter/flutter#189302): Delete this builder after 3.50 is released. properties: dependencies: >- [ @@ -4807,7 +4810,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:145.0.7632.117"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, @@ -4833,7 +4836,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:145.0.7632.117"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, @@ -4859,7 +4862,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:145.0.7632.117"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, @@ -4885,7 +4888,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:145.0.7632.117"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, @@ -4911,7 +4914,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:145.0.7632.117"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, @@ -4937,7 +4940,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:145.0.7632.117"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, @@ -4963,7 +4966,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:145.0.7632.117"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, @@ -4989,7 +4992,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:145.0.7632.117"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, @@ -5015,7 +5018,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:145.0.7632.117"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, @@ -5041,7 +5044,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:145.0.7632.117"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "ruby", "version": "ruby_3.1-pod_1.13"}, @@ -5069,7 +5072,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 @@ -5084,7 +5087,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 @@ -5099,7 +5102,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 @@ -6160,7 +6163,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"} @@ -6177,7 +6180,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"} @@ -6194,7 +6197,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"} @@ -6211,7 +6214,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"} @@ -6228,7 +6231,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"} @@ -6245,7 +6248,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"} @@ -6262,7 +6265,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"} @@ -6279,7 +6282,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"} @@ -6296,7 +6299,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"} @@ -6384,7 +6387,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 @@ -6416,7 +6419,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 @@ -6502,7 +6505,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] tags: > @@ -6547,7 +6550,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] tags: > @@ -6567,7 +6570,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] tags: > @@ -6587,7 +6590,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] tags: > @@ -6609,7 +6612,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] tags: > @@ -6656,7 +6659,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] tags: > @@ -6676,7 +6679,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] tags: > @@ -6696,7 +6699,7 @@ targets: properties: dependencies: >- [ - {"dependency": "android_sdk", "version": "version:36v9unmodified"}, + {"dependency": "android_sdk", "version": "version:37v2"}, {"dependency": "open_jdk", "version": "version:21"} ] tags: > @@ -6858,7 +6861,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:145.0.7632.117"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, @@ -6884,7 +6887,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:145.0.7632.117"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, @@ -6910,7 +6913,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:145.0.7632.117"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, @@ -6936,7 +6939,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:145.0.7632.117"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, @@ -6962,7 +6965,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:145.0.7632.117"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, @@ -6988,7 +6991,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:145.0.7632.117"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, @@ -7014,7 +7017,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:145.0.7632.117"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, @@ -7040,7 +7043,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:145.0.7632.117"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, @@ -7066,7 +7069,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:145.0.7632.117"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, @@ -7092,7 +7095,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:145.0.7632.117"}, {"dependency": "open_jdk", "version": "version:21"}, {"dependency": "goldctl", "version": "git_revision:c845c41b9b81bfcb11f2f0ab17b5b2386d634c31"}, @@ -7118,7 +7121,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"} ] @@ -7165,7 +7168,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/.github/actions/composite-flutter-setup/action.yml b/.github/actions/composite-flutter-setup/action.yml index 5e71b9efd1958..44c09ff189660 100644 --- a/.github/actions/composite-flutter-setup/action.yml +++ b/.github/actions/composite-flutter-setup/action.yml @@ -140,7 +140,8 @@ runs: - name: Setup PUB_CACHE environment variable shell: bash run: | - echo "PUB_CACHE=/opt/pub-cache" >> $GITHUB_ENV + mkdir -p "$RUNNER_TEMP/pub-cache" + echo "PUB_CACHE=$RUNNER_TEMP/pub-cache" >> "$GITHUB_ENV" # Get the Flutter revision. This is the key for the cache for artifacts # under bin/cache @@ -160,15 +161,16 @@ runs: shell: bash id: pub-deps-hash run: | - # Generate stable hash of pubspec.yaml files - find dev examples packages -name "pubspec.yaml" -print0 | sort -z | xargs -0 cat | sha256sum >> "$RUNNER_TEMP/pub_deps_sha" + # Generate a stable hash for github caching from all the pubspec.yaml + # files in the tree. Includes the root pubspec.yaml. + git ls-tree HEAD -- $(git ls-files ':(glob)**/pubspec.yaml') | git hash-object --stdin > "$RUNNER_TEMP/pub_deps_sha" echo "revision=$(cat "$RUNNER_TEMP/pub_deps_sha")" >> "$GITHUB_OUTPUT" - name: pub package cache uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae id: pub-cache with: path: | - /opt/pub-cache + ${{ runner.temp }}/pub-cache ${{ github.workspace }}/**/.dart_tool ${{ github.workspace }}/**/pubspec.lock key: ${{ runner.os }}-pub-${{ steps.pub-deps-hash.outputs.revision }} diff --git a/.github/actions/has-engine-changes/action.yml b/.github/actions/has-engine-changes/action.yml new file mode 100644 index 0000000000000..bbf5f1e465a9d --- /dev/null +++ b/.github/actions/has-engine-changes/action.yml @@ -0,0 +1,46 @@ +name: 'Has Engine Changes' +description: 'Checks if there are any engine changes in the current branch' +outputs: + changed: + description: "Returns 'true' if there are engine changes, 'false' otherwise" + value: ${{ steps.check.outputs.changed }} +runs: + using: "composite" + steps: + - name: Check Engine Changes + id: check + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} + MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }} + PUSH_BEFORE_SHA: ${{ github.event.before }} + run: | + EMPTY_SHA="0000000000000000000000000000000000000000" + if [ "$EVENT_NAME" = "pull_request" ]; then + BASE_REF="$PR_BASE_SHA" + elif [ "$EVENT_NAME" = "merge_group" ]; then + BASE_REF="$MERGE_GROUP_BASE_SHA" + elif [ "$EVENT_NAME" = "push" ] && [ -n "$PUSH_BEFORE_SHA" ] && [ "$PUSH_BEFORE_SHA" != "$EMPTY_SHA" ]; then + BASE_REF="$PUSH_BEFORE_SHA" + else + if git show-ref --verify --quiet refs/remotes/upstream/master; then + BASE_REF="upstream/master" + else + BASE_REF="origin/master" + fi + fi + + echo "Comparing against base SHA: $BASE_REF" + + CHANGED_FILES=$(.github/scripts/git_files_changed.sh "$BASE_REF") + echo "Changed files in this branch:" + echo "$CHANGED_FILES" + + if [ "$(printf '%s\n' "$CHANGED_FILES" | .github/scripts/did_engine_change.sh)" = "true" ]; then + echo "Engine files changed." + echo "changed=true" >> "$GITHUB_OUTPUT" + else + echo "No engine files changed." + echo "changed=false" >> "$GITHUB_OUTPUT" + fi diff --git a/.github/actions/wait-for-engine-build/action.yml b/.github/actions/wait-for-engine-build/action.yml index b2f4ca0b1a70b..b422fde685f77 100644 --- a/.github/actions/wait-for-engine-build/action.yml +++ b/.github/actions/wait-for-engine-build/action.yml @@ -17,40 +17,16 @@ runs: steps: - name: Check Engine Changes id: check_engine + uses: ./.github/actions/has-engine-changes + + - name: Log wait requirement + if: steps.check_engine.outputs.changed == 'true' shell: bash env: - EVENT_NAME: ${{ github.event_name }} - PR_BASE_SHA: ${{ github.event.pull_request.base.sha }} - MERGE_GROUP_BASE_SHA: ${{ github.event.merge_group.base_sha }} - PUSH_BEFORE_SHA: ${{ github.event.before }} INPUTS_CHECK_NAME: ${{ inputs.check-name }} INPUTS_CHECK_REGEXP: ${{ inputs.check-regexp }} run: | - # 1. Determine local base tracking SHA - if [ "$EVENT_NAME" = "pull_request" ]; then - BASE_REF="$PR_BASE_SHA" - elif [ "$EVENT_NAME" = "merge_group" ]; then - BASE_REF="$MERGE_GROUP_BASE_SHA" - elif [ "$EVENT_NAME" = "push" ] && [ "$PUSH_BEFORE_SHA" != "0000000000000000000000000000000000000000" ]; then - BASE_REF="$PUSH_BEFORE_SHA" - else - BASE_REF="origin/master" - fi - - echo "Comparing against base SHA: $BASE_REF" - - # 2. Get the changed files using the helper script - CHANGED_FILES=$(.github/scripts/git_files_changed.sh "$BASE_REF") - echo "Changed files in this branch:" - echo "$CHANGED_FILES" - - # 3. Check if any file matches engine pattern using the helper script: - if [ "$(printf '%s\n' "$CHANGED_FILES" | .github/scripts/did_engine_change.sh)" = "true" ]; then - echo "Engine file changed - forced to wait for check-name: '${INPUTS_CHECK_NAME}', check-regexp: '${INPUTS_CHECK_REGEXP}'" - echo "changed=true" >> "$GITHUB_OUTPUT" - else - echo "changed=false" >> "$GITHUB_OUTPUT" - fi + echo "Engine file changed - forced to wait for check-name: '${INPUTS_CHECK_NAME}', check-regexp: '${INPUTS_CHECK_REGEXP}'" # Note: Use the Cocoon wait-for-tests action to poll the Cocoon API. - name: Wait for test check-in diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 32f7f7fa70817..d60b57f69330f 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -17,6 +17,6 @@ jobs: runs-on: ubuntu-latest steps: # Source available at https://github.com/actions/labeler/blob/main/README.md - - uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d + - uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 with: sync-labels: true diff --git a/.github/workflows/mac-verify-binaries.yml b/.github/workflows/mac-verify-binaries.yml new file mode 100644 index 0000000000000..0effc07e308c8 --- /dev/null +++ b/.github/workflows/mac-verify-binaries.yml @@ -0,0 +1,55 @@ +name: Mac Verify Binaries + +on: + pull_request: + branches: [master] + merge_group: + branches: [master] + workflow_dispatch: + +jobs: + check-engine-changes: + permissions: + contents: read + runs-on: ubuntu-latest + outputs: + changed: ${{ steps.check.outputs.changed }} + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Check Engine Changes + id: check + uses: ./.github/actions/has-engine-changes + + Mac_arm64_verify_binaries: + needs: check-engine-changes + if: needs.check-engine-changes.outputs.changed == 'true' + permissions: + contents: read + runs-on: macos-latest + timeout-minutes: 130 + + steps: + - name: Checkout code + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + + - name: Wait for Engine Build + uses: ./.github/actions/wait-for-engine-build + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + check-name: 'Mac mac_host_engine,Mac mac_ios_engine,Linux linux_host_engine' + + - uses: ./.github/actions/composite-flutter-setup + + - name: Run verify_binaries_pre_codesigned + run: | + SHARD=verify_binaries_pre_codesigned LUCI_CI=true dart --enable-asserts dev/bots/test.dart diff --git a/.github/workflows/scheduled-localization-update.yml b/.github/workflows/scheduled-localization-update.yml index 9e029a3da13bf..ff0bffdea28b5 100644 --- a/.github/workflows/scheduled-localization-update.yml +++ b/.github/workflows/scheduled-localization-update.yml @@ -28,7 +28,7 @@ jobs: GH_REPO: ${{ github.repository }} TITLE: '[Automated Task] Quarterly Localizations Update (flutter_localizations)' ASSIGNEES: QuncCccccc - LABELS: automated task,a: internationalization,team-framework + LABELS: 'automated task,a: internationalization,team-framework' BODY: | ### Quarterly Localization Update diff --git a/AUTHORS b/AUTHORS index a59779edc32cf..bb4526e522dd9 100644 --- a/AUTHORS +++ b/AUTHORS @@ -147,3 +147,4 @@ Michal Kucharski Alexander Dmitriev Solvejet Mohamed Risaldar Uppil Thodi +Ishaq Hassan diff --git a/DEPS b/DEPS index dcdf366846475..1666d37147f40 100644 --- a/DEPS +++ b/DEPS @@ -16,7 +16,7 @@ vars = { 'skia_git': 'https://skia.googlesource.com', 'llvm_git': 'https://llvm.googlesource.com', 'dart_ai_rev': '9c96bfe5f091c9451eff5b59c9bffeb2e806b875', - 'skia_revision': '5e183e5aeac5ed96b403e54335ece52a42f630ac', + 'skia_revision': 'f73c4510d12da77512253d3a00d72275b45427c7', # Do not download the Emscripten SDK by default. # This prevents us from downloading the Emscripten toolchain for builds @@ -56,24 +56,24 @@ 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': '1e65011ee00428473ccf6dfdfa83b9700855df29', + 'dart_revision': 'c3acfc2479f6eae42f7cbafe5e5518a2dd757b81', # 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': '922c15f36cc75db5af33c46f9ea8934553fb808e', - 'dart_core_rev': 'be0b1531c445a185d3e93887b8d0355fc766c314', - 'dart_devtools_rev': '12d595649f189f1896722623f72599077f476848', - 'dart_ecosystem_rev': '848b3bf3b757d2e9ae4d60030eeed5756c87783f', + 'dart_boringssl_rev': '22a0079b189c391b95689813a41982ce11876f0a', + 'dart_core_rev': 'fe516ee1b38cc60e7a8c6e082c337037a043d782', + 'dart_devtools_rev': '21f1838f3a9b138ac377efb953ca5a53c8832e75', + 'dart_ecosystem_rev': 'edfdb3b4063b9034b708144633a700204f865f43', 'dart_http_rev': '5d94ef52582867e077bf41c3fa20fb8b1d1d834e', - 'dart_i18n_rev': 'd0683bdea253d19a4350f5bc2be9017aba61837f', + 'dart_i18n_rev': 'e1b5a798f8922bb27bbc6d858748ece6f9a19f02', 'dart_perfetto_rev': '13ce0c9e13b0940d2476cd0cff2301708a9a2e2b', 'dart_protobuf_rev': '91efb90f437bb6a30e6726c3369a2fcb9bba06e7', 'dart_pub_rev': 'ec276d10a7fa0f6c6ec005340fb9ad29f3b012d0', 'dart_sync_http_rev': '6666fff944221891182e1f80bf56569338164d72', - 'dart_tools_rev': '3f850c4fd27c3a19abd91889a8261e0bf2fd1663', + 'dart_tools_rev': 'b827a6e38b934232c7f7b8728a5aef6165e7df2d', 'dart_vector_math_rev': 'cf3b5db7340d317dd3489e5a35434b408020a852', - 'dart_web_rev': 'eb8c3fc61a1e35f48f865836c7c7342897d91bcc', + 'dart_web_rev': '12a9ca2ebc08f5a6f2d69aebc7daa1f5a2e6a431', 'dart_webdriver_rev': '3a711ebb36871eac997c5d5d2429f7414873dc63', 'dart_webkit_inspection_protocol_rev': '762115a971d1968bc940454ad1e88d506d8c5640', @@ -201,7 +201,7 @@ vars = { # The version / instance id of the cipd:chromium/fuchsia/test-scripts which # will be used altogether with fuchsia-sdk to setup the build / test # environment. - 'fuchsia_test_scripts_version': 'wLST_A-xfOeGT_5mje7Wi3mmnxOddKpEWQ9PDbQl6QEC', + 'fuchsia_test_scripts_version': '1frGe_KltAJKkeyPgy4cDJqScCYVYSpC9sJfjflcvl4C', # The version / instance id of the cipd:chromium/fuchsia/gn-sdk which will be # used altogether with fuchsia-sdk to generate gn based build rules. @@ -330,7 +330,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:12d595649f189f1896722623f72599077f476848'}]}, + {'dep_type': 'cipd', 'packages': [{'package': 'dart/third_party/flutter/devtools', 'version': 'git_revision:21f1838f3a9b138ac377efb953ca5a53c8832e75'}]}, 'engine/src/flutter/third_party/dart/third_party/perfetto/src': Var('chromium_git') + '/external/github.com/google/perfetto' + '@' + Var('dart_perfetto_rev'), @@ -342,7 +342,7 @@ deps = { Var('dart_git') + '/dart_style.git@dfdf6420c7ea923d28edef3f11e89b4ff23d03bf', 'engine/src/flutter/third_party/dart/third_party/pkg/dartdoc': - Var('dart_git') + '/dartdoc.git@1d56f263955f329b6701d8f84f069eb0aef353a4', + Var('dart_git') + '/dartdoc.git@ac96918074974dcd4ea20f764f17090f58c1e428', 'engine/src/flutter/third_party/dart/third_party/pkg/ecosystem': Var('dart_git') + '/ecosystem.git' + '@' + Var('dart_ecosystem_rev'), @@ -366,7 +366,7 @@ deps = { Var('dart_git') + '/pub.git' + '@' + Var('dart_pub_rev'), 'engine/src/flutter/third_party/dart/third_party/pkg/shelf': - Var('dart_git') + '/shelf.git@71248e727317930f244c4b4535e9733bcfc66677', + Var('dart_git') + '/shelf.git@6918a7690946044b4098e9f6735439044c676e13', 'engine/src/flutter/third_party/dart/third_party/pkg/sync_http': Var('dart_git') + '/sync_http.git' + '@' + Var('dart_sync_http_rev'), @@ -393,7 +393,7 @@ deps = { Var('dart_git') + '/external/github.com/google/webkit_inspection_protocol.dart.git' + '@' + Var('dart_webkit_inspection_protocol_rev'), 'engine/src/flutter/third_party/dart/tools/sdks/dart-sdk': - {'dep_type': 'cipd', 'packages': [{'package': 'dart/dart-sdk/${{platform}}', 'version': 'version:3.13.0-272.0.dev'}]}, + {'dep_type': 'cipd', 'packages': [{'package': 'dart/dart-sdk/${{platform}}', 'version': 'version:3.14.0-75.0.dev'}]}, # WARNING: end of dart dependencies list that is cleaned up automatically - see create_updated_flutter_deps.py. @@ -553,7 +553,7 @@ deps = { Var('chromium_git') + '/external/github.com/GPUOpen-LibrariesAndSDKs/VulkanMemoryAllocator' + '@' + 'c788c52156f3ef7bc7ab769cb03c110a53ac8fcb', 'engine/src/third_party/abseil-cpp': - Var('chromium_git') + '/chromium/src/third_party/abseil-cpp' + '@' + '564023aa53767b5f60b3a556f0a025b7b7e8241e', + Var('chromium_git') + '/chromium/src/third_party/abseil-cpp' + '@' + 'ff6e8ce3e932c16cebd1611c8fc42c45080a0e55', # Dart packages 'engine/src/flutter/third_party/pkg/archive': @@ -597,7 +597,7 @@ deps = { { # See tools/gradle/README.md for update instructions. # Version here means the CIPD tag. - 'version': 'version:8.11.1', + 'version': 'version:9.3.1', 'package': 'flutter/gradle' } ], @@ -637,7 +637,7 @@ deps = { 'packages': [ { 'package': 'flutter/android/sdk/all/${{platform}}', - 'version': 'version:36v9unmodified' + 'version': 'version:37v2' } ], 'condition': 'download_android_deps', @@ -833,7 +833,7 @@ deps = { 'packages': [ { 'package': 'fuchsia/sdk/core/linux-amd64', - 'version': 'GswhlPRO-D1qSNclxUWiXunqJcd3VqLBeNJNGL29QvwC' + 'version': '_J8wM3kyQpLN9wvRD5upBr9L1g5TCYF3Oc8P0m3QeZMC' } ], 'condition': 'download_fuchsia_deps and not download_fuchsia_sdk', diff --git a/README.md b/README.md index 94de84e49a7a1..9c228ee187312 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,6 @@ [![Discord badge][]][Discord instructions] [![Twitter handle][]][Twitter badge] [![BlueSky badge][]][BlueSky handle] -[![codecov](https://codecov.io/gh/flutter/flutter/branch/master/graph/badge.svg?token=11yDrJU2M2)](https://codecov.io/gh/flutter/flutter) [![LFX Health Score](https://insights.linuxfoundation.org/api/badge/health-score?project=flutter)](https://insights.linuxfoundation.org/project/flutter) [![CII Best Practices](https://bestpractices.coreinfrastructure.org/projects/5631/badge)](https://bestpractices.coreinfrastructure.org/projects/5631) [![SLSA 1](https://slsa.dev/images/gh-badge-level1.svg)](https://slsa.dev) diff --git a/analysis_options.yaml b/analysis_options.yaml index 4c40f88316561..74a5e1e3b0eeb 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,28 +1,6 @@ -# Specify analysis options. -# -# For a list of lints, see: https://dart.dev/tools/linter-rules -# For guidelines on configuring static analysis, see: -# https://dart.dev/tools/analysis -# -# There are other similar analysis options files in the flutter repos, -# which should be kept in sync with this file: -# -# - analysis_options.yaml (this file) -# - https://github.com/flutter/packages/blob/main/analysis_options.yaml -# -# This file contains the analysis options used for code in the flutter/flutter -# repository. +include: analysis_options_common.yaml analyzer: - language: - strict-casts: true - strict-inference: true - errors: - # allow deprecated members (we do this because otherwise we have to annotate - # every member in every test, assert, etc, when we or the Dart SDK deprecates - # something (https://github.com/flutter/flutter/issues/143312) - deprecated_member_use: ignore - deprecated_member_use_from_same_package: ignore exclude: - "bin/cache/**" # Ignore protoc generated files @@ -35,243 +13,3 @@ analyzer: - windows/** - macos/** - linux/** - -formatter: - page_width: 100 - -linter: - rules: - # This list is derived from the list of all available lints located at - # https://github.com/dart-lang/sdk/blob/main/pkg/linter/example/all.yaml - - always_declare_return_types - - always_put_control_body_on_new_line - # - always_put_required_named_parameters_first # we prefer having parameters in the same order as fields https://github.com/flutter/flutter/issues/10219 - # - always_specify_types # conflicts with omit_obvious_local_variable_types - # - always_use_package_imports # we do this commonly - - annotate_overrides - - annotate_redeclares - # - avoid_annotating_with_dynamic # conflicts with type_annotate_public_apis - - avoid_bool_literals_in_conditional_expressions - # - avoid_catches_without_on_clauses # blocked on https://github.com/dart-lang/linter/issues/3023 - # - avoid_catching_errors # blocked on https://github.com/dart-lang/linter/issues/4998 - # - avoid_classes_with_only_static_members # we do this commonly for `abstract final class`es - - avoid_double_and_int_checks - - avoid_dynamic_calls - - avoid_empty_else - - avoid_equals_and_hash_code_on_mutable_classes - - avoid_escaping_inner_quotes - - avoid_field_initializers_in_const_classes - # TODO(kallentu): Remove this lint once the Dart SDK in Flutter is on version 3.13. - - avoid_final_parameters - - avoid_function_literals_in_foreach_calls - # - avoid_futureor_void # not yet tested - # - avoid_implementing_value_types # see https://github.com/dart-lang/linter/issues/4558 - - avoid_init_to_null - - avoid_js_rounded_ints - # - avoid_multiple_declarations_per_line # seems to be a stylistic choice we don't subscribe to - # - avoid_positional_boolean_parameters # would have been nice to enable this but by now there's too many places that break it - - avoid_print - # - avoid_private_typedef_functions # we prefer having typedef (discussion in https://github.com/flutter/flutter/pull/16356) - - avoid_redundant_argument_values - - avoid_relative_lib_imports - - avoid_renaming_method_parameters - - avoid_return_types_on_setters - - avoid_returning_null_for_void - # - avoid_returning_this # there are enough valid reasons to return `this` that this lint ends up with too many false positives - - avoid_setters_without_getters - - avoid_shadowing_type_parameters - - avoid_single_cascade_in_expression_statements - - avoid_slow_async_io - - avoid_type_to_string - - avoid_types_as_parameter_names - # - avoid_types_on_closure_parameters # not yet tested - - avoid_unnecessary_containers - - avoid_unused_constructor_parameters - - avoid_void_async - # - avoid_web_libraries_in_flutter # we use web libraries in web-specific code, and our tests prevent us from using them elsewhere - - await_only_futures - - camel_case_extensions - - camel_case_types - - cancel_subscriptions - # - cascade_invocations # doesn't match the typical style of this repo - - cast_nullable_to_non_nullable - # - close_sinks # not reliable enough - - collection_methods_unrelated_type - - combinators_ordering - # - comment_references # blocked on https://github.com/dart-lang/linter/issues/1142 - - conditional_uri_does_not_exist - # - constant_identifier_names # needs an opt-out https://github.com/dart-lang/linter/issues/204 - - control_flow_in_finally - - curly_braces_in_flow_control_structures - - dangling_library_doc_comments - - depend_on_referenced_packages - - deprecated_consistency - # - deprecated_member_use_from_same_package # we allow self-references to deprecated members - # - diagnostic_describe_all_properties # enabled only at the framework level (packages/flutter/lib) - - directives_ordering - # - discarded_futures # too many false positives, similar to unawaited_futures - # - do_not_use_environment # there are appropriate times to use the environment, especially in our tests and build logic - # - document_ignores # not yet tested - - empty_catches - - empty_constructor_bodies - - empty_statements - - eol_at_end_of_file - - exhaustive_cases - - file_names - - flutter_style_todos - - hash_and_equals - - implementation_imports - - implicit_call_tearoffs - - implicit_reopen - - invalid_case_patterns - - invalid_runtime_check_with_js_interop_types - # - join_return_with_assignment # not required by flutter style - - leading_newlines_in_multiline_strings - - library_annotations - - library_names - - library_prefixes - - library_private_types_in_public_api - # - lines_longer_than_80_chars # not required by flutter style - - literal_only_boolean_expressions - # - matching_super_parameters # blocked on https://github.com/dart-lang/language/issues/2509 - - missing_code_block_language_in_doc_comment - - missing_whitespace_between_adjacent_strings - - no_adjacent_strings_in_list - - no_default_cases - - no_duplicate_case_values - - no_leading_underscores_for_library_prefixes - - no_leading_underscores_for_local_identifiers - - no_literal_bool_comparisons - - no_logic_in_create_state - - no_raw_types - # - no_runtimeType_toString # ok in tests; we enable this only in packages/ - - no_self_assignments - - no_wildcard_variable_uses - - non_constant_identifier_names - - noop_primitive_operations - - null_check_on_nullable_type_parameter - - null_closures - # - omit_local_variable_types # superset of omit_obvious_local_variable_types - - omit_obvious_local_variable_types # not yet tested - # - omit_obvious_property_types # conflicts with type_annotate_public_apis - # - one_member_abstracts # too many false positives - - only_throw_errors # this does get disabled in a few places where we have legacy code that uses strings et al - - overridden_fields - - package_names - - package_prefixed_library_names - # - parameter_assignments # we do this commonly - - prefer_adjacent_string_concatenation - - prefer_asserts_in_initializer_lists - # - prefer_asserts_with_message # not required by flutter style - - prefer_collection_literals - - prefer_conditional_assignment - - prefer_const_constructors - - prefer_const_constructors_in_immutables - - prefer_const_declarations - - prefer_const_literals_to_create_immutables - # - prefer_constructors_over_static_methods # far too many false positives - - prefer_contains - # - prefer_double_quotes # opposite of prefer_single_quotes - # - prefer_expression_function_bodies # conflicts with ./docs/contributing/Style-guide-for-Flutter-repo.md#consider-using--for-short-functions-and-methods - - prefer_final_fields - - prefer_final_in_for_each - - prefer_final_locals - - prefer_for_elements_to_map_fromIterable - - prefer_foreach - - prefer_function_declarations_over_variables - - prefer_generic_function_type_aliases - - prefer_if_elements_to_conditional_expressions - - prefer_if_null_operators - - prefer_initializing_formals - - prefer_inlined_adds - # - prefer_int_literals # conflicts with ./docs/contributing/Style-guide-for-Flutter-repo.md#use-double-literals-for-double-constants - - prefer_interpolation_to_compose_strings - - prefer_is_empty - - prefer_is_not_empty - - prefer_is_not_operator - - prefer_iterable_whereType - - prefer_mixin - # - prefer_null_aware_method_calls # "call()" is confusing to people new to the language since it's not documented anywhere - - prefer_null_aware_operators - - prefer_relative_imports - - prefer_single_quotes - - prefer_spread_collections - - prefer_typing_uninitialized_variables - - prefer_void_to_null - - provide_deprecation_message - # - public_member_api_docs # enabled on a case-by-case basis; see e.g. packages/analysis_options.yaml - - recursive_getters - # - require_trailing_commas # would be nice, but requires a lot of manual work: 10,000+ code locations would need to be reformatted by hand after bulk fix is applied - - secure_pubspec_urls - - sized_box_for_whitespace - - sized_box_shrink_expand - - slash_for_doc_comments - - sort_child_properties_last - - sort_constructors_first - # - sort_pub_dependencies # prevents separating pinned transitive dependencies - - sort_unnamed_constructors_first - - specify_nonobvious_local_variable_types - - specify_nonobvious_property_types - - strict_top_level_inference - - test_types_in_equals - - throw_in_finally - - tighten_type_of_initializing_formals - - type_annotate_public_apis - - type_init_formals - - type_literal_in_constant_pattern - # - unawaited_futures # too many false positives, especially with the way AnimationController works - # - unintended_html_in_doc_comment # blocked on https://github.com/dart-lang/linter/issues/5065 - # - unnecessary_async # not yet tested - - unnecessary_await_in_return - - unnecessary_brace_in_string_interps - - unnecessary_breaks - - unnecessary_const - - unnecessary_constructor_name - # - unnecessary_final # conflicts with prefer_final_locals - - unnecessary_getters_setters - # - unnecessary_ignore # Disabled by default to simplify migrations; should be periodically enabled locally to clean up offenders - # - unnecessary_lambdas # has false positives: https://github.com/dart-lang/linter/issues/498 - - unnecessary_late - - unnecessary_library_directive - # - unnecessary_library_name # blocked on https://github.com/dart-lang/dartdoc/issues/3882 - - unnecessary_new - - unnecessary_null_aware_assignments - - unnecessary_null_aware_operator_on_extension_on_nullable - - unnecessary_null_checks - - unnecessary_null_in_if_null_operators - - unnecessary_nullable_for_final_variable_declarations - - unnecessary_overrides - - unnecessary_parenthesis - # - unnecessary_raw_strings # what's "necessary" is a matter of opinion; consistency across strings can help readability more than this lint - - unnecessary_statements - - unnecessary_string_escapes - - unnecessary_string_interpolations - - unnecessary_this - - unnecessary_to_list_in_spreads - - unnecessary_underscores - - unreachable_from_main - - unrelated_type_equality_checks - # - unsafe_variance # not yet tested - - use_build_context_synchronously - - use_colored_box - # - use_decorated_box # leads to bugs: DecoratedBox and Container are not equivalent (Container inserts extra padding) - - use_enums - - use_full_hex_values_for_flutter_colors - - use_function_type_syntax_for_parameters - - use_is_even_rather_than_modulo - - use_key_in_widget_constructors - - use_late_for_private_fields_and_variables - - use_named_constants - - use_raw_strings - - use_rethrow_when_possible - - use_setters_to_change_properties - # - use_string_buffers # has false positives: https://github.com/dart-lang/sdk/issues/34182 - - use_string_in_part_of_directives - - use_super_parameters - - use_test_throws_matchers - # - use_to_and_as_if_applicable # has false positives, so we prefer to catch this by code-review - - use_truncating_division - - valid_regexps - # TODO(kallentu): Remove this lint once the Dart SDK in Flutter is on version 3.13. - - var_with_no_type_annotation - - void_checks diff --git a/analysis_options_common.yaml b/analysis_options_common.yaml new file mode 100644 index 0000000000000..79e8ce2d77b68 --- /dev/null +++ b/analysis_options_common.yaml @@ -0,0 +1,265 @@ +# Specify analysis options. +# +# For a list of lints, see: https://dart.dev/tools/linter-rules +# For guidelines on configuring static analysis, see: +# https://dart.dev/tools/analysis +# +# There are other similar analysis options files in the flutter repos, +# which should be kept in sync with this file: +# +# - analysis_options.yaml (this file) +# - https://github.com/flutter/packages/blob/main/analysis_options.yaml +# +# This file contains the analysis options used for code in the flutter/flutter +# repository. + +analyzer: + language: + strict-casts: true + strict-inference: true + errors: + # allow deprecated members (we do this because otherwise we have to annotate + # every member in every test, assert, etc, when we or the Dart SDK deprecates + # something (https://github.com/flutter/flutter/issues/143312) + deprecated_member_use: ignore + deprecated_member_use_from_same_package: ignore + +formatter: + page_width: 100 + +linter: + rules: + # This list is derived from the list of all available lints located at + # https://github.com/dart-lang/sdk/blob/main/pkg/linter/example/all.yaml + - always_declare_return_types + - always_put_control_body_on_new_line + # - always_put_required_named_parameters_first # we prefer having parameters in the same order as fields https://github.com/flutter/flutter/issues/10219 + # - always_specify_types # conflicts with omit_obvious_local_variable_types + # - always_use_package_imports # we do this commonly + - annotate_overrides + - annotate_redeclares + # - avoid_annotating_with_dynamic # conflicts with type_annotate_public_apis + - avoid_bool_literals_in_conditional_expressions + # - avoid_catches_without_on_clauses # blocked on https://github.com/dart-lang/linter/issues/3023 + # - avoid_catching_errors # blocked on https://github.com/dart-lang/linter/issues/4998 + # - avoid_classes_with_only_static_members # we do this commonly for `abstract final class`es + - avoid_double_and_int_checks + - avoid_dynamic_calls + - avoid_empty_else + - avoid_equals_and_hash_code_on_mutable_classes + - avoid_escaping_inner_quotes + - avoid_field_initializers_in_const_classes + # TODO(kallentu): Remove this lint once the Dart SDK in Flutter is on version 3.13. + - avoid_final_parameters + - avoid_function_literals_in_foreach_calls + # - avoid_futureor_void # not yet tested + # - avoid_implementing_value_types # see https://github.com/dart-lang/linter/issues/4558 + - avoid_init_to_null + - avoid_js_rounded_ints + # - avoid_multiple_declarations_per_line # seems to be a stylistic choice we don't subscribe to + # - avoid_positional_boolean_parameters # would have been nice to enable this but by now there's too many places that break it + - avoid_print + # - avoid_private_typedef_functions # we prefer having typedef (discussion in https://github.com/flutter/flutter/pull/16356) + - avoid_redundant_argument_values + - avoid_relative_lib_imports + - avoid_renaming_method_parameters + - avoid_return_types_on_setters + - avoid_returning_null_for_void + # - avoid_returning_this # there are enough valid reasons to return `this` that this lint ends up with too many false positives + - avoid_setters_without_getters + - avoid_shadowing_type_parameters + - avoid_single_cascade_in_expression_statements + - avoid_slow_async_io + - avoid_type_to_string + - avoid_types_as_parameter_names + # - avoid_types_on_closure_parameters # not yet tested + - avoid_unnecessary_containers + - avoid_unused_constructor_parameters + - avoid_void_async + # - avoid_web_libraries_in_flutter # we use web libraries in web-specific code, and our tests prevent us from using them elsewhere + - await_only_futures + - camel_case_extensions + - camel_case_types + - cancel_subscriptions + # - cascade_invocations # doesn't match the typical style of this repo + - cast_nullable_to_non_nullable + # - close_sinks # not reliable enough + - collection_methods_unrelated_type + - combinators_ordering + # - comment_references # blocked on https://github.com/dart-lang/linter/issues/1142 + - conditional_uri_does_not_exist + # - constant_identifier_names # needs an opt-out https://github.com/dart-lang/linter/issues/204 + - control_flow_in_finally + - curly_braces_in_flow_control_structures + - dangling_library_doc_comments + - depend_on_referenced_packages + - deprecated_consistency + # - deprecated_member_use_from_same_package # we allow self-references to deprecated members + # - diagnostic_describe_all_properties # enabled only at the framework level (packages/flutter/lib) + - directives_ordering + # - discarded_futures # too many false positives, similar to unawaited_futures + # - do_not_use_environment # there are appropriate times to use the environment, especially in our tests and build logic + # - document_ignores # not yet tested + - empty_catches + - empty_constructor_bodies + - empty_statements + - eol_at_end_of_file + - exhaustive_cases + - file_names + - flutter_style_todos + - hash_and_equals + - implementation_imports + - implicit_call_tearoffs + - implicit_reopen + - invalid_case_patterns + - invalid_runtime_check_with_js_interop_types + # - join_return_with_assignment # not required by flutter style + - leading_newlines_in_multiline_strings + - library_annotations + - library_names + - library_prefixes + - library_private_types_in_public_api + # - lines_longer_than_80_chars # not required by flutter style + - literal_only_boolean_expressions + # - matching_super_parameters # blocked on https://github.com/dart-lang/language/issues/2509 + - missing_code_block_language_in_doc_comment + - missing_whitespace_between_adjacent_strings + - no_adjacent_strings_in_list + - no_default_cases + - no_duplicate_case_values + - no_leading_underscores_for_library_prefixes + - no_leading_underscores_for_local_identifiers + - no_literal_bool_comparisons + - no_logic_in_create_state + - no_raw_types + # - no_runtimeType_toString # ok in tests; we enable this only in packages/ + - no_self_assignments + - no_wildcard_variable_uses + - non_constant_identifier_names + - noop_primitive_operations + - null_check_on_nullable_type_parameter + - null_closures + # - omit_local_variable_types # superset of omit_obvious_local_variable_types + - omit_obvious_local_variable_types # not yet tested + # - omit_obvious_property_types # conflicts with type_annotate_public_apis + # - one_member_abstracts # too many false positives + - only_throw_errors # this does get disabled in a few places where we have legacy code that uses strings et al + - overridden_fields + - package_names + - package_prefixed_library_names + # - parameter_assignments # we do this commonly + - prefer_adjacent_string_concatenation + - prefer_asserts_in_initializer_lists + # - prefer_asserts_with_message # not required by flutter style + - prefer_collection_literals + - prefer_conditional_assignment + - prefer_const_constructors + - prefer_const_constructors_in_immutables + - prefer_const_declarations + - prefer_const_literals_to_create_immutables + # - prefer_constructors_over_static_methods # far too many false positives + - prefer_contains + # - prefer_double_quotes # opposite of prefer_single_quotes + # - prefer_expression_function_bodies # conflicts with ./docs/contributing/Style-guide-for-Flutter-repo.md#consider-using--for-short-functions-and-methods + - prefer_final_fields + - prefer_final_in_for_each + - prefer_final_locals + - prefer_for_elements_to_map_fromIterable + - prefer_foreach + - prefer_function_declarations_over_variables + - prefer_generic_function_type_aliases + - prefer_if_elements_to_conditional_expressions + - prefer_if_null_operators + - prefer_initializing_formals + - prefer_inlined_adds + # - prefer_int_literals # conflicts with ./docs/contributing/Style-guide-for-Flutter-repo.md#use-double-literals-for-double-constants + - prefer_interpolation_to_compose_strings + - prefer_is_empty + - prefer_is_not_empty + - prefer_is_not_operator + - prefer_iterable_whereType + - prefer_mixin + # - prefer_null_aware_method_calls # "call()" is confusing to people new to the language since it's not documented anywhere + - prefer_null_aware_operators + - prefer_relative_imports + - prefer_single_quotes + - prefer_spread_collections + - prefer_typing_uninitialized_variables + - prefer_void_to_null + - provide_deprecation_message + # - public_member_api_docs # enabled on a case-by-case basis; see e.g. packages/analysis_options.yaml + - recursive_getters + # - require_trailing_commas # would be nice, but requires a lot of manual work: 10,000+ code locations would need to be reformatted by hand after bulk fix is applied + - secure_pubspec_urls + - sized_box_for_whitespace + - sized_box_shrink_expand + - slash_for_doc_comments + - sort_child_properties_last + - sort_constructors_first + # - sort_pub_dependencies # prevents separating pinned transitive dependencies + - sort_unnamed_constructors_first + - specify_nonobvious_local_variable_types + - specify_nonobvious_property_types + - strict_top_level_inference + - test_types_in_equals + - throw_in_finally + - tighten_type_of_initializing_formals + - type_annotate_public_apis + - type_init_formals + - type_literal_in_constant_pattern + # - unawaited_futures # too many false positives, especially with the way AnimationController works + # - unintended_html_in_doc_comment # blocked on https://github.com/dart-lang/linter/issues/5065 + # - unnecessary_async # not yet tested + - unnecessary_await_in_return + - unnecessary_brace_in_string_interps + - unnecessary_breaks + - unnecessary_const + - unnecessary_constructor_name + # - unnecessary_final # conflicts with prefer_final_locals + - unnecessary_getters_setters + # - unnecessary_ignore # Disabled by default to simplify migrations; should be periodically enabled locally to clean up offenders + # - unnecessary_lambdas # has false positives: https://github.com/dart-lang/linter/issues/498 + - unnecessary_late + - unnecessary_library_directive + # - unnecessary_library_name # blocked on https://github.com/dart-lang/dartdoc/issues/3882 + - unnecessary_new + - unnecessary_null_aware_assignments + - unnecessary_null_aware_operator_on_extension_on_nullable + - unnecessary_null_checks + - unnecessary_null_in_if_null_operators + - unnecessary_nullable_for_final_variable_declarations + - unnecessary_overrides + - unnecessary_parenthesis + # - unnecessary_raw_strings # what's "necessary" is a matter of opinion; consistency across strings can help readability more than this lint + - unnecessary_statements + - unnecessary_string_escapes + - unnecessary_string_interpolations + - unnecessary_this + - unnecessary_to_list_in_spreads + - unnecessary_underscores + - unreachable_from_main + - unrelated_type_equality_checks + # - unsafe_variance # not yet tested + - use_build_context_synchronously + - use_colored_box + # - use_decorated_box # leads to bugs: DecoratedBox and Container are not equivalent (Container inserts extra padding) + - use_enums + - use_full_hex_values_for_flutter_colors + - use_function_type_syntax_for_parameters + - use_is_even_rather_than_modulo + - use_key_in_widget_constructors + - use_late_for_private_fields_and_variables + - use_named_constants + - use_raw_strings + - use_rethrow_when_possible + - use_setters_to_change_properties + # - use_string_buffers # has false positives: https://github.com/dart-lang/sdk/issues/34182 + - use_string_in_part_of_directives + - use_super_parameters + - use_test_throws_matchers + # - use_to_and_as_if_applicable # has false positives, so we prefer to catch this by code-review + - use_truncating_division + - valid_regexps + # TODO(kallentu): Remove this lint once the Dart SDK in Flutter is on version 3.13. + - var_with_no_type_annotation + - void_checks diff --git a/bin/internal/flutter_packages.version b/bin/internal/flutter_packages.version index 4a8596baa32f7..a6ebf2a65b6f0 100644 --- a/bin/internal/flutter_packages.version +++ b/bin/internal/flutter_packages.version @@ -1 +1 @@ -8260a1e5670ce6a92ee83419975f062e79ca15d0 +5351d8c0f8df210f09d79aea5960a5d48ceb44af diff --git a/dev/a11y_assessments/android/gradle/wrapper/gradle-wrapper.properties b/dev/a11y_assessments/android/gradle/wrapper/gradle-wrapper.properties index a20f2c46d22ec..bdc0141f5311c 100644 --- a/dev/a11y_assessments/android/gradle/wrapper/gradle-wrapper.properties +++ b/dev/a11y_assessments/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip diff --git a/dev/a11y_assessments/ios/Flutter/AppFrameworkInfo.plist b/dev/a11y_assessments/ios/Flutter/AppFrameworkInfo.plist index 0d14080090af7..391a902b2bebe 100644 --- a/dev/a11y_assessments/ios/Flutter/AppFrameworkInfo.plist +++ b/dev/a11y_assessments/ios/Flutter/AppFrameworkInfo.plist @@ -20,7 +20,5 @@ ???? CFBundleVersion 1.0 - MinimumOSVersion - 15.0 diff --git a/dev/a11y_assessments/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/dev/a11y_assessments/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 8e3ca5dfe1936..15cada4838e2f 100644 --- a/dev/a11y_assessments/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/dev/a11y_assessments/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -59,6 +59,7 @@ ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" debugServiceExtension = "internal" + enableGPUValidationMode = "1" allowLocationSimulation = "YES"> diff --git a/dev/a11y_assessments/ios/Runner/AppDelegate.swift b/dev/a11y_assessments/ios/Runner/AppDelegate.swift index 58f3ea9b92990..68cf615f9babd 100644 --- a/dev/a11y_assessments/ios/Runner/AppDelegate.swift +++ b/dev/a11y_assessments/ios/Runner/AppDelegate.swift @@ -11,7 +11,12 @@ import UIKit _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } } + +extension AppDelegate: FlutterImplicitEngineDelegate { + func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { + GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) + } +} diff --git a/dev/a11y_assessments/ios/Runner/Info.plist b/dev/a11y_assessments/ios/Runner/Info.plist index 74b9bb41e71fc..0249c9c6bd697 100644 --- a/dev/a11y_assessments/ios/Runner/Info.plist +++ b/dev/a11y_assessments/ios/Runner/Info.plist @@ -45,5 +45,26 @@ UIApplicationSupportsIndirectInputEvents + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneDelegateClassName + FlutterSceneDelegate + UISceneConfigurationName + flutter + UISceneStoryboardFile + Main + + + + diff --git a/dev/benchmarks/complex_layout/android/gradle/wrapper/gradle-wrapper.properties b/dev/benchmarks/complex_layout/android/gradle/wrapper/gradle-wrapper.properties index a20f2c46d22ec..bdc0141f5311c 100644 --- a/dev/benchmarks/complex_layout/android/gradle/wrapper/gradle-wrapper.properties +++ b/dev/benchmarks/complex_layout/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip diff --git a/dev/benchmarks/macrobenchmarks/android/gradle/wrapper/gradle-wrapper.properties b/dev/benchmarks/macrobenchmarks/android/gradle/wrapper/gradle-wrapper.properties index a20f2c46d22ec..bdc0141f5311c 100644 --- a/dev/benchmarks/macrobenchmarks/android/gradle/wrapper/gradle-wrapper.properties +++ b/dev/benchmarks/macrobenchmarks/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip diff --git a/dev/benchmarks/microbenchmarks/android/gradle/wrapper/gradle-wrapper.properties b/dev/benchmarks/microbenchmarks/android/gradle/wrapper/gradle-wrapper.properties index a20f2c46d22ec..bdc0141f5311c 100644 --- a/dev/benchmarks/microbenchmarks/android/gradle/wrapper/gradle-wrapper.properties +++ b/dev/benchmarks/microbenchmarks/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip diff --git a/dev/benchmarks/platform_views_layout/android/gradle/wrapper/gradle-wrapper.properties b/dev/benchmarks/platform_views_layout/android/gradle/wrapper/gradle-wrapper.properties index a20f2c46d22ec..bdc0141f5311c 100644 --- a/dev/benchmarks/platform_views_layout/android/gradle/wrapper/gradle-wrapper.properties +++ b/dev/benchmarks/platform_views_layout/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip diff --git a/dev/benchmarks/platform_views_layout_hybrid_composition/android/gradle/wrapper/gradle-wrapper.properties b/dev/benchmarks/platform_views_layout_hybrid_composition/android/gradle/wrapper/gradle-wrapper.properties index a20f2c46d22ec..bdc0141f5311c 100644 --- a/dev/benchmarks/platform_views_layout_hybrid_composition/android/gradle/wrapper/gradle-wrapper.properties +++ b/dev/benchmarks/platform_views_layout_hybrid_composition/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip diff --git a/dev/benchmarks/test_apps/stocks/android/gradle/wrapper/gradle-wrapper.properties b/dev/benchmarks/test_apps/stocks/android/gradle/wrapper/gradle-wrapper.properties index a20f2c46d22ec..bdc0141f5311c 100644 --- a/dev/benchmarks/test_apps/stocks/android/gradle/wrapper/gradle-wrapper.properties +++ b/dev/benchmarks/test_apps/stocks/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip diff --git a/dev/bots/check_examples_cross_imports.dart b/dev/bots/check_examples_cross_imports.dart new file mode 100644 index 0000000000000..c0bbe0a117a00 --- /dev/null +++ b/dev/bots/check_examples_cross_imports.dart @@ -0,0 +1,1111 @@ +// 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. + +// To run this, from the root of the Flutter repository: +// bin/cache/dart-sdk/bin/dart --enable-asserts dev/bots/check_examples_cross_imports.dart + +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; +import 'package:path/path.dart' as path; + +import 'cross_imports_checker_utils.dart'; +import 'utils.dart'; + +final String _scriptLocation = path.fromUri(Platform.script); +final String _flutterRoot = path.dirname(path.dirname(path.dirname(_scriptLocation))); +final String _examplesDirectoryPath = path.join(_flutterRoot, 'examples'); + +void main(List args) { + final argParser = ArgParser(); + argParser.addFlag('help', negatable: false, help: 'Print help for this command.'); + argParser.addOption( + 'examples', + valueHelp: 'path', + defaultsTo: _examplesDirectoryPath, + help: 'A location where the examples are found.', + ); + argParser.addOption( + 'flutter-root', + valueHelp: 'path', + defaultsTo: _flutterRoot, + help: 'The path to the root of the Flutter repo.', + ); + final ArgResults parsedArgs; + + void usage() { + print('dart --enable-asserts ${path.basename(_scriptLocation)} [options]'); + print(argParser.usage); + } + + try { + parsedArgs = argParser.parse(args); + } on FormatException catch (e) { + print(e.message); + usage(); + exit(1); + } + + if (parsedArgs['help'] as bool) { + usage(); + exit(0); + } + + const FileSystem filesystem = LocalFileSystem(); + final Directory examplesDirectory = filesystem.directory(parsedArgs['examples']! as String); + final Directory flutterRoot = filesystem.directory(parsedArgs['flutter-root']! as String); + + final checker = ExamplesCrossImportChecker( + examplesDirectory: examplesDirectory, + flutterRoot: flutterRoot, + ); + + if (!checker.check()) { + reportErrorsAndExit('Some errors were found in the examples imports.'); + } + reportSuccessAndExit('No errors were detected with examples cross imports.'); +} + +/// Checks the examples in `examples/**` libraries for cross imports. +/// +/// Excludes known examples that contain cross imports, i.e. +/// [ExamplesCrossImportChecker.knownExamplesFlutterViewCrossImports] and +/// [ExamplesCrossImportChecker.knownExamplesImageListCrossImports]. +/// +/// No examples should import Material or Cupertino. +/// Any Material or Cupertino specific examples should go in +/// packages/material_ui or packages/cupertino_ui respectively. +class ExamplesCrossImportChecker { + ExamplesCrossImportChecker({ + required this.examplesDirectory, + required this.flutterRoot, + this.filesystem = const LocalFileSystem(), + }); + + final Directory examplesDirectory; + final Directory flutterRoot; + final FileSystem filesystem; + + static const String _kSampleTemplatesDirectoryName = 'sample_templates'; + + /// The known cross imports in the `examples/` directory, including subdirectories. + /// + /// These cross imports should all eventually be resolved, but until they are we allow them, so + /// that we can catch any new cross imports that are added. + // TODO(justinmc): Fix all of these tests so there are no cross imports. + // See https://github.com/flutter/flutter/issues/187645. + static final Set knownExamplesCrossImports = { + 'examples/api/lib/animation/animation_controller/animated_digit.0.dart', + 'examples/api/lib/animation/curves/curve2_d.0.dart', + 'examples/api/test/animation/animation_controller/animated_digit.0_test.dart', + 'examples/api/test/animation/curves/curve2_d.0_test.dart', + 'examples/api/lib/foundation/key/value_key.0.dart', + 'examples/api/test/foundation/key/value_key.0_test.dart', + 'examples/api/lib/gestures/pointer_signal_resolver/pointer_signal_resolver.0.dart', + 'examples/api/lib/gestures/tap_and_drag/tap_and_drag.0.dart', + 'examples/api/test/gestures/pointer_signal_resolver/pointer_signal_resolver.0_test.dart', + 'examples/api/test/gestures/tap_and_drag/tap_and_drag.0_test.dart', + 'examples/api/lib/painting/gradient/linear_gradient.0.dart', + 'examples/api/lib/painting/star_border/star_border.0.dart', + 'examples/api/lib/painting/axis_direction/axis_direction.0.dart', + 'examples/api/lib/painting/borders/border_side.stroke_align.0.dart', + 'examples/api/lib/painting/linear_border/linear_border.0.dart', + 'examples/api/lib/painting/image_provider/image_provider.0.dart', + 'examples/api/lib/painting/rounded_superellipse_border/rounded_superellipse_border.0.dart', + 'examples/api/test/painting/gradient/linear_gradient.0_test.dart', + 'examples/api/test/painting/star_border/star_border.0_test.dart', + 'examples/api/test/painting/axis_direction/axis_direction.0_test.dart', + 'examples/api/test/painting/borders/border_side.stroke_align.0_test.dart', + 'examples/api/test/painting/linear_border/linear_border.0_test.dart', + 'examples/api/test/painting/rounded_superellipse_border/rounded_superellipse_border.0_test.dart', + 'examples/api/lib/rendering/sliver_grid/sliver_grid_delegate_with_fixed_cross_axis_count.0.dart', + 'examples/api/lib/rendering/sliver_grid/sliver_grid_delegate_with_fixed_cross_axis_count.1.dart', + 'examples/api/lib/rendering/growth_direction/growth_direction.0.dart', + 'examples/api/lib/rendering/box/parent_data.0.dart', + 'examples/api/lib/rendering/scroll_direction/scroll_direction.0.dart', + 'examples/api/test/rendering/sliver_grid/sliver_grid_delegate_with_fixed_cross_axis_count.1_test.dart', + 'examples/api/test/rendering/sliver_grid/sliver_grid_delegate_with_fixed_cross_axis_count.0_test.dart', + 'examples/api/test/rendering/growth_direction/growth_direction.0_test.dart', + 'examples/api/test/rendering/box/parent_data.0_test.dart', + 'examples/api/test/rendering/scroll_direction/scroll_direction.0_test.dart', + 'examples/api/lib/services/mouse_cursor/mouse_cursor.0.dart', + 'examples/api/lib/services/binding/handle_request_app_exit.0.dart', + 'examples/api/lib/services/text_input/text_input_control.0.dart', + 'examples/api/lib/services/keyboard_key/physical_keyboard_key.0.dart', + 'examples/api/lib/services/keyboard_key/logical_keyboard_key.0.dart', + 'examples/api/lib/services/system_chrome/system_chrome.set_system_u_i_overlay_style.0.dart', + 'examples/api/lib/services/system_chrome/system_chrome.set_system_u_i_overlay_style.1.dart', + 'examples/api/test/services/text_input/text_input_control.0_test.dart', + 'examples/api/test/services/system_chrome/system_chrome.set_system_u_i_overlay_style.0_test.dart', + 'examples/api/test/services/system_chrome/system_chrome.set_system_u_i_overlay_style.1_test.dart', + 'examples/api/test/ui/text/font_feature.font_feature_ordinal_forms.0_test.dart', + 'examples/api/test/ui/text/font_feature.font_feature_contextual_alternates.0_test.dart', + 'examples/api/test/ui/text/font_feature.font_feature_alternative_fractions.0_test.dart', + 'examples/api/test/ui/text/font_feature.font_feature_oldstyle_figures.0_test.dart', + 'examples/api/test/ui/text/font_feature.font_feature_stylistic_alternates.0_test.dart', + 'examples/api/test/ui/text/font_feature.font_feature_historical_forms.0_test.dart', + 'examples/api/test/ui/text/font_feature.font_feature_stylistic_set.0_test.dart', + 'examples/api/test/ui/text/font_feature.font_feature_slashed_zero.0_test.dart', + 'examples/api/test/ui/text/font_feature.font_feature_superscripts.0_test.dart', + 'examples/api/test/ui/text/font_feature.font_feature_case_sensitive_forms.0_test.dart', + 'examples/api/test/ui/text/font_feature.font_feature_proportional_figures.0_test.dart', + 'examples/api/test/ui/text/font_feature.font_feature_historical_ligatures.0_test.dart', + 'examples/api/test/ui/text/font_feature.font_feature_tabular_figures.0_test.dart', + 'examples/api/test/ui/text/font_feature.font_feature_notational_forms.0_test.dart', + 'examples/api/test/ui/text/font_feature.font_feature_stylistic_set.1_test.dart', + 'examples/api/test/ui/text/font_feature.font_feature_alternative.0_test.dart', + 'examples/api/test/ui/text/font_feature.font_feature_locale_aware.0_test.dart', + 'examples/api/test/ui/text/font_feature.font_feature_fractions.0_test.dart', + 'examples/api/test/ui/text/font_feature.font_feature_denominator.0_test.dart', + 'examples/api/test/ui/text/font_feature.font_feature_lining_figures.0_test.dart', + 'examples/api/test/ui/text/font_feature.font_feature_numerators.0_test.dart', + 'examples/api/test/ui/text/font_feature.font_feature_swash.0_test.dart', + 'examples/api/test/ui/text/font_feature.font_feature_scientific_inferiors.0_test.dart', + 'examples/api/test/ui/text/font_feature.font_feature_subscripts.0_test.dart', + 'examples/api/test/ui/text/font_feature.font_feature_character_variant.0_test.dart', + 'examples/api/lib/widgets/animated_grid/sliver_animated_grid.0.dart', + 'examples/api/lib/widgets/animated_grid/animated_grid.0.dart', + 'examples/api/lib/widgets/navigator_pop_handler/navigator_pop_handler.1.dart', + 'examples/api/lib/widgets/navigator_pop_handler/navigator_pop_handler.0.dart', + 'examples/api/lib/widgets/editable_text/editable_text.on_changed.0.dart', + 'examples/api/lib/widgets/editable_text/text_editing_controller.0.dart', + 'examples/api/lib/widgets/editable_text/text_editing_controller.1.dart', + 'examples/api/lib/widgets/editable_text/editable_text.on_content_inserted.0.dart', + 'examples/api/lib/widgets/page/page_can_pop.0.dart', + 'examples/api/lib/widgets/undo_history/undo_history_controller.0.dart', + 'examples/api/lib/widgets/raw_tooltip/raw_tooltip.0.dart', + 'examples/api/lib/widgets/form/form.1.dart', + 'examples/api/lib/widgets/form/form.0.dart', + 'examples/api/lib/widgets/layout_builder/layout_builder.0.dart', + 'examples/api/lib/widgets/tap_region/text_field_tap_region.0.dart', + 'examples/api/lib/widgets/restoration/restoration_mixin.0.dart', + 'examples/api/lib/widgets/app/widgets_app.widgets_app.0.dart', + 'examples/api/lib/widgets/drag_target/draggable.0.dart', + 'examples/api/lib/widgets/autocomplete/raw_autocomplete.focus_node.0.dart', + 'examples/api/lib/widgets/autocomplete/raw_autocomplete.2.dart', + 'examples/api/lib/widgets/autocomplete/raw_autocomplete.1.dart', + 'examples/api/lib/widgets/autocomplete/raw_autocomplete.0.dart', + 'examples/api/lib/widgets/keep_alive/automatic_keep_alive_client_mixin.0.dart', + 'examples/api/lib/widgets/keep_alive/automatic_keep_alive.0.dart', + 'examples/api/lib/widgets/keep_alive/keep_alive.0.dart', + 'examples/api/lib/widgets/safe_area/safe_area.0.dart', + 'examples/api/lib/widgets/scroll_notification_observer/scroll_notification_observer.0.dart', + 'examples/api/lib/widgets/animated_size/animated_size.0.dart', + 'examples/api/lib/widgets/framework/error_widget.0.dart', + 'examples/api/lib/widgets/framework/build_owner.0.dart', + 'examples/api/lib/widgets/sliver/pinned_header_sliver.1.dart', + 'examples/api/lib/widgets/sliver/sliver_list.0.dart', + 'examples/api/lib/widgets/sliver/pinned_header_sliver.0.dart', + 'examples/api/lib/widgets/sliver/sliver_constrained_cross_axis.0.dart', + 'examples/api/lib/widgets/sliver/sliver_tree.1.dart', + 'examples/api/lib/widgets/sliver/sliver_tree.0.dart', + 'examples/api/lib/widgets/sliver/sliver_floating_header.0.dart', + 'examples/api/lib/widgets/sliver/decorated_sliver.1.dart', + 'examples/api/lib/widgets/sliver/sliver_opacity.1.dart', + 'examples/api/lib/widgets/sliver/decorated_sliver.0.dart', + 'examples/api/lib/widgets/sliver/sliver_ensure_semantics.0.dart', + 'examples/api/lib/widgets/sliver/sliver_resizing_header.0.dart', + 'examples/api/lib/widgets/sliver/sliver_cross_axis_group.0.dart', + 'examples/api/lib/widgets/sliver/sliver_main_axis_group.0.dart', + 'examples/api/lib/widgets/heroes/hero.0.dart', + 'examples/api/lib/widgets/heroes/hero.1.dart', + 'examples/api/lib/widgets/dismissible/dismissible.0.dart', + 'examples/api/lib/widgets/overflow_bar/overflow_bar.0.dart', + 'examples/api/lib/widgets/preferred_size/preferred_size.0.dart', + 'examples/api/lib/widgets/media_query/media_query_data.system_gesture_insets.0.dart', + 'examples/api/lib/widgets/focus_traversal/ordered_traversal_policy.0.dart', + 'examples/api/lib/widgets/focus_traversal/focus_traversal_group.0.dart', + 'examples/api/lib/widgets/value_listenable_builder/value_listenable_builder.0.dart', + 'examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.1.dart', + 'examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.0.dart', + 'examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.3.dart', + 'examples/api/lib/widgets/raw_menu_anchor/raw_menu_anchor.2.dart', + 'examples/api/lib/widgets/async/stream_builder.0.dart', + 'examples/api/lib/widgets/async/future_builder.0.dart', + 'examples/api/lib/widgets/shared_app_data/shared_app_data.1.dart', + 'examples/api/lib/widgets/shared_app_data/shared_app_data.0.dart', + 'examples/api/lib/widgets/animated_list/animated_list_separated.0.dart', + 'examples/api/lib/widgets/animated_list/sliver_animated_list.0.dart', + 'examples/api/lib/widgets/animated_list/animated_list.0.dart', + 'examples/api/lib/widgets/basic/fractionally_sized_box.0.dart', + 'examples/api/lib/widgets/basic/physical_shape.0.dart', + 'examples/api/lib/widgets/basic/aspect_ratio.2.dart', + 'examples/api/lib/widgets/basic/flow.0.dart', + 'examples/api/lib/widgets/basic/aspect_ratio.0.dart', + 'examples/api/lib/widgets/basic/clip_rrect.0.dart', + 'examples/api/lib/widgets/basic/ignore_pointer.0.dart', + 'examples/api/lib/widgets/basic/fitted_box.0.dart', + 'examples/api/lib/widgets/basic/custom_multi_child_layout.0.dart', + 'examples/api/lib/widgets/basic/listener.0.dart', + 'examples/api/lib/widgets/basic/clip_rrect.1.dart', + 'examples/api/lib/widgets/basic/offstage.0.dart', + 'examples/api/lib/widgets/basic/aspect_ratio.1.dart', + 'examples/api/lib/widgets/basic/overflowbox.0.dart', + 'examples/api/lib/widgets/basic/indexed_stack.0.dart', + 'examples/api/lib/widgets/basic/mouse_region.on_exit.1.dart', + 'examples/api/lib/widgets/basic/expanded.1.dart', + 'examples/api/lib/widgets/basic/mouse_region.0.dart', + 'examples/api/lib/widgets/basic/expanded.0.dart', + 'examples/api/lib/widgets/basic/mouse_region.on_exit.0.dart', + 'examples/api/lib/widgets/basic/absorb_pointer.0.dart', + 'examples/api/lib/widgets/single_child_scroll_view/single_child_scroll_view.1.dart', + 'examples/api/lib/widgets/single_child_scroll_view/single_child_scroll_view.0.dart', + 'examples/api/lib/widgets/scroll_end_notification/scroll_end_notification.1.dart', + 'examples/api/lib/widgets/scroll_end_notification/scroll_end_notification.0.dart', + 'examples/api/lib/widgets/autofill/autofill_group.0.dart', + 'examples/api/lib/widgets/scroll_view/list_view.1.dart', + 'examples/api/lib/widgets/scroll_view/list_view.0.dart', + 'examples/api/lib/widgets/scroll_view/grid_view.0.dart', + 'examples/api/lib/widgets/scroll_view/custom_scroll_view.1.dart', + 'examples/api/lib/widgets/binding/widget_binding_observer.0.dart', + 'examples/api/lib/widgets/image/image.loading_builder.0.dart', + 'examples/api/lib/widgets/image/image.error_builder.0.dart', + 'examples/api/lib/widgets/image/image.frame_builder.0.dart', + 'examples/api/lib/widgets/app_lifecycle_listener/app_lifecycle_listener.0.dart', + 'examples/api/lib/widgets/app_lifecycle_listener/app_lifecycle_listener.1.dart', + 'examples/api/lib/widgets/slotted_render_object_widget/slotted_multi_child_render_object_widget_mixin.0.dart', + 'examples/api/lib/widgets/color_filter/color_filtered.0.dart', + 'examples/api/lib/widgets/implicit_animations/animated_padding.0.dart', + 'examples/api/lib/widgets/implicit_animations/animated_positioned.0.dart', + 'examples/api/lib/widgets/implicit_animations/animated_align.0.dart', + 'examples/api/lib/widgets/implicit_animations/animated_slide.0.dart', + 'examples/api/lib/widgets/implicit_animations/animated_container.0.dart', + 'examples/api/lib/widgets/implicit_animations/sliver_animated_opacity.0.dart', + 'examples/api/lib/widgets/implicit_animations/animated_fractionally_sized_box.0.dart', + 'examples/api/lib/widgets/radio_group/radio_group.0.dart', + 'examples/api/lib/widgets/inherited_theme/inherited_theme.0.dart', + 'examples/api/lib/widgets/scroll_position/scroll_metrics_notification.0.dart', + 'examples/api/lib/widgets/scroll_position/scroll_controller_on_attach.0.dart', + 'examples/api/lib/widgets/scroll_position/is_scrolling_listener.0.dart', + 'examples/api/lib/widgets/scroll_position/scroll_controller_notification.0.dart', + 'examples/api/lib/widgets/page_transitions_builder/page_transitions_builder.0.dart', + 'examples/api/lib/widgets/page_storage/page_storage.0.dart', + 'examples/api/lib/widgets/table/table.0.dart', + 'examples/api/lib/widgets/notification_listener/notification.0.dart', + 'examples/api/lib/widgets/inherited_model/inherited_model.0.dart', + 'examples/api/lib/widgets/focus_scope/focus.2.dart', + 'examples/api/lib/widgets/focus_scope/focus_scope.0.dart', + 'examples/api/lib/widgets/focus_scope/focus.1.dart', + 'examples/api/lib/widgets/focus_scope/focus.0.dart', + 'examples/api/lib/widgets/pop_scope/pop_scope.1.dart', + 'examples/api/lib/widgets/pop_scope/pop_scope.0.dart', + 'examples/api/lib/widgets/animated_switcher/animated_switcher.0.dart', + 'examples/api/lib/widgets/shortcuts/shortcuts.0.dart', + 'examples/api/lib/widgets/shortcuts/character_activator.0.dart', + 'examples/api/lib/widgets/shortcuts/shortcuts.1.dart', + 'examples/api/lib/widgets/shortcuts/callback_shortcuts.0.dart', + 'examples/api/lib/widgets/shortcuts/single_activator.0.dart', + 'examples/api/lib/widgets/shortcuts/logical_key_set.0.dart', + 'examples/api/lib/widgets/actions/action_listener.0.dart', + 'examples/api/lib/widgets/actions/action.action_overridable.0.dart', + 'examples/api/lib/widgets/actions/focusable_action_detector.0.dart', + 'examples/api/lib/widgets/actions/actions.0.dart', + 'examples/api/lib/widgets/widget_state/widget_state_property.0.dart', + 'examples/api/lib/widgets/widget_state/widget_state_border_side.0.dart', + 'examples/api/lib/widgets/widget_state/widget_state_outlined_border.0.dart', + 'examples/api/lib/widgets/widget_state/widget_state_mouse_cursor.0.dart', + 'examples/api/lib/widgets/magnifier/magnifier.0.dart', + 'examples/api/lib/widgets/navigator/restorable_route_future.0.dart', + 'examples/api/lib/widgets/navigator/navigator.restorable_push_and_remove_until.0.dart', + 'examples/api/lib/widgets/navigator/navigator_state.restorable_push.0.dart', + 'examples/api/lib/widgets/navigator/navigator.restorable_push_replacement.0.dart', + 'examples/api/lib/widgets/navigator/navigator_state.restorable_push_and_remove_until.0.dart', + 'examples/api/lib/widgets/navigator/navigator_state.restorable_push_replacement.0.dart', + 'examples/api/lib/widgets/navigator/navigator.0.dart', + 'examples/api/lib/widgets/navigator/navigator.restorable_push.0.dart', + 'examples/api/lib/widgets/system_context_menu/system_context_menu.0.dart', + 'examples/api/lib/widgets/system_context_menu/system_context_menu.1.dart', + 'examples/api/lib/widgets/tween_animation_builder/tween_animation_builder.0.dart', + 'examples/api/lib/widgets/page_view/page_view.0.dart', + 'examples/api/lib/widgets/page_view/page_view.1.dart', + 'examples/api/lib/widgets/hardware_keyboard/key_event_manager.0.dart', + 'examples/api/lib/widgets/sliver_fill/sliver_fill_remaining.2.dart', + 'examples/api/lib/widgets/sliver_fill/sliver_fill_remaining.3.dart', + 'examples/api/lib/widgets/sliver_fill/sliver_fill_remaining.0.dart', + 'examples/api/lib/widgets/sliver_fill/sliver_fill_remaining.1.dart', + 'examples/api/lib/widgets/interactive_viewer/interactive_viewer.builder.0.dart', + 'examples/api/lib/widgets/interactive_viewer/interactive_viewer.0.dart', + 'examples/api/lib/widgets/interactive_viewer/interactive_viewer.constrained.0.dart', + 'examples/api/lib/widgets/interactive_viewer/interactive_viewer.transformation_controller.0.dart', + 'examples/api/lib/widgets/text/ui_testing_with_text.dart', + 'examples/api/lib/widgets/text/text.0.dart', + 'examples/api/lib/widgets/nested_scroll_view/nested_scroll_view.1.dart', + 'examples/api/lib/widgets/nested_scroll_view/nested_scroll_view.0.dart', + 'examples/api/lib/widgets/nested_scroll_view/nested_scroll_view.2.dart', + 'examples/api/lib/widgets/nested_scroll_view/nested_scroll_view_state.0.dart', + 'examples/api/lib/widgets/overscroll_indicator/glowing_overscroll_indicator.1.dart', + 'examples/api/lib/widgets/overscroll_indicator/glowing_overscroll_indicator.0.dart', + 'examples/api/lib/widgets/transitions/positioned_transition.0.dart', + 'examples/api/lib/widgets/transitions/listenable_builder.3.dart', + 'examples/api/lib/widgets/transitions/matrix_transition.0.dart', + 'examples/api/lib/widgets/transitions/listenable_builder.2.dart', + 'examples/api/lib/widgets/transitions/size_transition.0.dart', + 'examples/api/lib/widgets/transitions/relative_positioned_transition.0.dart', + 'examples/api/lib/widgets/transitions/animated_builder.0.dart', + 'examples/api/lib/widgets/transitions/decorated_box_transition.0.dart', + 'examples/api/lib/widgets/transitions/rotation_transition.0.dart', + 'examples/api/lib/widgets/transitions/fade_transition.0.dart', + 'examples/api/lib/widgets/transitions/animated_widget.0.dart', + 'examples/api/lib/widgets/transitions/align_transition.0.dart', + 'examples/api/lib/widgets/transitions/listenable_builder.1.dart', + 'examples/api/lib/widgets/transitions/listenable_builder.0.dart', + 'examples/api/lib/widgets/transitions/default_text_style_transition.0.dart', + 'examples/api/lib/widgets/transitions/slide_transition.0.dart', + 'examples/api/lib/widgets/transitions/scale_transition.0.dart', + 'examples/api/lib/widgets/transitions/sliver_fade_transition.0.dart', + 'examples/api/lib/widgets/windows/popup.0.dart', + 'examples/api/lib/widgets/windows/tooltip.0.dart', + 'examples/api/lib/widgets/windows/satellite.0.dart', + 'examples/api/lib/widgets/text_editing_intents/editable_text_tap_up_outside_intent.0.dart', + 'examples/api/lib/widgets/overlay/overlay_portal.0.dart', + 'examples/api/lib/widgets/overlay/overlay.0.dart', + 'examples/api/lib/widgets/draggable_scrollable_sheet/draggable_scrollable_sheet.0.dart', + 'examples/api/lib/widgets/focus_manager/focus_node.unfocus.0.dart', + 'examples/api/lib/widgets/focus_manager/focus_node.0.dart', + 'examples/api/lib/widgets/restoration_properties/restorable_value.0.dart', + 'examples/api/lib/widgets/gesture_detector/gesture_detector.3.dart', + 'examples/api/lib/widgets/gesture_detector/gesture_detector.2.dart', + 'examples/api/lib/widgets/gesture_detector/gesture_detector.1.dart', + 'examples/api/lib/widgets/gesture_detector/gesture_detector.0.dart', + 'examples/api/lib/widgets/routes/show_general_dialog.0.dart', + 'examples/api/lib/widgets/routes/local_history_entry.0.dart', + 'examples/api/lib/widgets/routes/route_observer.0.dart', + 'examples/api/lib/widgets/routes/flexible_route_transitions.1.dart', + 'examples/api/lib/widgets/routes/flexible_route_transitions.0.dart', + 'examples/api/lib/widgets/routes/popup_route.0.dart', + 'examples/api/lib/widgets/repeating_animation_builder/repeating_animation_builder.0.dart', + 'examples/api/lib/widgets/scrollbar/raw_scrollbar.1.dart', + 'examples/api/lib/widgets/scrollbar/raw_scrollbar.shape.0.dart', + 'examples/api/lib/widgets/scrollbar/raw_scrollbar.0.dart', + 'examples/api/lib/widgets/scrollbar/raw_scrollbar.2.dart', + 'examples/api/lib/widgets/scrollbar/raw_scrollbar.desktop.0.dart', + 'examples/api/lib/widgets/inherited_notifier/inherited_notifier.0.dart', + 'examples/api/lib/widgets/text_magnifier/text_magnifier.0.dart', + 'examples/api/test/widgets/animated_grid/animated_grid.0_test.dart', + 'examples/api/test/widgets/animated_grid/sliver_animated_grid.0_test.dart', + 'examples/api/test/widgets/navigator_pop_handler/navigator_pop_handler.1_test.dart', + 'examples/api/test/widgets/navigator_pop_handler/navigator_pop_handler.0_test.dart', + 'examples/api/test/widgets/editable_text/editable_text.on_content_inserted.0_test.dart', + 'examples/api/test/widgets/editable_text/text_editing_controller.1_test.dart', + 'examples/api/test/widgets/editable_text/editable_text.on_changed.0_test.dart', + 'examples/api/test/widgets/editable_text/text_editing_controller.0_test.dart', + 'examples/api/test/widgets/undo_history/undo_history_controller.0_test.dart', + 'examples/api/test/widgets/raw_tooltip/raw_tooltip.0_test.dart', + 'examples/api/test/widgets/form/form.0_test.dart', + 'examples/api/test/widgets/form/form.1_test.dart', + 'examples/api/test/widgets/tap_region/text_field_tap_region.0_test.dart', + 'examples/api/test/widgets/restoration/restoration_mixin.0_test.dart', + 'examples/api/test/widgets/drag_target/draggable.0_test.dart', + 'examples/api/test/widgets/autocomplete/raw_autocomplete.1_test.dart', + 'examples/api/test/widgets/autocomplete/raw_autocomplete.focus_node.0_test.dart', + 'examples/api/test/widgets/autocomplete/raw_autocomplete.0_test.dart', + 'examples/api/test/widgets/autocomplete/raw_autocomplete.2_test.dart', + 'examples/api/test/widgets/keep_alive/automatic_keep_alive.0_test.dart', + 'examples/api/test/widgets/keep_alive/keep_alive.0_test.dart', + 'examples/api/test/widgets/keep_alive/automatic_keep_alive_client_mixin.0_test.dart', + 'examples/api/test/widgets/safe_area/safe_area.0_test.dart', + 'examples/api/test/widgets/scroll_notification_observer/scroll_notification_observer.0_test.dart', + 'examples/api/test/widgets/animated_size/animated_size.0_test.dart', + 'examples/api/test/widgets/framework/build_owner.0_test.dart', + 'examples/api/test/widgets/framework/error_widget.0_test.dart', + 'examples/api/test/widgets/sliver/pinned_header_sliver.1_test.dart', + 'examples/api/test/widgets/sliver/sliver_floating_header.0_test.dart', + 'examples/api/test/widgets/sliver/pinned_header_sliver.0_test.dart', + 'examples/api/test/widgets/sliver/sliver_opacity.1_test.dart', + 'examples/api/test/widgets/sliver/decorated_sliver.0_test.dart', + 'examples/api/test/widgets/sliver/sliver_cross_axis_group.0_test.dart', + 'examples/api/test/widgets/sliver/decorated_sliver.1_test.dart', + 'examples/api/test/widgets/sliver/sliver_main_axis_group.0_test.dart', + 'examples/api/test/widgets/sliver/sliver_constrained_cross_axis.0_test.dart', + 'examples/api/test/widgets/sliver/sliver_resizing_header.0_test.dart', + 'examples/api/test/widgets/heroes/hero.0_test.dart', + 'examples/api/test/widgets/heroes/hero.1_test.dart', + 'examples/api/test/widgets/dismissible/dismissible.0_test.dart', + 'examples/api/test/widgets/overflow_bar/overflow_bar.0_test.dart', + 'examples/api/test/widgets/preferred_size/preferred_size.0_test.dart', + 'examples/api/test/widgets/media_query/media_query_data.system_gesture_insets.0_test.dart', + 'examples/api/test/widgets/focus_traversal/focus_traversal_group.0_test.dart', + 'examples/api/test/widgets/focus_traversal/ordered_traversal_policy.0_test.dart', + 'examples/api/test/widgets/value_listenable_builder/value_listenable_builder.0_test.dart', + 'examples/api/test/widgets/raw_menu_anchor/raw_menu_anchor.3_test.dart', + 'examples/api/test/widgets/raw_menu_anchor/raw_menu_anchor.2_test.dart', + 'examples/api/test/widgets/raw_menu_anchor/raw_menu_anchor.0_test.dart', + 'examples/api/test/widgets/async/stream_builder.0_test.dart', + 'examples/api/test/widgets/async/future_builder.0_test.dart', + 'examples/api/test/widgets/shared_app_data/shared_app_data.0_test.dart', + 'examples/api/test/widgets/shared_app_data/shared_app_data.1_test.dart', + 'examples/api/test/widgets/animated_list/animated_list_separated.0_test.dart', + 'examples/api/test/widgets/animated_list/sliver_animated_list.0_test.dart', + 'examples/api/test/widgets/animated_list/animated_list.0_test.dart', + 'examples/api/test/widgets/basic/physical_shape.0_test.dart', + 'examples/api/test/widgets/basic/aspect_ratio.2_test.dart', + 'examples/api/test/widgets/basic/indexed_stack.0_test.dart', + 'examples/api/test/widgets/basic/clip_rrect.1_test.dart', + 'examples/api/test/widgets/basic/absorb_pointer.0_test.dart', + 'examples/api/test/widgets/basic/listener.0_test.dart', + 'examples/api/test/widgets/basic/clip_rrect.0_test.dart', + 'examples/api/test/widgets/basic/mouse_region.0_test.dart', + 'examples/api/test/widgets/basic/expanded.0_test.dart', + 'examples/api/test/widgets/basic/fitted_box.0_test.dart', + 'examples/api/test/widgets/basic/mouse_region.on_exit.0_test.dart', + 'examples/api/test/widgets/basic/aspect_ratio.0_test.dart', + 'examples/api/test/widgets/basic/fractionally_sized_box.0_test.dart', + 'examples/api/test/widgets/basic/expanded.1_test.dart', + 'examples/api/test/widgets/basic/mouse_region.on_exit.1_test.dart', + 'examples/api/test/widgets/basic/custom_multi_child_layout.0_test.dart', + 'examples/api/test/widgets/basic/aspect_ratio.1_test.dart', + 'examples/api/test/widgets/basic/flow.0_test.dart', + 'examples/api/test/widgets/basic/overflowbox.0_test.dart', + 'examples/api/test/widgets/scroll_end_notification/scroll_end_notification.0_test.dart', + 'examples/api/test/widgets/scroll_end_notification/scroll_end_notification.1_test.dart', + 'examples/api/test/widgets/autofill/autofill_group.0_test.dart', + 'examples/api/test/widgets/scroll_view/list_view.1_test.dart', + 'examples/api/test/widgets/scroll_view/custom_scroll_view.1_test.dart', + 'examples/api/test/widgets/scroll_view/list_view.0_test.dart', + 'examples/api/test/widgets/image/image.loading_builder.0_test.dart', + 'examples/api/test/widgets/image/image.error_builder.0_test.dart', + 'examples/api/test/widgets/image/image.frame_builder.0_test.dart', + 'examples/api/test/widgets/color_filter/color_filtered.0_test.dart', + 'examples/api/test/widgets/implicit_animations/animated_positioned.0_test.dart', + 'examples/api/test/widgets/implicit_animations/sliver_animated_opacity.0_test.dart', + 'examples/api/test/widgets/implicit_animations/animated_slide.0_test.dart', + 'examples/api/test/widgets/implicit_animations/animated_padding.0_test.dart', + 'examples/api/test/widgets/implicit_animations/animated_fractionally_sized_box.0_test.dart', + 'examples/api/test/widgets/implicit_animations/animated_align.0_test.dart', + 'examples/api/test/widgets/implicit_animations/animated_container.0_test.dart', + 'examples/api/test/widgets/radio_group/radio_group.0_test.dart', + 'examples/api/test/widgets/inherited_theme/inherited_theme.0_test.dart', + 'examples/api/test/widgets/scroll_position/scroll_metrics_notification.0_test.dart', + 'examples/api/test/widgets/scroll_position/scroll_controller_on_attach.0_test.dart', + 'examples/api/test/widgets/scroll_position/is_scrolling_listener.0_test.dart', + 'examples/api/test/widgets/scroll_position/scroll_controller_notification.0_test.dart', + 'examples/api/test/widgets/page_transitions_builder/page_transitions_builder.0_test.dart', + 'examples/api/test/widgets/page_storage/page_storage.0_test.dart', + 'examples/api/test/widgets/table/table.0_test.dart', + 'examples/api/test/widgets/notification_listener/notification.0_test.dart', + 'examples/api/test/widgets/inherited_model/inherited_model.0_test.dart', + 'examples/api/test/widgets/focus_scope/focus.0_test.dart', + 'examples/api/test/widgets/focus_scope/focus.1_test.dart', + 'examples/api/test/widgets/focus_scope/focus.2_test.dart', + 'examples/api/test/widgets/focus_scope/focus_scope.0_test.dart', + 'examples/api/test/widgets/pop_scope/pop_scope.1_test.dart', + 'examples/api/test/widgets/animated_switcher/animated_switcher.0_test.dart', + 'examples/api/test/widgets/shortcuts/character_activator.0_test.dart', + 'examples/api/test/widgets/actions/action_listener.0_test.dart', + 'examples/api/test/widgets/actions/focusable_action_detector.0_test.dart', + 'examples/api/test/widgets/actions/actions.0_test.dart', + 'examples/api/test/widgets/actions/action.action_overridable.0_test.dart', + 'examples/api/test/widgets/widget_state/widget_state_property.0_test.dart', + 'examples/api/test/widgets/widget_state/widget_state_border_side.0_test.dart', + 'examples/api/test/widgets/widget_state/widget_state_outlined_border.0_test.dart', + 'examples/api/test/widgets/widget_state/widget_state_mouse_cursor.0_test.dart', + 'examples/api/test/widgets/magnifier/magnifier.0_test.dart', + 'examples/api/test/widgets/navigator/navigator.restorable_push.0_test.dart', + 'examples/api/test/widgets/navigator/navigator_state.restorable_push_and_remove_until.0_test.dart', + 'examples/api/test/widgets/navigator/navigator_state.restorable_push.0_test.dart', + 'examples/api/test/widgets/navigator/navigator.restorable_push_and_remove_until.0_test.dart', + 'examples/api/test/widgets/navigator/restorable_route_future.0_test.dart', + 'examples/api/test/widgets/navigator/navigator_state.restorable_push_replacement.0_test.dart', + 'examples/api/test/widgets/navigator/navigator.restorable_push_replacement.0_test.dart', + 'examples/api/test/widgets/system_context_menu/system_context_menu.1_test.dart', + 'examples/api/test/widgets/system_context_menu/system_context_menu.0_test.dart', + 'examples/api/test/widgets/tween_animation_builder/tween_animation_builder.0_test.dart', + 'examples/api/test/widgets/page_view/page_view.0_test.dart', + 'examples/api/test/widgets/page_view/page_view.1_test.dart', + 'examples/api/test/widgets/hardware_keyboard/key_event_manager.0_test.dart', + 'examples/api/test/widgets/sliver_fill/sliver_fill_remaining.1_test.dart', + 'examples/api/test/widgets/sliver_fill/sliver_fill_remaining.0_test.dart', + 'examples/api/test/widgets/sliver_fill/sliver_fill_remaining.3_test.dart', + 'examples/api/test/widgets/sliver_fill/sliver_fill_remaining.2_test.dart', + 'examples/api/test/widgets/interactive_viewer/interactive_viewer.transformation_controller.0_test.dart', + 'examples/api/test/widgets/interactive_viewer/interactive_viewer.0_test.dart', + 'examples/api/test/widgets/interactive_viewer/interactive_viewer.constrained.0_test.dart', + 'examples/api/test/widgets/text/text.0_test.dart', + 'examples/api/test/widgets/nested_scroll_view/nested_scroll_view.2_test.dart', + 'examples/api/test/widgets/nested_scroll_view/nested_scroll_view_state.0_test.dart', + 'examples/api/test/widgets/nested_scroll_view/nested_scroll_view.0_test.dart', + 'examples/api/test/widgets/nested_scroll_view/nested_scroll_view.1_test.dart', + 'examples/api/test/widgets/overscroll_indicator/glowing_overscroll_indicator.0_test.dart', + 'examples/api/test/widgets/overscroll_indicator/glowing_overscroll_indicator.1_test.dart', + 'examples/api/test/widgets/transitions/listenable_builder.3_test.dart', + 'examples/api/test/widgets/transitions/sliver_fade_transition.0_test.dart', + 'examples/api/test/widgets/transitions/matrix_transition.0_test.dart', + 'examples/api/test/widgets/transitions/default_text_style_transition.0_test.dart', + 'examples/api/test/widgets/transitions/listenable_builder.2_test.dart', + 'examples/api/test/widgets/transitions/align_transition.0_test.dart', + 'examples/api/test/widgets/transitions/size_transition.0_test.dart', + 'examples/api/test/widgets/transitions/fade_transition.0_test.dart', + 'examples/api/test/widgets/transitions/listenable_builder.1_test.dart', + 'examples/api/test/widgets/transitions/relative_positioned_transition.0_test.dart', + 'examples/api/test/widgets/transitions/slide_transition.0_test.dart', + 'examples/api/test/widgets/transitions/positioned_transition.0_test.dart', + 'examples/api/test/widgets/transitions/decorated_box_transition.0_test.dart', + 'examples/api/test/widgets/transitions/animated_builder.0_test.dart', + 'examples/api/test/widgets/transitions/listenable_builder.0_test.dart', + 'examples/api/test/widgets/transitions/scale_transition.0_test.dart', + 'examples/api/test/widgets/transitions/animated_widget.0_test.dart', + 'examples/api/test/widgets/transitions/rotation_transition.0_test.dart', + 'examples/api/test/widgets/text_editing_intents/editable_text_tap_up_outside_intent.0_test.dart', + 'examples/api/test/widgets/overlay/overlay.0_test.dart', + 'examples/api/test/widgets/draggable_scrollable_sheet/draggable_scrollable_sheet.0_test.dart', + 'examples/api/test/widgets/focus_manager/focus_node.unfocus.0_test.dart', + 'examples/api/test/widgets/focus_manager/focus_node.0_test.dart', + 'examples/api/test/widgets/restoration_properties/restorable_value.0_test.dart', + 'examples/api/test/widgets/gesture_detector/gesture_detector.3_test.dart', + 'examples/api/test/widgets/gesture_detector/gesture_detector.2_test.dart', + 'examples/api/test/widgets/gesture_detector/gesture_detector.1_test.dart', + 'examples/api/test/widgets/gesture_detector/gesture_detector.0_test.dart', + 'examples/api/test/widgets/routes/popup_route.0_test.dart', + 'examples/api/test/widgets/routes/show_general_dialog.0_test.dart', + 'examples/api/test/widgets/repeating_animation_builder/repeating_animation_builder.0_test.dart', + 'examples/api/test/widgets/scrollbar/raw_scrollbar.2_test.dart', + 'examples/api/test/widgets/scrollbar/raw_scrollbar.desktop.0_test.dart', + 'examples/api/test/widgets/scrollbar/raw_scrollbar.0_test.dart', + 'examples/api/test/widgets/scrollbar/raw_scrollbar.shape.0_test.dart', + 'examples/api/test/widgets/scrollbar/raw_scrollbar.1_test.dart', + 'examples/api/test/widgets/inherited_notifier/inherited_notifier.0_test.dart', + 'examples/api/test/widgets/text_magnifier/text_magnifier.0_test.dart', + 'examples/flutter_view/lib/main.dart', + 'examples/image_list/lib/main.dart', + 'examples/multiple_windows/lib/app/main_window.dart', + 'examples/multiple_windows/lib/app/tooltip_button.dart', + 'examples/multiple_windows/lib/app/tooltip_window_edit_dialog.dart', + 'examples/multiple_windows/lib/app/dialog_window_content.dart', + 'examples/multiple_windows/lib/app/dialog_window_edit_dialog.dart', + 'examples/multiple_windows/lib/app/popup_window_content.dart', + 'examples/multiple_windows/lib/app/window_content.dart', + 'examples/multiple_windows/lib/app/window_edit_dialog.dart', + 'examples/multiple_windows/lib/app/rotated_wire_cube.dart', + 'examples/multiple_windows/lib/app/popup_window_edit_dialog.dart', + 'examples/multiple_windows/lib/app/tooltip_window_content.dart', + 'examples/multiple_windows/lib/app/popup_button.dart', + 'examples/multiple_windows/lib/app/window_settings_dialog.dart', + 'examples/multiple_windows/lib/main.dart', + 'examples/multiple_windows/test/multiple_windows_test.dart', + 'examples/platform_view/lib/main.dart', + 'examples/splash/lib/main.dart', + 'examples/splash/test/splash_test.dart', + 'examples/texture/lib/main.dart', + }; + + static final RegExp _examplesPrefix = RegExp(r'examples'); + + /// Find the `examples/api/lib` and `examples/api/test` directories + /// which contain the API examples and relevant tests. + /// + /// For the cross imports checker, only the `examples/api/lib` and `examples/api/test` directories are relevant. + /// The other directories in `examples/api` are either generated (e.g. build or .dart_tool), + /// platform directories for the samples (e.g. windows or linux), + /// or a shim for the integration test driver. + ({Directory libDirectory, Directory testDirectory}) _findExamplesSlashApiDirectories( + Directory examplesSlashApiDirectory, + ) { + Directory? examplesSlashApiLibDirectory; + Directory? examplesSlashApiTestDirectory; + + for (final Directory directory in examplesSlashApiDirectory.listSync().whereType()) { + final String directoryName = path.basename(directory.absolute.path); + + if (directoryName == 'lib' && examplesSlashApiLibDirectory == null) { + examplesSlashApiLibDirectory = directory; + } else if (directoryName == 'test' && examplesSlashApiTestDirectory == null) { + examplesSlashApiTestDirectory = directory; + } + } + + if (examplesSlashApiLibDirectory == null) { + throw StateError('Could not find lib directory in examples/api.'); + } + + if (examplesSlashApiTestDirectory == null) { + throw StateError('Could not find test directory in examples/api.'); + } + + return ( + libDirectory: examplesSlashApiLibDirectory, + testDirectory: examplesSlashApiTestDirectory, + ); + } + + /// Get a list of all the filenames that end in ".dart", grouped by library. + Map<_ExamplesLibrary, Set> _getExampleFiles() { + final dartFilePattern = RegExp(r'\.dart$'); + + const _ExamplesLibrary examplesRoot = _GenericExampleLibrary('examples'); + final Map<_ExamplesLibrary, Set> mapping = {examplesRoot: {}}; + + // List the files directly under `examples` and then walk the subdirectories. + for (final FileSystemEntity fileSystemEntity in examplesDirectory.listSync()) { + if (fileSystemEntity is File && fileSystemEntity.absolute.path.contains(dartFilePattern)) { + mapping[examplesRoot]?.add(fileSystemEntity); + + continue; + } + + if (fileSystemEntity is! Directory) { + continue; + } + + final String directoryName = path.basename(fileSystemEntity.absolute.path); + + if (directoryName == 'build' || directoryName == '.dart_tool') { + continue; + } + + // The examples/api folder contains examples in a single Flutter project, + // grouped in subfolders in lib/ and test/, so these need to be handled separately. + if (directoryName == 'api') { + final examplesSlashApiLibrary = _ExamplesLibrary.fromDirectory( + fileSystemEntity, + flutterRoot: flutterRoot, + ); + + // First list the files directly under examples/api. + mapping[examplesSlashApiLibrary] = { + for (final File file in fileSystemEntity.listSync().whereType()) + if (file.absolute.path.contains(dartFilePattern)) file, + }; + + final (:Directory libDirectory, :Directory testDirectory) = + _findExamplesSlashApiDirectories(fileSystemEntity); + + // Handle the files under examples/api/lib/sample_templates and examples/api/test/sample_templates, + // which list individual files with a specific file pattern. + mapping.addAll( + _getExamplesSlashApiSampleTemplatesFiles( + libDirectory: libDirectory, + testDirectory: testDirectory, + dartFilePattern: dartFilePattern, + ), + ); + + // Handle the other samples, which are divided per subfolder. + mapping.addAll( + _getExamplesSlashApiExamples( + libDirectory: libDirectory, + testDirectory: testDirectory, + dartFilePattern: dartFilePattern, + ), + ); + + continue; + } + + final library = _ExamplesLibrary.fromDirectory(fileSystemEntity, flutterRoot: flutterRoot); + + mapping[library] = _getExampleFilesForDirectory( + fileSystemEntity, + dartFilePattern: dartFilePattern, + ); + } + + return mapping; + } + + /// Get a list of all the filenames that end in ".dart" for the given examples directory. + /// + /// The [directory] must not be a subdirectory of `examples/api`. + Set _getExampleFilesForDirectory(Directory directory, {required Pattern dartFilePattern}) { + final String examplesSlashApiPath = path.join(flutterRoot.absolute.path, 'examples', 'api'); + + if (directory.absolute.path.startsWith(examplesSlashApiPath)) { + throw ArgumentError('Directory must not be an examples/api subdirectory.', 'directory'); + } + + final files = {}; + final queue = [directory]; + + while (queue.isNotEmpty) { + final Directory current = queue.removeAt(0); + + for (final FileSystemEntity fileSystemEntity in current.listSync()) { + if (fileSystemEntity is File && fileSystemEntity.absolute.path.contains(dartFilePattern)) { + files.add(fileSystemEntity); + + continue; + } + + if (fileSystemEntity is! Directory) { + continue; + } + + final String directoryName = path.basename(fileSystemEntity.absolute.path); + + if (directoryName == 'build' || directoryName == '.dart_tool') { + continue; + } + + queue.add(fileSystemEntity); + } + } + + return files; + } + + /// Get a list of all the filenames that end in ".dart", grouped by library, + /// for the subdrectories of `examples/api/lib/sample_templates` and `examples/api/test/sample_templates`. + Map<_SampleTemplatesLibraryFile, Set> _getExamplesSlashApiSampleTemplatesFiles({ + required Directory libDirectory, + required Directory testDirectory, + required Pattern dartFilePattern, + }) { + final Directory sampleTemplatesLibDirectory = libDirectory.childDirectory( + _kSampleTemplatesDirectoryName, + ); + final Directory sampleTemplatesTestDirectory = testDirectory.childDirectory( + _kSampleTemplatesDirectoryName, + ); + + final Map<_SampleTemplatesLibraryFile, Set> mapping = {}; + + for (final File file + in sampleTemplatesLibDirectory.listSync(recursive: true).whereType()) { + if (file.absolute.path.contains(dartFilePattern)) { + mapping[_SampleTemplatesLibraryFile.fromFile(file)] = {file}; + } + } + + for (final File file + in sampleTemplatesTestDirectory.listSync(recursive: true).whereType()) { + if (file.absolute.path.contains(dartFilePattern)) { + mapping[_SampleTemplatesLibraryFile.fromFile(file)] = {file}; + } + } + + return mapping; + } + + /// Get a list of all the filenames that end in ".dart", grouped by library, + /// for the subdirectories of `examples/api`, + /// except `examples/api/lib/sample_templates` and `examples/api/test/sample_templates`. + Map<_ExamplesLibrary, Set> _getExamplesSlashApiExamples({ + required Directory libDirectory, + required Directory testDirectory, + required Pattern dartFilePattern, + }) { + final Map<_ExamplesLibrary, Set> mapping = {}; + + for (final Directory directory in libDirectory.listSync().whereType()) { + // The sample templates directory is handled separately. + if (path.basename(directory.absolute.path) == _kSampleTemplatesDirectoryName) { + continue; + } + + final library = _ExamplesLibrary.fromDirectory(directory, flutterRoot: flutterRoot); + + mapping.putIfAbsent(library, () => {}); + + for (final File file in directory.listSync(recursive: true).whereType()) { + if (!file.absolute.path.contains(dartFilePattern)) { + continue; + } + + mapping[library]?.add(file); + } + } + + for (final Directory directory in testDirectory.listSync().whereType()) { + // The sample templates directory is handled separately. + if (path.basename(directory.absolute.path) == _kSampleTemplatesDirectoryName) { + continue; + } + + final library = _ExamplesLibrary.fromDirectory(directory, flutterRoot: flutterRoot); + + mapping.putIfAbsent(library, () => {}); + + for (final File file in directory.listSync(recursive: true).whereType()) { + if (!file.absolute.path.contains(dartFilePattern)) { + continue; + } + + mapping[library]?.add(file); + } + } + + return mapping; + } + + /// Returns true if there are no errors, false otherwise. + bool check() { + filesystem.currentDirectory = flutterRoot; + + final Map<_ExamplesLibrary, Set> filesByLibrary = _getExampleFiles(); + + // Find all cross imports. + final Map crossImportsPerLibrary = + getCrossImports(filesByLibrary); + + var valid = true; + + // Find any cross imports that are not in the known list. + for (final MapEntry entry + in crossImportsPerLibrary.entries) { + final Set unknownCupertinoImports = getUnknowns( + knownExamplesCrossImports, + entry.value.cupertinoImports, + prefix: _examplesPrefix, + ); + final Set unknownMaterialImports = getUnknowns( + knownExamplesCrossImports, + entry.value.materialImports, + prefix: _examplesPrefix, + ); + + if (unknownMaterialImports.isNotEmpty) { + valid = false; + foundError( + getImportError( + flutterRoot: flutterRoot, + files: unknownMaterialImports, + checkedLibrary: entry.key, + importStatement: LibraryCrossImportStatementType.material, + ).split('\n'), + ); + } + + if (unknownCupertinoImports.isNotEmpty) { + valid = false; + foundError( + getImportError( + flutterRoot: flutterRoot, + files: unknownCupertinoImports, + checkedLibrary: entry.key, + importStatement: LibraryCrossImportStatementType.cupertino, + ).split('\n'), + ); + } + } + + // Find any known cross imports that weren't found, and are therefore fixed. + // Pre-compute all library prefixes so that root libraries (e.g. `examples`, + // `examples/api`) don't claim entries that belong to a more-specific sub-library. + // TODO(justinmc): Remove this after all known cross imports have been + // fixed. + // See https://github.com/flutter/flutter/issues/187645. + final Set allLibraryPrefixes = { + for (final CrossImportCheckedLibrary library in crossImportsPerLibrary.keys) + '${library.libraryName}/', + }; + + for (final MapEntry entry + in crossImportsPerLibrary.entries) { + final ownPrefix = '${entry.key.libraryName}/'; + + final Set crossImportsForLibrary = entry.value.cupertinoImports.union( + entry.value.materialImports, + ); + + final Set knownCrossImportsForLibrary = { + for (final String element in entry.key.knownCrossImports) + // The known cross imports include both /lib and /test entries, so handle both. + // Exclude entries that are owned by a more-specific sub-library. + if (element.startsWith(ownPrefix) && + !allLibraryPrefixes.any( + (String prefix) => + prefix != ownPrefix && + prefix.startsWith(ownPrefix) && + element.startsWith(prefix), + )) + element, + }; + + final Set fixedCrossImports = differencePaths( + knownCrossImportsForLibrary, + crossImportsForLibrary, + prefix: _examplesPrefix, + ); + + if (fixedCrossImports.isNotEmpty) { + valid = false; + foundError(getFixedImportError(fixedCrossImports, entry.key).split('\n')); + } + } + + return valid; + } +} + +/// The examples that we are concerned with cross importing. +sealed class _ExamplesLibrary implements CrossImportCheckedLibrary { + const _ExamplesLibrary(this._name); + + /// Construct a [_ExamplesLibrary] from a given [directory]. + /// + /// The [directory] must be inside the [flutterRoot]. + factory _ExamplesLibrary.fromDirectory(Directory directory, {required Directory flutterRoot}) { + if (!directory.absolute.path.startsWith(flutterRoot.absolute.path)) { + throw ArgumentError('Directory must be inside ${flutterRoot.absolute.path}.', 'directory'); + } + + final String relativePath = path + .relative(directory.absolute.path, from: flutterRoot.absolute.path) + .replaceAll(Platform.pathSeparator, '/'); + + return switch (relativePath) { + _ + when relativePath.startsWith('examples/api/lib/cupertino') || + relativePath.startsWith('examples/api/test/cupertino') => + _CupertinoApiExampleLibrary(relativePath), + + _ + when relativePath.startsWith('examples/api/lib/material') || + relativePath.startsWith('examples/api/test/material') => + _MaterialApiExampleLibrary(relativePath), + _ + when relativePath.startsWith('examples/api/lib/animation') || + relativePath.startsWith('examples/api/lib/foundation') || + relativePath.startsWith('examples/api/lib/gestures') || + relativePath.startsWith('examples/api/lib/painting') || + relativePath.startsWith('examples/api/lib/rendering') || + relativePath.startsWith('examples/api/lib/services') || + relativePath.startsWith('examples/api/lib/ui') || + relativePath.startsWith('examples/api/lib/widgets') => + _ApiExampleLibrary(relativePath), + _ + when relativePath.startsWith('examples/api/test/animation') || + relativePath.startsWith('examples/api/test/foundation') || + relativePath.startsWith('examples/api/test/gestures') || + relativePath.startsWith('examples/api/test/painting') || + relativePath.startsWith('examples/api/test/rendering') || + relativePath.startsWith('examples/api/test/services') || + relativePath.startsWith('examples/api/test/ui') || + relativePath.startsWith('examples/api/test/widgets') => + _ApiExampleLibrary(relativePath), + _ + when relativePath.startsWith('examples/flutter_view') || + relativePath.startsWith('examples/hello_world') || + relativePath.startsWith('examples/image_list') || + relativePath.startsWith('examples/layers') || + relativePath.startsWith('examples/multiple_windows') || + relativePath.startsWith('examples/platform_channel') || + relativePath.startsWith('examples/platform_channel_swift') || + relativePath.startsWith('examples/platform_view') || + relativePath.startsWith('examples/splash') || + relativePath.startsWith('examples/texture') => + _ApiExampleLibrary(relativePath), + _ when relativePath.startsWith('examples/api') || relativePath.startsWith('examples') => + _ApiExampleLibrary(relativePath), + _ => throw UnimplementedError('Unknown library: $relativePath'), + }; + } + + /// The short name of the library, for example `examples/flutter_view`. + final String _name; + + @override + String get cannotImportMessage { + return 'Only Material examples can import Material and only Cupertino examples can import Cupertino.'; + } + + @override + Set get knownCrossImports => ExamplesCrossImportChecker.knownExamplesCrossImports; + + @override + String get libraryName => _name; + + @override + String get removeCrossImportsInstructionMessage { + return 'However, they now need to be removed from the\n' + 'knownExamplesCrossImports list in the script /dev/bots/check_examples_cross_imports.dart.'; + } + + @override + bool canImport(LibraryCrossImportStatementType import) => false; + + @override + String getDisallowedImportMessage(String importedLibraryName, int filesCount) { + return filesCount < 2 + ? 'The following file in $libraryName has a disallowed import of $importedLibraryName. ' + 'Refactor it or move it to the $importedLibraryName examples.\n' + : 'The following $filesCount files in $libraryName have a disallowed import of $importedLibraryName. ' + 'Refactor them or move them to the $importedLibraryName examples.\n'; + } +} + +/// Any API example - not related to Material or Cupertino - inside `examples/api`, and its tests. +/// +/// For example `examples/api/lib/foundation` and `examples/api/test/foundation`. +final class _ApiExampleLibrary extends _ExamplesLibrary { + const _ApiExampleLibrary(super.name); +} + +/// The examples in `examples/api/lib/cupertino` +/// and their tests in `examples/api/test/cupertino`. +final class _CupertinoApiExampleLibrary extends _ExamplesLibrary { + const _CupertinoApiExampleLibrary(super.name); + + @override + bool canImport(LibraryCrossImportStatementType import) { + // While the Cupertino examples under `examples/api` are not allowed to import Material, + // the actual samples have been relocated to `packages/cupertino_ui`, + // where their cross imports will be addressed separately. + // The existing samples in `examples/api` are the now defunct orginal samples. + // For the purpose of the checker, allow all imports. + return true; + } +} + +/// Any non-API example, not in `examples/api`, +/// such as `examples/flutter_view` or `examples/hello_world`. +final class _GenericExampleLibrary extends _ExamplesLibrary { + const _GenericExampleLibrary(super.name); +} + +/// The examples in `examples/api/lib/material` +/// and their tests in `examples/api/test/material`. +final class _MaterialApiExampleLibrary extends _ExamplesLibrary { + const _MaterialApiExampleLibrary(super.name); + + @override + bool canImport(LibraryCrossImportStatementType import) { + // While the Material examples under `examples/api` are not allowed to import Cupertino, + // the actual samples have been relocated to `packages/material_ui`, + // where their cross imports will be addressed separately. + // The existing samples in `examples/api` are the now defunct orginal samples. + // For the purpose of the checker, allow all imports. + return true; + } +} + +/// The examples in `examples/api/lib/sample_templates` +/// and their tests in `examples/api/test/sample_templates`. +/// +/// The sample templates are individual files, rather than directories. +final class _SampleTemplatesLibraryFile extends _ExamplesLibrary { + const _SampleTemplatesLibraryFile._(super.name, this._filePath); + + factory _SampleTemplatesLibraryFile.fromFile(File file) { + const examplesLibPrefix = 'examples/api/lib/sample_templates'; + const examplesTestPrefix = 'examples/api/test/sample_templates'; + final String filePath = file.absolute.path.replaceAll(Platform.pathSeparator, '/'); + + final int libIndex = filePath.indexOf(examplesLibPrefix); + + if (libIndex != -1) { + return _SampleTemplatesLibraryFile._(examplesLibPrefix, filePath); + } + + final int testIndex = filePath.indexOf(examplesTestPrefix); + + if (testIndex != -1) { + return _SampleTemplatesLibraryFile._(examplesTestPrefix, filePath); + } + + throw ArgumentError('Invalid file path: $filePath'); + } + + /// The file path to the template file. + final String _filePath; + + @override + bool canImport(LibraryCrossImportStatementType import) { + return switch (import) { + LibraryCrossImportStatementType.material => _filePath.contains('material'), + LibraryCrossImportStatementType.cupertino => _filePath.contains('cupertino'), + }; + } +} diff --git a/dev/bots/check_tests_cross_imports.dart b/dev/bots/check_tests_cross_imports.dart index d0bf9440f8cf1..1810b2696d1c8 100644 --- a/dev/bots/check_tests_cross_imports.dart +++ b/dev/bots/check_tests_cross_imports.dart @@ -12,6 +12,7 @@ import 'package:file/file.dart'; import 'package:file/local.dart'; import 'package:path/path.dart' as path; +import 'cross_imports_checker_utils.dart'; import 'utils.dart'; final String _scriptLocation = path.fromUri(Platform.script); @@ -187,17 +188,8 @@ class TestsCrossImportChecker { 'packages/flutter_test/lib/src/widget_tester.dart', 'packages/flutter_test/lib/src/finders.dart', 'packages/flutter_test/lib/src/matchers.dart', - 'packages/flutter_test/test_fixes/flutter_test/animation_sheet_builder.dart', - 'packages/flutter_test/test_fixes/flutter_test/matchers.dart', - 'packages/flutter_test/test/navigator_test.dart', - 'packages/flutter_test/test/mock_canvas_test.dart', - 'packages/flutter_test/test/semantics_finder_test.dart', 'packages/flutter_test/test/accessibility_window_test.dart', - 'packages/flutter_test/test/widget_tester_live_device_test.dart', - 'packages/flutter_test/test/all_elements_test.dart', - 'packages/flutter_test/test/utils/memory_leak_tests.dart', 'packages/flutter_test/test/widget_tester_test.dart', - 'packages/flutter_test/test/live_widget_controller_test.dart', 'packages/flutter_test/test/accessibility_test.dart', 'packages/flutter_test/test/finders_test.dart', 'packages/flutter_test/test/controller_test.dart', @@ -226,88 +218,6 @@ class TestsCrossImportChecker { // This matches both `packages/flutter/test` and `packages/flutter_test`. static final RegExp _flutterTestPrefix = RegExp(r'packages[/\\]flutter[/\\_]test'); - /// Returns the [Set] of paths in [knownPaths] that are not in [files]. - static Set _differencePaths(Set knownPaths, Set files) { - final Set testPaths = files.map((File file) { - final int index = file.absolute.path.indexOf(_flutterTestPrefix); - if (index < 0) { - throw ArgumentError('All files must include $_flutterTestPrefix in their path.', 'files'); - } - return file.absolute.path.substring(index).replaceAll(Platform.pathSeparator, '/'); - }).toSet(); - return knownPaths.difference(testPaths); - } - - /// Get the [Map] of files, per [_Library], of the files that have cross imports on Material and Cupertino. - static Map<_Library, _CrossImportingFiles> _getCrossImports(Map<_Library, Set> libraries) { - final Map<_Library, _CrossImportingFiles> crossImports = {}; - - for (final MapEntry<_Library, Set> entry in libraries.entries) { - final Set cupertinoImports = {}; - final Set materialImports = {}; - - for (final File file in entry.value) { - final String contents = file.readAsStringSync(); - - if (!entry.key.canImport(_LibraryImportStatement.cupertino) && - contents.contains(_LibraryImportStatement.cupertino.importString)) { - cupertinoImports.add(file); - } - - if (!entry.key.canImport(_LibraryImportStatement.material) && - contents.contains(_LibraryImportStatement.material.importString)) { - materialImports.add(file); - } - } - - crossImports[entry.key] = ( - cupertinoImports: cupertinoImports, - materialImports: materialImports, - ); - } - - return crossImports; - } - - /// Returns the [Set] of files that are not in [knownPaths]. - static Set _getUnknowns(Set knownPaths, Set files) { - return files.where((File file) { - final int index = file.absolute.path.indexOf(_flutterTestPrefix); - if (index < 0) { - throw ArgumentError('All files must include $_flutterTestPrefix in their path.', 'files'); - } - final String comparablePath = file.absolute.path - .substring(index) - .replaceAll(Platform.pathSeparator, '/'); - return !knownPaths.contains(comparablePath); - }).toSet(); - } - - /// Returns the error message for the given [fixedPaths] that no longer have a - /// cross import. - /// - /// The [library] must not be [_MaterialLibrary], because Material is allowed to - /// cross-import. - static String _getFixedImportError(Set fixedPaths, _Library library) { - assert(fixedPaths.isNotEmpty); - final buffer = StringBuffer( - 'Huzzah! The following tests in ${library.name} no longer contain cross imports!\n', - ); - for (final path in fixedPaths) { - buffer.writeln(' $path'); - } - buffer.writeln('However, they now need to be removed from the'); - buffer.write( - '${library.crossImportsListSymbolName} list in the script /dev/bots/check_tests_cross_imports.dart.', - ); - return buffer.toString().trimRight(); - } - - /// Returns the [file]'s relative path, relative to [flutterRoot]. - String _getRelativePath(File file) { - return path.relative(file.absolute.path, from: flutterRoot.absolute.path); - } - /// Get a list of all the filenames that end in ".dart", grouped by library. Map<_Library, Set> _getTestFiles() { final dartFilePattern = RegExp(r'\.dart$'); @@ -360,34 +270,6 @@ class TestsCrossImportChecker { return mapping; } - /// Returns the import error for the [files] in [testLibrary] which contain the given [importStatement]. - /// - /// Import errors only occur when: - /// - any library that is not Material or Cupertino, imports Material or Cupertino - /// - Cupertino imports Material - String _getImportError({ - required Set files, - required _Library testLibrary, - required _LibraryImportStatement importStatement, - }) { - assert( - !testLibrary.canImport(importStatement), - 'any library that is not Material or Cupertino, imports Material or Cupertino, ' - 'and when Cupertino imports Material.', - ); - - final String importedLibraryName = importStatement.readableName; - final buffer = StringBuffer( - files.length < 2 - ? 'The following test in ${testLibrary.name} has a disallowed import of $importedLibraryName. Refactor it or move it to $importedLibraryName.\n' - : 'The following ${files.length} tests in ${testLibrary.name} have a disallowed import of $importedLibraryName. Refactor them or move them to $importedLibraryName.\n', - ); - for (final file in files) { - buffer.writeln(' ${_getRelativePath(file).replaceAll(Platform.pathSeparator, '/')}'); - } - return buffer.toString().trimRight(); - } - /// Returns true if there are no errors, false otherwise. bool check() { filesystem.currentDirectory = flutterRoot; @@ -395,30 +277,33 @@ class TestsCrossImportChecker { final Map<_Library, Set> filesByLibrary = _getTestFiles(); // Find all cross imports. - final Map<_Library, _CrossImportingFiles> crossImportsPerLibrary = _getCrossImports( - filesByLibrary, - ); + final Map crossImportsPerLibrary = + getCrossImports(filesByLibrary); var valid = true; // Find any cross imports that are not in the known list. - for (final MapEntry<_Library, _CrossImportingFiles> entry in crossImportsPerLibrary.entries) { - final Set unknownCupertinoImports = _getUnknowns( + for (final MapEntry entry + in crossImportsPerLibrary.entries) { + final Set unknownCupertinoImports = getUnknowns( _knownCrossImports, entry.value.cupertinoImports, + prefix: _flutterTestPrefix, ); - final Set unknownMaterialImports = _getUnknowns( + final Set unknownMaterialImports = getUnknowns( _knownCrossImports, entry.value.materialImports, + prefix: _flutterTestPrefix, ); if (unknownMaterialImports.isNotEmpty) { valid = false; foundError( - _getImportError( + getImportError( + flutterRoot: flutterRoot, files: unknownMaterialImports, - testLibrary: entry.key, - importStatement: _LibraryImportStatement.material, + checkedLibrary: entry.key, + importStatement: LibraryCrossImportStatementType.material, ).split('\n'), ); } @@ -426,10 +311,11 @@ class TestsCrossImportChecker { if (unknownCupertinoImports.isNotEmpty) { valid = false; foundError( - _getImportError( + getImportError( + flutterRoot: flutterRoot, files: unknownCupertinoImports, - testLibrary: entry.key, - importStatement: _LibraryImportStatement.cupertino, + checkedLibrary: entry.key, + importStatement: LibraryCrossImportStatementType.cupertino, ).split('\n'), ); } @@ -439,19 +325,21 @@ class TestsCrossImportChecker { // TODO(justinmc): Remove this after all known cross imports have been // fixed. // See https://github.com/flutter/flutter/issues/177028. - for (final MapEntry<_Library, _CrossImportingFiles> entry in crossImportsPerLibrary.entries) { + for (final MapEntry entry + in crossImportsPerLibrary.entries) { final Set crossImportsForLibrary = entry.value.cupertinoImports.union( entry.value.materialImports, ); final Set knownCrossImportsForLibrary = entry.key.knownCrossImports; - final Set fixedCrossImports = _differencePaths( + final Set fixedCrossImports = differencePaths( knownCrossImportsForLibrary, crossImportsForLibrary, + prefix: _flutterTestPrefix, ); if (fixedCrossImports.isNotEmpty) { valid = false; - foundError(_getFixedImportError(fixedCrossImports, entry.key).split('\n')); + foundError(getFixedImportError(fixedCrossImports, entry.key).split('\n')); } } @@ -459,22 +347,9 @@ class TestsCrossImportChecker { } } -/// The set of files that import Cupertino and Material for a given [_Library]. -typedef _CrossImportingFiles = ({Set cupertinoImports, Set materialImports}); - -enum _LibraryImportStatement { - material('Material', "import 'package:flutter/material.dart'"), - cupertino('Cupertino', "import 'package:flutter/cupertino.dart'"); - - const _LibraryImportStatement(this.readableName, this.importString); - - final String readableName; - final String importString; -} - /// The libraries that we are concerned with cross importing. -sealed class _Library { - const _Library(this.name); +sealed class _Library implements CrossImportCheckedLibrary { + const _Library(this._name); /// Construct a [_Library] from a given [directory]. /// @@ -496,38 +371,15 @@ sealed class _Library { } /// The short name of the library, for example `packages/flutter/test/widgets`. - final String name; + final String _name; - /// The name of the variable in [TestsCrossImportChecker] - /// that contains the list of known cross imports for this library. - /// - /// This is used for reporting mismatched cross imports. - String get crossImportsListSymbolName { - return switch (name) { - 'packages/flutter_test' => 'knownFlutterTestLibraryCrossImports', - 'packages/flutter/test' => 'knownFlutterSlashTestCrossImports', - 'packages/flutter/test/animation' => 'knownAnimationCrossImports', - 'packages/flutter/test/cupertino' => 'knownCupertinoCrossImports', - 'packages/flutter/test/dart' => 'knownDartCrossImports', - 'packages/flutter/test/examples' => 'knownExamplesCrossImports', - 'packages/flutter/test/foundation' => 'knownFoundationCrossImports', - 'packages/flutter/test/gestures' => 'knownGesturesCrossImports', - 'packages/flutter/test/harness' => 'knownHarnessCrossImports', - 'packages/flutter/test/material' => throw UnsupportedError( - 'Material is responsible for testing its interactions with Cupertino, so it is allowed to cross-import.', - ), - 'packages/flutter/test/painting' => 'knownPaintingCrossImports', - 'packages/flutter/test/physics' => 'knownPhysicsCrossImports', - 'packages/flutter/test/rendering' => 'knownRenderingCrossImports', - 'packages/flutter/test/scheduler' => 'knownSchedulerCrossImports', - 'packages/flutter/test/semantics' => 'knownSemanticsCrossImports', - 'packages/flutter/test/services' => 'knownServicesCrossImports', - 'packages/flutter/test/widgets' => 'knownWidgetsCrossImports', - _ => throw UnimplementedError('Unknown library: $name'), - }; + @override + String get cannotImportMessage { + return 'any library that is not Material or Cupertino, imports Material or Cupertino, ' + 'and when Cupertino imports Material.'; } - /// Get the list of known cross imports for this [_Library]. + @override Set get knownCrossImports { // Material is allowed to cross import. if (this is _MaterialLibrary) { @@ -553,19 +405,65 @@ sealed class _Library { 'knownSemanticsCrossImports' => TestsCrossImportChecker.knownSemanticsCrossImports, 'knownServicesCrossImports' => TestsCrossImportChecker.knownServicesCrossImports, 'knownWidgetsCrossImports' => TestsCrossImportChecker.knownWidgetsCrossImports, - _ => throw UnimplementedError('Unknown library: $name'), + _ => throw UnimplementedError('Unknown library: $libraryName'), }; } - /// Returns whether this library can contain the given [import]. - bool canImport(_LibraryImportStatement import) { + @override + String get libraryName => _name; + + @override + String get removeCrossImportsInstructionMessage { + return 'However, they now need to be removed from the\n' + '$crossImportsListSymbolName list in the script /dev/bots/check_tests_cross_imports.dart.'; + } + + @override + bool canImport(LibraryCrossImportStatementType import) { return switch (this) { - _MaterialLibrary() => - import == _LibraryImportStatement.material || import == _LibraryImportStatement.cupertino, - _CupertinoLibrary() => import == _LibraryImportStatement.cupertino, + _MaterialLibrary() => import == .material || import == .cupertino, + _CupertinoLibrary() => import == .cupertino, _OtherLibrary() => false, }; } + + @override + String getDisallowedImportMessage(String importedLibraryName, int filesCount) { + return filesCount < 2 + ? 'The following test in $libraryName has a disallowed import of $importedLibraryName. ' + 'Refactor it or move it to $importedLibraryName.\n' + : 'The following $filesCount tests in $libraryName have a disallowed import of $importedLibraryName. ' + 'Refactor them or move them to $importedLibraryName.\n'; + } + + /// The name of the variable in [TestsCrossImportChecker] + /// that contains the list of known cross imports for this library. + /// + /// This is used for reporting mismatched cross imports. + String get crossImportsListSymbolName { + return switch (libraryName) { + 'packages/flutter_test' => 'knownFlutterTestLibraryCrossImports', + 'packages/flutter/test' => 'knownFlutterSlashTestCrossImports', + 'packages/flutter/test/animation' => 'knownAnimationCrossImports', + 'packages/flutter/test/cupertino' => 'knownCupertinoCrossImports', + 'packages/flutter/test/dart' => 'knownDartCrossImports', + 'packages/flutter/test/examples' => 'knownExamplesCrossImports', + 'packages/flutter/test/foundation' => 'knownFoundationCrossImports', + 'packages/flutter/test/gestures' => 'knownGesturesCrossImports', + 'packages/flutter/test/harness' => 'knownHarnessCrossImports', + 'packages/flutter/test/material' => throw UnsupportedError( + 'Material is responsible for testing its interactions with Cupertino, so it is allowed to cross-import.', + ), + 'packages/flutter/test/painting' => 'knownPaintingCrossImports', + 'packages/flutter/test/physics' => 'knownPhysicsCrossImports', + 'packages/flutter/test/rendering' => 'knownRenderingCrossImports', + 'packages/flutter/test/scheduler' => 'knownSchedulerCrossImports', + 'packages/flutter/test/semantics' => 'knownSemanticsCrossImports', + 'packages/flutter/test/services' => 'knownServicesCrossImports', + 'packages/flutter/test/widgets' => 'knownWidgetsCrossImports', + _ => throw UnimplementedError('Unknown library: $libraryName'), + }; + } } /// The Material library, also known as `packages/flutter/test/material`. diff --git a/dev/bots/cross_imports_checker_utils.dart b/dev/bots/cross_imports_checker_utils.dart new file mode 100644 index 0000000000000..da55972cc1296 --- /dev/null +++ b/dev/bots/cross_imports_checker_utils.dart @@ -0,0 +1,195 @@ +// 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:io' show Platform; + +import 'package:file/file.dart'; +import 'package:path/path.dart' as path; + +/// A typedef that contains a set of [File]s which import Cupertino, +/// and a set of [File]s which import Material. +typedef CrossImportingFiles = ({Set cupertinoImports, Set materialImports}); + +/// A library that is checked for cross imports. +abstract interface class CrossImportCheckedLibrary { + /// Get the error message that is used to assert in [getImportError], + /// that an import error is expected. + /// + /// This error message should describe why an import is not allowed. + /// + /// For example: "Only Material is allowed to import Material" + String get cannotImportMessage; + + /// Get the list of known cross imports for this [CrossImportCheckedLibrary]. + Set get knownCrossImports; + + /// The short name of the library. + /// + /// For example `packages/flutter/test/widgets` or `examples/api/foo`. + String get libraryName; + + /// The message that instructs how to remove now-fixed cross imports for this library, + /// in the relevant cross imports checker. + /// + /// For example: + /// "However, they now need to be removed from the\n cross imports list in the script /dev/bots/check_tests_cross_imports.dart." + String get removeCrossImportsInstructionMessage; + + /// Returns whether this library may contain the given [import]. + bool canImport(LibraryCrossImportStatementType import); + + /// Get an error message that describes that [filesCount] files + /// have a disallowed import of [importedLibraryName]. + /// + /// This message is used as preamble when listing the import errors emitted by a cross imports checker. + /// + /// For example: + /// "The following $filesCount files have a disallowed import of $importedLibraryName. Refactor them or move them to $importedLibraryName." + String getDisallowedImportMessage(String importedLibraryName, int filesCount); +} + +/// An enum that defines the possible cross import statements for libraries, +/// which are of special interest for a cross imports checker. +enum LibraryCrossImportStatementType { + /// A cross import of the Material library. + material('Material', "import 'package:flutter/material.dart'"), + + /// A cross import of the Cupertino library. + cupertino('Cupertino', "import 'package:flutter/cupertino.dart'"); + + const LibraryCrossImportStatementType(this.readableName, this.importString); + + /// The readable name for the library that is being cross imported. + /// + /// For example `Material` or `Cupertino`. + final String readableName; + + /// The import statement string for a cross import of this type. + /// + /// This string is a valid Dart import statement, + /// for example `import 'package:flutter/material.dart'`. + /// + /// This import statement does not include a trailing semicolon, + /// as there may be a `show` keyword following the import statement in the affected library. + final String importString; +} + +/// Returns the [Set] of paths in [knownPaths] that are not in [files]. +/// +/// Each file is expected to have a path that includes [prefix]. +Set differencePaths(Set knownPaths, Set files, {required Pattern prefix}) { + final Set testPaths = files.map((File file) { + final int index = file.absolute.path.indexOf(prefix); + if (index < 0) { + throw ArgumentError('All files must include $prefix in their path.', 'files'); + } + + return file.absolute.path.substring(index).replaceAll(Platform.pathSeparator, '/'); + }).toSet(); + + return knownPaths.difference(testPaths); +} + +/// Get the [Map] of files, per [CrossImportCheckedLibrary], +/// of the files that have cross imports on Material and Cupertino. +Map getCrossImports( + Map> libraries, +) { + final Map crossImports = {}; + + for (final MapEntry> entry in libraries.entries) { + final Set cupertinoImports = {}; + final Set materialImports = {}; + + for (final File file in entry.value) { + final String contents = file.readAsStringSync(); + + for (final LibraryCrossImportStatementType importStatement + in LibraryCrossImportStatementType.values) { + switch (importStatement) { + case .cupertino: + if (!entry.key.canImport(importStatement) && + contents.contains(importStatement.importString)) { + cupertinoImports.add(file); + } + case .material: + if (!entry.key.canImport(importStatement) && + contents.contains(importStatement.importString)) { + materialImports.add(file); + } + } + } + } + + crossImports[entry.key] = ( + cupertinoImports: cupertinoImports, + materialImports: materialImports, + ); + } + + return crossImports; +} + +/// Returns the error message for the given [fixedPaths] +/// that no longer have a cross import. +/// +/// The [library] must not be the Material library, +/// because Material is allowed to cross-import. +String getFixedImportError(Set fixedPaths, CrossImportCheckedLibrary library) { + assert(fixedPaths.isNotEmpty); + final buffer = StringBuffer( + 'Huzzah! The following files in ${library.libraryName} no longer contain cross imports!\n', + ); + for (final path in fixedPaths) { + buffer.writeln(' $path'); + } + buffer.write(library.removeCrossImportsInstructionMessage); + + return buffer.toString().trimRight(); +} + +/// Returns the import error for the [files] in [checkedLibrary] which contain the given [importStatement]. +String getImportError({ + required Set files, + required Directory flutterRoot, + required CrossImportCheckedLibrary checkedLibrary, + required LibraryCrossImportStatementType importStatement, +}) { + assert(!checkedLibrary.canImport(importStatement), checkedLibrary.cannotImportMessage); + + final String importedLibraryName = importStatement.readableName; + final buffer = StringBuffer( + checkedLibrary.getDisallowedImportMessage(importedLibraryName, files.length), + ); + for (final file in files) { + buffer.writeln( + ' ${getRelativePath(file, flutterRoot: flutterRoot).replaceAll(Platform.pathSeparator, '/')}', + ); + } + return buffer.toString().trimRight(); +} + +/// Returns the [file]'s relative path, relative to [flutterRoot]. +String getRelativePath(File file, {required Directory flutterRoot}) { + return path.relative(file.absolute.path, from: flutterRoot.absolute.path); +} + +/// Returns the [Set] of files that are not in [knownPaths]. +/// +/// Each file is expected to have a path that includes [prefix]. +Set getUnknowns(Set knownPaths, Set files, {required Pattern prefix}) { + return files.where((File file) { + final int index = file.absolute.path.indexOf(prefix); + + if (index < 0) { + throw ArgumentError('All files must include $prefix in their path.', 'files'); + } + + final String comparablePath = file.absolute.path + .substring(index) + .replaceAll(Platform.pathSeparator, '/'); + + return !knownPaths.contains(comparablePath); + }).toSet(); +} diff --git a/dev/bots/suite_runners/run_android_hardware_smoke_tests.dart b/dev/bots/suite_runners/run_android_hardware_smoke_tests.dart index 24056c7aefa23..b667dc6845e73 100644 --- a/dev/bots/suite_runners/run_android_hardware_smoke_tests.dart +++ b/dev/bots/suite_runners/run_android_hardware_smoke_tests.dart @@ -4,6 +4,7 @@ import 'dart:async'; import 'dart:io' show Platform; + import 'package:file/file.dart'; import 'package:file/local.dart'; import 'package:path/path.dart' as path; @@ -15,18 +16,6 @@ import 'run_android_engine_tests.dart'; String _impellerBackendMetadata({required String value}) => ''; -void _copyDirectory(Directory source, Directory destination) { - destination.createSync(recursive: true); - for (final FileSystemEntity entity in source.listSync(recursive: true)) { - if (entity is File) { - final String relativePath = path.relative(entity.path, from: source.path); - final String destPath = path.join(destination.path, relativePath); - entity.fileSystem.file(destPath).parent.createSync(recursive: true); - entity.copySync(destPath); - } - } -} - void _cleanGoldensDirectory(Directory directory) { if (!directory.existsSync()) { return; @@ -64,9 +53,6 @@ Future runAndroidHardwareSmokeTests({ final Directory destinationDir = const LocalFileSystem().directory( path.join(testDir, 'test_driver', 'goldens'), ); - final Directory sourceDir = const LocalFileSystem().directory( - path.join(testDir, 'android_hardware_smoke_test.${backend.name}.goldens'), - ); try { // Replace whatever the current backend is with the specified backend. @@ -93,13 +79,6 @@ Future runAndroidHardwareSmokeTests({ ], workingDirectory: testDir); if (runInstrumented) { - // 2. Copy the generated goldens to the assets directory so they get packaged with the APK. - // In CI, the Skia Gold comparator downloads the baseline images into a temporary prefixed - // directory (sourceDir) instead of the default assets directory (destinationDir). - if (sourceDir.existsSync()) { - _copyDirectory(sourceDir, destinationDir); - } - final String gradle = path.absolute( path.join(androidDir, Platform.isWindows ? 'gradlew.bat' : 'gradlew'), ); @@ -117,10 +96,5 @@ Future runAndroidHardwareSmokeTests({ // Clean up copied goldens to keep Git worktree completely clean _cleanGoldensDirectory(destinationDir); - - // Clean up the temporary prefixed goldens directory - if (sourceDir.existsSync()) { - sourceDir.deleteSync(recursive: true); - } } } diff --git a/dev/bots/suite_runners/run_verify_binaries_codesigned_tests.dart b/dev/bots/suite_runners/run_verify_binaries_codesigned_tests.dart index 6ef24bae1a525..1621dd56c7b55 100644 --- a/dev/bots/suite_runners/run_verify_binaries_codesigned_tests.dart +++ b/dev/bots/suite_runners/run_verify_binaries_codesigned_tests.dart @@ -121,6 +121,7 @@ List binariesWithoutEntitlements(String flutterRoot) { 'dart-sdk/bin/snapshots/dart2bytecode.dart.snapshot', 'dart-sdk/bin/snapshots/dart2js_aot.dart.snapshot', 'dart-sdk/bin/snapshots/dart2wasm_product.snapshot', + 'dart-sdk/bin/snapshots/dart_runtime_service_vm_aot.dart.snapshot', 'dart-sdk/bin/snapshots/dart_tooling_daemon_aot.dart.snapshot', 'dart-sdk/bin/snapshots/dartdev_aot.dart.snapshot', 'dart-sdk/bin/snapshots/dartdevc_aot.dart.snapshot', diff --git a/dev/bots/suite_runners/run_web_tests.dart b/dev/bots/suite_runners/run_web_tests.dart index 891f8224c0d7c..5551e97b51d56 100644 --- a/dev/bots/suite_runners/run_web_tests.dart +++ b/dev/bots/suite_runners/run_web_tests.dart @@ -178,6 +178,9 @@ class WebTestsSuite { useWasm: false, ), + () => _runWebE2eTest('deferred_loading_integration', buildMode: 'release', useWasm: false), + () => _runWebE2eTest('deferred_loading_integration', buildMode: 'release', useWasm: true), + () => _runWebTreeshakeTest(), () => _runFlutterDriverWebTest( diff --git a/dev/bots/test.dart b/dev/bots/test.dart index 8ab0284582fcb..f76a4d59b4330 100644 --- a/dev/bots/test.dart +++ b/dev/bots/test.dart @@ -230,14 +230,8 @@ Future _runWebToolTests() async { ); } -Future _runToolHostCrossArchTests() { - return runDartTest( - _toolsPath, - // These are integration tests - forceSingleCore: true, - testPaths: [path.join('test', 'host_cross_arch.shard')], - ); -} +// TODO(jmagman): https://github.com/flutter/flutter/issues/189302 remove when it gets to stable. +Future _runToolHostCrossArchTests() async {} Future _runIntegrationToolTests() async { final List allTests = Directory(path.join(_toolsPath, 'test', 'integration.shard')) diff --git a/dev/bots/test/check_examples_cross_imports_test.dart b/dev/bots/test/check_examples_cross_imports_test.dart new file mode 100644 index 0000000000000..809cd6b4be40d --- /dev/null +++ b/dev/bots/test/check_examples_cross_imports_test.dart @@ -0,0 +1,1130 @@ +// 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:io'; + +import 'package:file/file.dart'; +import 'package:file/memory.dart'; +import 'package:path/path.dart' as path; + +import '../check_examples_cross_imports.dart'; +import '../cross_imports_checker_utils.dart'; +import 'common.dart'; +import 'cross_imports_checker_test_utils.dart'; + +// A pattern that matches `examples/api/lib/**` and `examples/api/test/**`, for use in tests. +final _kExamplesSlashApiLibraryPattern = RegExp(r'^examples/api/(lib|test)/[a-z_]+'); + +void main() { + late ExamplesCrossImportChecker checker; + late _CrossImportsExamplesDirectories checkerDirectories; + + void buildKnownCrossImportExamplesFiles({Set excludes = const {}}) { + final Map> knownFiles = checkerDirectories.getKnownFiles( + checker.examplesDirectory, + ); + + for (final Set files in knownFiles.values) { + for (final filePath in files) { + if (excludes.contains(filePath)) { + continue; + } + + final File file = checker.filesystem.file( + path.join( + checker.flutterRoot.absolute.path, + filePath.replaceAll('/', Platform.pathSeparator), + ), + ); + + final LibraryCrossImportStatementType importStatement = + getCrossImportStatementForExamplesLibraryFile(filePath); + + writeImport(file, importStatement.importString); + } + } + } + + setUp(() { + final fs = MemoryFileSystem( + style: Platform.isWindows ? FileSystemStyle.windows : FileSystemStyle.posix, + ); + // Get the root prefix of the current directory so that on Windows we get a + // correct root prefix. + final Directory flutterRoot = fs.directory( + path.join(path.rootPrefix(fs.currentDirectory.absolute.path), 'flutter sdk'), + )..createSync(recursive: true); + fs.currentDirectory = flutterRoot; + + final Directory examplesDirectory = flutterRoot.childDirectory('examples')..createSync(); + + checker = ExamplesCrossImportChecker( + examplesDirectory: examplesDirectory, + flutterRoot: flutterRoot, + filesystem: fs, + ); + checkerDirectories = _CrossImportsExamplesDirectories(examplesDirectory) + ..createExamplesDirectories(examplesDirectory); + }); + + test('when only all knowns have cross imports', () async { + buildKnownCrossImportExamplesFiles(); + bool? success; + final String result = await capture(() async { + success = checker.check(); + }); + expect(result, equals('')); + expect(success, isTrue); + }); + + test('non-Dart files are ignored', () async { + buildKnownCrossImportExamplesFiles(); + + checker.examplesDirectory.childFile('README.md') + ..createSync() + ..writeAsStringSync("import 'package:flutter/material.dart';"); + + expect(checker.check(), isTrue); + }); + + test('non-Dart files are ignored in nested directories', () async { + buildKnownCrossImportExamplesFiles(); + + checker.examplesDirectory + .childDirectory('layers') + .childDirectory('rendering') + .childFile('README.md') + ..createSync(recursive: true) + ..writeAsStringSync("import 'package:flutter/material.dart';"); + + expect(checker.check(), isTrue); + }); + + test('non-Dart files with .dart in the filename are ignored', () async { + buildKnownCrossImportExamplesFiles(); + + checker.examplesDirectory.childFile('foo.dart.md') + ..createSync() + ..writeAsStringSync("import 'package:flutter/material.dart';"); + + expect(checker.check(), isTrue); + }); + + test('non-Dart files with .dart in the filename are ignored in nested directories', () async { + buildKnownCrossImportExamplesFiles(); + + checker.examplesDirectory + .childDirectory('layers') + .childDirectory('rendering') + .childFile('foo.dart.md') + ..createSync(recursive: true) + ..writeAsStringSync("import 'package:flutter/material.dart';"); + + expect(checker.check(), isTrue); + }); + + test('examples/api/lib/sample_templates templates produce no violations when valid', () async { + final Directory sampleTemplatesDirectory = checker.examplesDirectory + .childDirectory('api') + .childDirectory('lib') + .childDirectory('sample_templates'); + + for (final i in [0, 1, 2]) { + sampleTemplatesDirectory.childFile('cupertino.$i.dart') + ..createSync(recursive: true) + ..writeAsStringSync("import 'package:flutter/cupertino.dart';"); + sampleTemplatesDirectory.childFile('material.$i.dart') + ..createSync(recursive: true) + ..writeAsStringSync("import 'package:flutter/material.dart';"); + sampleTemplatesDirectory.childFile('widgets.$i.dart') + ..createSync(recursive: true) + ..writeAsStringSync("import 'package:flutter/widgets.dart';"); + } + + buildKnownCrossImportExamplesFiles(); + bool? success; + final String result = await capture(() async { + success = checker.check(); + }); + expect(result, equals('')); + expect(success, isTrue); + }); + + test('examples/api/lib/sample_templates templates produce violations when invalid', () async { + final Directory sampleTemplatesDirectory = checker.examplesDirectory + .childDirectory('api') + .childDirectory('lib') + .childDirectory('sample_templates'); + + sampleTemplatesDirectory.childFile('cupertino.0.dart') + ..createSync(recursive: true) + ..writeAsStringSync("import 'package:flutter/material.dart';"); + sampleTemplatesDirectory.childFile('material.0.dart') + ..createSync(recursive: true) + ..writeAsStringSync("import 'package:flutter/cupertino.dart';"); + sampleTemplatesDirectory.childFile('widgets.0.dart') + ..createSync(recursive: true) + ..writeAsStringSync("import 'package:flutter/cupertino.dart';"); + + buildKnownCrossImportExamplesFiles(); + bool? success; + final String result = await capture(() async { + success = checker.check(); + }, shouldHaveErrors: true); + + final String lines = [ + '╔═╡ERROR #1╞════════════════════════════════════════════════════════════════════', + '║ The following file in examples/api/lib/sample_templates has a disallowed import of Material. Refactor it or move it to the Material examples.', + '║ examples/api/lib/sample_templates/cupertino.0.dart', + '╚═══════════════════════════════════════════════════════════════════════════════', + '╔═╡ERROR #2╞════════════════════════════════════════════════════════════════════', + '║ The following file in examples/api/lib/sample_templates has a disallowed import of Cupertino. Refactor it or move it to the Cupertino examples.', + '║ examples/api/lib/sample_templates/material.0.dart', + '╚═══════════════════════════════════════════════════════════════════════════════', + '╔═╡ERROR #3╞════════════════════════════════════════════════════════════════════', + '║ The following file in examples/api/lib/sample_templates has a disallowed import of Cupertino. Refactor it or move it to the Cupertino examples.', + '║ examples/api/lib/sample_templates/widgets.0.dart', + '╚═══════════════════════════════════════════════════════════════════════════════', + ].join('\n'); + expect(result, equals('$lines\n')); + expect(success, isFalse); + }); + + test('examples/api/test/sample_templates templates produce no violations when valid', () async { + final Directory sampleTemplatesDirectory = checker.examplesDirectory + .childDirectory('api') + .childDirectory('test') + .childDirectory('sample_templates'); + + for (final i in [0, 1, 2]) { + sampleTemplatesDirectory.childFile('cupertino.${i}_test.dart') + ..createSync(recursive: true) + ..writeAsStringSync("import 'package:flutter/cupertino.dart';"); + sampleTemplatesDirectory.childFile('material.${i}_test.dart') + ..createSync(recursive: true) + ..writeAsStringSync("import 'package:flutter/material.dart';"); + sampleTemplatesDirectory.childFile('widgets.${i}_test.dart') + ..createSync(recursive: true) + ..writeAsStringSync("import 'package:flutter/widgets.dart';"); + } + + buildKnownCrossImportExamplesFiles(); + bool? success; + final String result = await capture(() async { + success = checker.check(); + }); + expect(result, equals('')); + expect(success, isTrue); + }); + + test('examples/api/test/sample_templates templates produce violations when invalid', () async { + final Directory sampleTemplatesDirectory = checker.examplesDirectory + .childDirectory('api') + .childDirectory('test') + .childDirectory('sample_templates'); + + sampleTemplatesDirectory.childFile('cupertino.0_test.dart') + ..createSync(recursive: true) + ..writeAsStringSync("import 'package:flutter/material.dart';"); + sampleTemplatesDirectory.childFile('material.0_test.dart') + ..createSync(recursive: true) + ..writeAsStringSync("import 'package:flutter/cupertino.dart';"); + sampleTemplatesDirectory.childFile('widgets.0_test.dart') + ..createSync(recursive: true) + ..writeAsStringSync("import 'package:flutter/cupertino.dart';"); + + buildKnownCrossImportExamplesFiles(); + bool? success; + final String result = await capture(() async { + success = checker.check(); + }, shouldHaveErrors: true); + + final String lines = [ + '╔═╡ERROR #1╞════════════════════════════════════════════════════════════════════', + '║ The following file in examples/api/test/sample_templates has a disallowed import of Material. Refactor it or move it to the Material examples.', + '║ examples/api/test/sample_templates/cupertino.0_test.dart', + '╚═══════════════════════════════════════════════════════════════════════════════', + '╔═╡ERROR #2╞════════════════════════════════════════════════════════════════════', + '║ The following file in examples/api/test/sample_templates has a disallowed import of Cupertino. Refactor it or move it to the Cupertino examples.', + '║ examples/api/test/sample_templates/material.0_test.dart', + '╚═══════════════════════════════════════════════════════════════════════════════', + '╔═╡ERROR #3╞════════════════════════════════════════════════════════════════════', + '║ The following file in examples/api/test/sample_templates has a disallowed import of Cupertino. Refactor it or move it to the Cupertino examples.', + '║ examples/api/test/sample_templates/widgets.0_test.dart', + '╚═══════════════════════════════════════════════════════════════════════════════', + ].join('\n'); + expect(result, equals('$lines\n')); + expect(success, isFalse); + }); + + for (final String libraryName in crossImportsGenericExamplesTestCases) { + // The examples root (examples/) is expected to not contain examples. + final bool hasNoCrossImports = + isExamplesRoot(libraryName) || hasNoKnownCrossImports(libraryName); + + test( + 'when not all $libraryName knowns have cross imports', + () async { + final String excludedSample = getFirstCrossImportForLibrary(libraryName); + + buildKnownCrossImportExamplesFiles(excludes: {excludedSample}); + + bool? success; + final String result = await capture(() async { + success = checker.check(); + }, shouldHaveErrors: true); + final String lines = [ + '╔═╡ERROR #1╞════════════════════════════════════════════════════════════════════', + '║ Huzzah! The following files in $libraryName no longer contain cross imports!', + '║ $excludedSample', + '║ However, they now need to be removed from the', + '║ knownExamplesCrossImports list in the script /dev/bots/check_examples_cross_imports.dart.', + '╚═══════════════════════════════════════════════════════════════════════════════', + ].join('\n'); + expect(result, equals('$lines\n')); + expect(success, isFalse); + }, + skip: hasNoCrossImports, // [intended]: Nothing to log if there are no known imports + ); + + test('unknown $libraryName cross import of Material', () async { + final dartFile = '$libraryName/foo.dart'; + + final Directory examplesFilesDirectory = checkerDirectories.examplesFilesDirectoryFor( + libraryName, + checker.examplesDirectory, + ); + + buildKnownCrossImportExamplesFiles(); + writeImportInFiles({dartFile}, inDirectory: examplesFilesDirectory); + + bool? success; + final String result = await capture(() async { + success = checker.check(); + }, shouldHaveErrors: true); + final String lines = [ + '╔═╡ERROR #1╞════════════════════════════════════════════════════════════════════', + '║ The following file in $libraryName has a disallowed import of Material. Refactor it or move it to the Material examples.', + '║ $dartFile', + '╚═══════════════════════════════════════════════════════════════════════════════', + ].join('\n'); + expect(result, equals('$lines\n')); + expect(success, isFalse); + }); + + test('multiple unknown $libraryName cross imports of Material', () async { + final dartFileOne = '$libraryName/foo.dart'; + final dartFileTwo = '$libraryName/bar.dart'; + + final Directory examplesFilesDirectory = checkerDirectories.examplesFilesDirectoryFor( + libraryName, + checker.examplesDirectory, + ); + + buildKnownCrossImportExamplesFiles(); + writeImportInFiles({dartFileOne, dartFileTwo}, inDirectory: examplesFilesDirectory); + + bool? success; + final String result = await capture(() async { + success = checker.check(); + }, shouldHaveErrors: true); + final String lines = [ + '╔═╡ERROR #1╞════════════════════════════════════════════════════════════════════', + '║ The following 2 files in $libraryName have a disallowed import of Material. Refactor them or move them to the Material examples.', + '║ $dartFileOne', + '║ $dartFileTwo', + '╚═══════════════════════════════════════════════════════════════════════════════', + ].join('\n'); + expect(result, equals('$lines\n')); + expect(success, isFalse); + }); + + test('unknown $libraryName cross import of Material in test file', () async { + final testDartFile = '$libraryName/foo_test.dart'; + + final Directory examplesFilesDirectory = checkerDirectories.examplesFilesDirectoryFor( + libraryName, + checker.examplesDirectory, + ); + + buildKnownCrossImportExamplesFiles(); + writeImportInFiles({testDartFile}, inDirectory: examplesFilesDirectory); + + bool? success; + final String result = await capture(() async { + success = checker.check(); + }, shouldHaveErrors: true); + final String lines = [ + '╔═╡ERROR #1╞════════════════════════════════════════════════════════════════════', + '║ The following file in $libraryName has a disallowed import of Material. Refactor it or move it to the Material examples.', + '║ $testDartFile', + '╚═══════════════════════════════════════════════════════════════════════════════', + ].join('\n'); + expect(result, equals('$lines\n')); + expect(success, isFalse); + }); + + test('unknown $libraryName cross import of Cupertino', () async { + final dartFile = '$libraryName/foo.dart'; + final Directory examplesFilesDirectory = checkerDirectories.examplesFilesDirectoryFor( + libraryName, + checker.examplesDirectory, + ); + + buildKnownCrossImportExamplesFiles(); + writeImportInFiles( + {dartFile}, + inDirectory: examplesFilesDirectory, + importString: "import 'package:flutter/cupertino.dart';", + ); + + bool? success; + final String result = await capture(() async { + success = checker.check(); + }, shouldHaveErrors: true); + final String lines = [ + '╔═╡ERROR #1╞════════════════════════════════════════════════════════════════════', + '║ The following file in $libraryName has a disallowed import of Cupertino. Refactor it or move it to the Cupertino examples.', + '║ $dartFile', + '╚═══════════════════════════════════════════════════════════════════════════════', + ].join('\n'); + expect(result, equals('$lines\n')); + expect(success, isFalse); + }); + + test('multiple unknown $libraryName cross imports of Cupertino', () async { + final dartFileOne = '$libraryName/foo.dart'; + final dartFileTwo = '$libraryName/bar.dart'; + + final Directory examplesFilesDirectory = checkerDirectories.examplesFilesDirectoryFor( + libraryName, + checker.examplesDirectory, + ); + + buildKnownCrossImportExamplesFiles(); + writeImportInFiles( + {dartFileOne, dartFileTwo}, + inDirectory: examplesFilesDirectory, + importString: "import 'package:flutter/cupertino.dart';", + ); + + bool? success; + final String result = await capture(() async { + success = checker.check(); + }, shouldHaveErrors: true); + final String lines = [ + '╔═╡ERROR #1╞════════════════════════════════════════════════════════════════════', + '║ The following 2 files in $libraryName have a disallowed import of Cupertino. Refactor them or move them to the Cupertino examples.', + '║ $dartFileOne', + '║ $dartFileTwo', + '╚═══════════════════════════════════════════════════════════════════════════════', + ].join('\n'); + expect(result, equals('$lines\n')); + expect(success, isFalse); + }); + + test('unknown $libraryName cross import of Cupertino in test file', () async { + final testDartFile = '$libraryName/foo_test.dart'; + final Directory examplesFilesDirectory = checkerDirectories.examplesFilesDirectoryFor( + libraryName, + checker.examplesDirectory, + ); + + buildKnownCrossImportExamplesFiles(); + writeImportInFiles( + {testDartFile}, + inDirectory: examplesFilesDirectory, + importString: "import 'package:flutter/cupertino.dart';", + ); + + bool? success; + final String result = await capture(() async { + success = checker.check(); + }, shouldHaveErrors: true); + final String lines = [ + '╔═╡ERROR #1╞════════════════════════════════════════════════════════════════════', + '║ The following file in $libraryName has a disallowed import of Cupertino. Refactor it or move it to the Cupertino examples.', + '║ $testDartFile', + '╚═══════════════════════════════════════════════════════════════════════════════', + ].join('\n'); + expect(result, equals('$lines\n')); + expect(success, isFalse); + }); + + test('files under $libraryName/build are ignored', () async { + buildKnownCrossImportExamplesFiles(); + + final Directory examplesFilesDirectory = checkerDirectories.examplesFilesDirectoryFor( + libraryName, + checker.examplesDirectory, + ); + + final Directory buildDirectory = examplesFilesDirectory.childDirectory('build')..createSync(); + buildDirectory.childFile('foo.dart') + ..createSync() + ..writeAsStringSync("import 'package:flutter/material.dart';"); + + expect(checker.check(), isTrue); + }); + + test('files under $libraryName/.dart_tool are ignored', () async { + buildKnownCrossImportExamplesFiles(); + + final Directory examplesFilesDirectory = checkerDirectories.examplesFilesDirectoryFor( + libraryName, + checker.examplesDirectory, + ); + + final Directory dartToolDirectory = examplesFilesDirectory.childDirectory('.dart_tool') + ..createSync(); + dartToolDirectory.childFile('foo.dart') + ..createSync() + ..writeAsStringSync("import 'package:flutter/material.dart';"); + + expect(checker.check(), isTrue); + }); + + test('files in $libraryName subdirectories are checked', () async { + buildKnownCrossImportExamplesFiles(); + + final Directory examplesFilesDirectory = checkerDirectories.examplesFilesDirectoryFor( + libraryName, + checker.examplesDirectory, + ); + + examplesFilesDirectory.childDirectory('baz').childDirectory('bar').childFile('foo.dart') + ..createSync(recursive: true) + ..writeAsStringSync("import 'package:flutter/material.dart';"); + + bool? success; + final String result = await capture(() async { + success = checker.check(); + }, shouldHaveErrors: true); + + final String lines = [ + '╔═╡ERROR #1╞════════════════════════════════════════════════════════════════════', + '║ The following file in $libraryName has a disallowed import of Material. Refactor it or move it to the Material examples.', + '║ $libraryName/baz/bar/foo.dart', + '╚═══════════════════════════════════════════════════════════════════════════════', + ].join('\n'); + expect(result, equals('$lines\n')); + expect(success, isFalse); + }, skip: isExamplesRoot(libraryName)); // [intended]: The root directory should not have examples + } + + for (final String libraryName in crossImportsExamplesApiTestCases) { + // This flag is defined here, instead of near the `skip:` below, + // due to a formatter bug with comments and named arguments, + // which causes `dev/bots/analyze.dart` to fail, since the skip test comment gets put on the next line. + // TODO(navaronbracke): Remove this when https://github.com/dart-lang/dart_style/pull/1848 rolls into Flutter + final bool noCrossImports = hasNoKnownCrossImports(libraryName); + + test('non-Dart files are ignored in $libraryName', () async { + buildKnownCrossImportExamplesFiles(); + + final Directory directory = getDirectoryForExamplesSlashApiLibrary( + libraryName, + flutterRoot: checker.flutterRoot, + ); + + directory.childFile('README.md') + ..createSync() + ..writeAsStringSync("import 'package:flutter/material.dart';"); + + expect(checker.check(), isTrue); + }); + + test('non-Dart files are ignored in $libraryName subdirectories', () async { + buildKnownCrossImportExamplesFiles(); + + final Directory directory = getDirectoryForExamplesSlashApiLibrary( + libraryName, + flutterRoot: checker.flutterRoot, + ); + + directory.childDirectory('layers').childDirectory('rendering').childFile('README.md') + ..createSync(recursive: true) + ..writeAsStringSync("import 'package:flutter/material.dart';"); + + expect(checker.check(), isTrue); + }); + + test( + 'non-Dart files with .dart in the filename are ignored $libraryName subdirectories', + () async { + buildKnownCrossImportExamplesFiles(); + + final Directory directory = getDirectoryForExamplesSlashApiLibrary( + libraryName, + flutterRoot: checker.flutterRoot, + ); + + directory.childDirectory('layers').childDirectory('rendering').childFile('foo.dart.md') + ..createSync(recursive: true) + ..writeAsStringSync("import 'package:flutter/material.dart';"); + + expect(checker.check(), isTrue); + }, + ); + + test( + 'when not all $libraryName knowns have cross imports', + () async { + final String excludedSample = getFirstCrossImportForLibrary(libraryName); + + buildKnownCrossImportExamplesFiles(excludes: {excludedSample}); + + bool? success; + final String result = await capture(() async { + success = checker.check(); + }, shouldHaveErrors: true); + final String lines = [ + '╔═╡ERROR #1╞════════════════════════════════════════════════════════════════════', + '║ Huzzah! The following files in $libraryName no longer contain cross imports!', + '║ $excludedSample', + '║ However, they now need to be removed from the', + '║ knownExamplesCrossImports list in the script /dev/bots/check_examples_cross_imports.dart.', + '╚═══════════════════════════════════════════════════════════════════════════════', + ].join('\n'); + expect(result, equals('$lines\n')); + expect(success, isFalse); + }, + skip: noCrossImports, // [intended]: Nothing to log if there are no known imports. + ); + + test('unknown $libraryName cross import of Material', () async { + final dartFile = '$libraryName/foo.dart'; + + final Directory examplesFilesDirectory = getDirectoryForExamplesSlashApiLibrary( + libraryName, + flutterRoot: checker.flutterRoot, + ); + + buildKnownCrossImportExamplesFiles(); + writeImportInFiles({dartFile}, inDirectory: examplesFilesDirectory); + + bool? success; + final String result = await capture(() async { + success = checker.check(); + }, shouldHaveErrors: true); + final String lines = [ + '╔═╡ERROR #1╞════════════════════════════════════════════════════════════════════', + '║ The following file in $libraryName has a disallowed import of Material. Refactor it or move it to the Material examples.', + '║ $dartFile', + '╚═══════════════════════════════════════════════════════════════════════════════', + ].join('\n'); + expect(result, equals('$lines\n')); + expect(success, isFalse); + }, skip: isMaterialExample(libraryName)); // [intended]: Material examples can import Material + + test('multiple unknown $libraryName cross imports of Material', () async { + final dartFileOne = '$libraryName/foo.dart'; + final dartFileTwo = '$libraryName/bar.dart'; + + final Directory examplesFilesDirectory = getDirectoryForExamplesSlashApiLibrary( + libraryName, + flutterRoot: checker.flutterRoot, + ); + + buildKnownCrossImportExamplesFiles(); + writeImportInFiles({dartFileOne, dartFileTwo}, inDirectory: examplesFilesDirectory); + + bool? success; + final String result = await capture(() async { + success = checker.check(); + }, shouldHaveErrors: true); + final String lines = [ + '╔═╡ERROR #1╞════════════════════════════════════════════════════════════════════', + '║ The following 2 files in $libraryName have a disallowed import of Material. Refactor them or move them to the Material examples.', + '║ $dartFileOne', + '║ $dartFileTwo', + '╚═══════════════════════════════════════════════════════════════════════════════', + ].join('\n'); + expect(result, equals('$lines\n')); + expect(success, isFalse); + }, skip: isMaterialExample(libraryName)); // [intended]: Material examples can import Material + + test('unknown $libraryName cross import of Material in test file', () async { + final testDartFile = '$libraryName/foo_test.dart'; + + final Directory examplesFilesDirectory = getDirectoryForExamplesSlashApiLibrary( + libraryName, + flutterRoot: checker.flutterRoot, + ); + + buildKnownCrossImportExamplesFiles(); + writeImportInFiles({testDartFile}, inDirectory: examplesFilesDirectory); + + bool? success; + final String result = await capture(() async { + success = checker.check(); + }, shouldHaveErrors: true); + final String lines = [ + '╔═╡ERROR #1╞════════════════════════════════════════════════════════════════════', + '║ The following file in $libraryName has a disallowed import of Material. Refactor it or move it to the Material examples.', + '║ $testDartFile', + '╚═══════════════════════════════════════════════════════════════════════════════', + ].join('\n'); + expect(result, equals('$lines\n')); + expect(success, isFalse); + }, skip: isMaterialExample(libraryName)); // [intended]: Material examples can import Material + + test('unknown $libraryName cross import of Cupertino', () async { + final dartFile = '$libraryName/foo.dart'; + + final Directory examplesFilesDirectory = getDirectoryForExamplesSlashApiLibrary( + libraryName, + flutterRoot: checker.flutterRoot, + ); + + buildKnownCrossImportExamplesFiles(); + writeImportInFiles( + {dartFile}, + inDirectory: examplesFilesDirectory, + importString: "import 'package:flutter/cupertino.dart';", + ); + + bool? success; + final String result = await capture(() async { + success = checker.check(); + }, shouldHaveErrors: true); + final String lines = [ + '╔═╡ERROR #1╞════════════════════════════════════════════════════════════════════', + '║ The following file in $libraryName has a disallowed import of Cupertino. Refactor it or move it to the Cupertino examples.', + '║ $dartFile', + '╚═══════════════════════════════════════════════════════════════════════════════', + ].join('\n'); + expect(result, equals('$lines\n')); + expect(success, isFalse); + }, skip: isCupertinoExample(libraryName)); // [intended]: Cupertino examples can import Cupertino + + test('multiple unknown $libraryName cross imports of Cupertino', () async { + final dartFileOne = '$libraryName/foo.dart'; + final dartFileTwo = '$libraryName/bar.dart'; + + final Directory examplesFilesDirectory = getDirectoryForExamplesSlashApiLibrary( + libraryName, + flutterRoot: checker.flutterRoot, + ); + + buildKnownCrossImportExamplesFiles(); + writeImportInFiles( + {dartFileOne, dartFileTwo}, + inDirectory: examplesFilesDirectory, + importString: "import 'package:flutter/cupertino.dart';", + ); + + bool? success; + final String result = await capture(() async { + success = checker.check(); + }, shouldHaveErrors: true); + final String lines = [ + '╔═╡ERROR #1╞════════════════════════════════════════════════════════════════════', + '║ The following 2 files in $libraryName have a disallowed import of Cupertino. Refactor them or move them to the Cupertino examples.', + '║ $dartFileOne', + '║ $dartFileTwo', + '╚═══════════════════════════════════════════════════════════════════════════════', + ].join('\n'); + expect(result, equals('$lines\n')); + expect(success, isFalse); + }, skip: isCupertinoExample(libraryName)); // [intended]: Cupertino examples can import Cupertino + + test('unknown $libraryName cross import of Cupertino in test file', () async { + final testDartFile = '$libraryName/foo_test.dart'; + + final Directory examplesFilesDirectory = getDirectoryForExamplesSlashApiLibrary( + libraryName, + flutterRoot: checker.flutterRoot, + ); + + buildKnownCrossImportExamplesFiles(); + writeImportInFiles( + {testDartFile}, + inDirectory: examplesFilesDirectory, + importString: "import 'package:flutter/cupertino.dart';", + ); + + bool? success; + final String result = await capture(() async { + success = checker.check(); + }, shouldHaveErrors: true); + final String lines = [ + '╔═╡ERROR #1╞════════════════════════════════════════════════════════════════════', + '║ The following file in $libraryName has a disallowed import of Cupertino. Refactor it or move it to the Cupertino examples.', + '║ $testDartFile', + '╚═══════════════════════════════════════════════════════════════════════════════', + ].join('\n'); + expect(result, equals('$lines\n')); + expect(success, isFalse); + }, skip: isCupertinoExample(libraryName)); // [intended]: Cupertino examples can import Cupertino + + test('files in $libraryName subdirectories are checked', () async { + final dartFile = '$libraryName/baz/bar/foo.dart'; + + final Directory examplesFilesDirectory = getDirectoryForExamplesSlashApiLibrary( + libraryName, + flutterRoot: checker.flutterRoot, + ); + + final LibraryCrossImportStatementType importStatement = + getCrossImportStatementForExamplesLibraryFile(dartFile); + final String disallowedImportName = importStatement.readableName; + + buildKnownCrossImportExamplesFiles(); + writeImportInFiles( + {dartFile}, + inDirectory: examplesFilesDirectory, + importString: importStatement.importString, + ); + + bool? success; + final String result = await capture(() async { + success = checker.check(); + }, shouldHaveErrors: true); + + final String lines = [ + '╔═╡ERROR #1╞════════════════════════════════════════════════════════════════════', + '║ The following file in $libraryName has a disallowed import of $disallowedImportName. Refactor it or move it to the $disallowedImportName examples.', + '║ $dartFile', + '╚═══════════════════════════════════════════════════════════════════════════════', + ].join('\n'); + expect(result, equals('$lines\n')); + expect(success, isFalse); + }); + } + + test('Material API examples in examples/api are ignored explicitly', () async { + // The Material examples in examples/api have been moved to packages/material_ui. + // The original code is still present in examples/api until it can be finally deleted from there. + // For this reason the checker should ignore the Material examples in examples/api, + // even if they contain cross imports. + + buildKnownCrossImportExamplesFiles(); + + const examplesApiMaterialLibraryName = 'examples/api/lib/material'; + const examplesApiMaterialTestLibraryName = 'examples/api/test/material'; + const LibraryCrossImportStatementType importStatement = .cupertino; + + final Directory examplesMaterialLibFilesDirectory = getDirectoryForExamplesSlashApiLibrary( + examplesApiMaterialLibraryName, + flutterRoot: checker.flutterRoot, + ); + + final Directory examplesMaterialTestFilesDirectory = getDirectoryForExamplesSlashApiLibrary( + examplesApiMaterialTestLibraryName, + flutterRoot: checker.flutterRoot, + ); + + writeImportInFiles( + {'examples/api/lib/material/qux.dart', 'examples/api/lib/material/baz/foo.dart'}, + inDirectory: examplesMaterialLibFilesDirectory, + importString: importStatement.importString, + ); + + writeImportInFiles( + {'examples/api/test/material/qux_test.dart', 'examples/api/test/material/baz/foo_test.dart'}, + inDirectory: examplesMaterialTestFilesDirectory, + importString: importStatement.importString, + ); + + bool? success; + final String result = await capture(() async { + success = checker.check(); + }); + expect(result, equals('')); + expect(success, isTrue); + }); + + test('Cupertino API examples in examples/api are ignored explicitly', () async { + // The Cupertino examples in examples/api have been moved to packages/cupertino_ui. + // The original code is still present in examples/api until it can be finally deleted from there. + // For this reason the checker should ignore the Cupertino examples in examples/api, + // even if they contain cross imports. + + buildKnownCrossImportExamplesFiles(); + + const examplesApiCupertinoLibraryName = 'examples/api/lib/cupertino'; + const examplesApiCupertinoTestLibraryName = 'examples/api/test/cupertino'; + const LibraryCrossImportStatementType importStatement = .material; + + final Directory examplesCupertinoLibFilesDirectory = getDirectoryForExamplesSlashApiLibrary( + examplesApiCupertinoLibraryName, + flutterRoot: checker.flutterRoot, + ); + + final Directory examplesCupertinoTestFilesDirectory = getDirectoryForExamplesSlashApiLibrary( + examplesApiCupertinoTestLibraryName, + flutterRoot: checker.flutterRoot, + ); + + writeImportInFiles( + {'examples/api/lib/cupertino/qux.dart', 'examples/api/lib/cupertino/baz/foo.dart'}, + inDirectory: examplesCupertinoLibFilesDirectory, + importString: importStatement.importString, + ); + + writeImportInFiles( + { + 'examples/api/test/cupertino/qux_test.dart', + 'examples/api/test/cupertino/baz/foo_test.dart', + }, + inDirectory: examplesCupertinoTestFilesDirectory, + importString: importStatement.importString, + ); + + bool? success; + final String result = await capture(() async { + success = checker.check(); + }); + expect(result, equals('')); + expect(success, isTrue); + }); +} + +/// Get a [LibraryCrossImportStatementType], for the file at the given [filePath], +/// that will result in the [ExamplesCrossImportChecker] flagging the file as having disallowed cross imports. +/// +/// Returns [LibraryCrossImportStatementType.cupertino] if the file is in the Material examples, +/// and [LibraryCrossImportStatementType.material] if the file is in the Cupertino examples. +/// +/// Returns [LibraryCrossImportStatementType.material] for any other library, +/// such as `examples/layers/rendering/spinning_square.dart`. +LibraryCrossImportStatementType getCrossImportStatementForExamplesLibraryFile(String filePath) { + if (filePath.startsWith('examples/api/lib/material/') || + filePath.startsWith('examples/api/test/material/')) { + return LibraryCrossImportStatementType.cupertino; + } + + if (filePath.startsWith('examples/api/lib/cupertino/') || + filePath.startsWith('examples/api/test/cupertino/')) { + return LibraryCrossImportStatementType.material; + } + + return LibraryCrossImportStatementType.material; +} + +/// Get the directory for the given `examples/api` [libraryName]. +/// +/// The library name can only contain lowercase a-z and underscores +/// and must start with either `examples/api/lib` or `examples/api/test`. +Directory getDirectoryForExamplesSlashApiLibrary( + String libraryName, { + required Directory flutterRoot, +}) { + if (!_kExamplesSlashApiLibraryPattern.hasMatch(libraryName)) { + throw ArgumentError('Invalid library name: $libraryName', 'libraryName'); + } + + final String fullPath = path.joinAll([flutterRoot.path, ...libraryName.split('/')]); + + return flutterRoot.fileSystem.directory(fullPath); +} + +/// Get the first known cross import for the given [libraryName]. +/// +/// Throws a [StateError] if there are no known cross imports for the given library. +String getFirstCrossImportForLibrary(String libraryName) { + // Use the first entry that belongs to this library, since known imports sets + // are shared between lib/ and test/ directories and may contain paths for both. + return ExamplesCrossImportChecker.knownExamplesCrossImports.firstWhere( + (String p) => p.startsWith('$libraryName/'), + ); +} + +/// Returns whether there are no known cross imports for the given [libraryName]. +bool hasNoKnownCrossImports(String libraryName) { + return !ExamplesCrossImportChecker.knownExamplesCrossImports.any( + (String entry) => entry.startsWith('$libraryName/'), + ); +} + +/// Returns whether the given [libraryName] matches the Material examples under `examples/api`. +bool isMaterialExample(String libraryName) { + return libraryName == 'examples/api/lib/material' || libraryName == 'examples/api/test/material'; +} + +/// Returns whether the given [libraryName] matches the Cupertino examples under `examples/api`. +bool isCupertinoExample(String libraryName) { + return libraryName == 'examples/api/lib/cupertino' || + libraryName == 'examples/api/test/cupertino'; +} + +/// Returns whether the given [libraryName] matches the root `examples` or `examples/api` directories, +/// which contain subdirectories with examples, but should themselves be void of examples. +bool isExamplesRoot(String libraryName) { + return libraryName == 'examples' || libraryName == 'examples/api'; +} + +// A utility that keeps track of the directories under test, +// to avoid having to late initialize them individually in `setUp()`. +class _CrossImportsExamplesDirectories { + factory _CrossImportsExamplesDirectories(Directory examplesDirectory) { + return _CrossImportsExamplesDirectories._( + examplesSlashApiDirectory: examplesDirectory.childDirectory('api'), + examplesFlutterViewDirectory: examplesDirectory.childDirectory('flutter_view'), + examplesHelloWorldDirectory: examplesDirectory.childDirectory('hello_world'), + examplesImageListDirectory: examplesDirectory.childDirectory('image_list'), + examplesLayersDirectory: examplesDirectory.childDirectory('layers'), + examplesMultipleWindowsDirectory: examplesDirectory.childDirectory('multiple_windows'), + examplesPlatformChannelDirectory: examplesDirectory.childDirectory('platform_channel'), + examplesPlatformChannelSwiftDirectory: examplesDirectory.childDirectory( + 'platform_channel_swift', + ), + examplesPlatformViewDirectory: examplesDirectory.childDirectory('platform_view'), + examplesSplashDirectory: examplesDirectory.childDirectory('splash'), + examplesTextureDirectory: examplesDirectory.childDirectory('texture'), + ); + } + + const _CrossImportsExamplesDirectories._({ + required this.examplesSlashApiDirectory, + required this.examplesFlutterViewDirectory, + required this.examplesHelloWorldDirectory, + required this.examplesImageListDirectory, + required this.examplesLayersDirectory, + required this.examplesMultipleWindowsDirectory, + required this.examplesPlatformChannelDirectory, + required this.examplesPlatformChannelSwiftDirectory, + required this.examplesPlatformViewDirectory, + required this.examplesSplashDirectory, + required this.examplesTextureDirectory, + }); + + final Directory examplesSlashApiDirectory; + final Directory examplesFlutterViewDirectory; + final Directory examplesHelloWorldDirectory; + final Directory examplesImageListDirectory; + final Directory examplesLayersDirectory; + final Directory examplesMultipleWindowsDirectory; + final Directory examplesPlatformChannelDirectory; + final Directory examplesPlatformChannelSwiftDirectory; + final Directory examplesPlatformViewDirectory; + final Directory examplesSplashDirectory; + final Directory examplesTextureDirectory; + + /// A mapping of `examples/xyz` directories + /// to their corresponding known imports list in `check_examples_cross_imports.dart` + Map> getKnownFiles(Directory examplesDirectory) { + final Directory libDirectory = examplesSlashApiDirectory.childDirectory('lib'); + final Directory testDirectory = examplesSlashApiDirectory.childDirectory('test'); + final Map> exampleSlashApiSubdirectoryMapping = {}; + + for (final directory in [libDirectory, testDirectory]) { + exampleSlashApiSubdirectoryMapping[directory.childDirectory('animation')] = + ExamplesCrossImportChecker.knownExamplesCrossImports; + exampleSlashApiSubdirectoryMapping[directory.childDirectory('foundation')] = + ExamplesCrossImportChecker.knownExamplesCrossImports; + exampleSlashApiSubdirectoryMapping[directory.childDirectory('gestures')] = + ExamplesCrossImportChecker.knownExamplesCrossImports; + exampleSlashApiSubdirectoryMapping[directory.childDirectory('painting')] = + ExamplesCrossImportChecker.knownExamplesCrossImports; + exampleSlashApiSubdirectoryMapping[directory.childDirectory('rendering')] = + ExamplesCrossImportChecker.knownExamplesCrossImports; + exampleSlashApiSubdirectoryMapping[directory.childDirectory('sample_templates')] = + ExamplesCrossImportChecker.knownExamplesCrossImports; + exampleSlashApiSubdirectoryMapping[directory.childDirectory('services')] = + ExamplesCrossImportChecker.knownExamplesCrossImports; + exampleSlashApiSubdirectoryMapping[directory.childDirectory('ui')] = + ExamplesCrossImportChecker.knownExamplesCrossImports; + exampleSlashApiSubdirectoryMapping[directory.childDirectory('widgets')] = + ExamplesCrossImportChecker.knownExamplesCrossImports; + } + + return >{ + examplesDirectory: ExamplesCrossImportChecker.knownExamplesCrossImports, + examplesSlashApiDirectory: ExamplesCrossImportChecker.knownExamplesCrossImports, + ...exampleSlashApiSubdirectoryMapping, + examplesFlutterViewDirectory: ExamplesCrossImportChecker.knownExamplesCrossImports, + examplesHelloWorldDirectory: ExamplesCrossImportChecker.knownExamplesCrossImports, + examplesImageListDirectory: ExamplesCrossImportChecker.knownExamplesCrossImports, + examplesLayersDirectory: ExamplesCrossImportChecker.knownExamplesCrossImports, + examplesMultipleWindowsDirectory: ExamplesCrossImportChecker.knownExamplesCrossImports, + examplesPlatformChannelDirectory: ExamplesCrossImportChecker.knownExamplesCrossImports, + examplesPlatformChannelSwiftDirectory: ExamplesCrossImportChecker.knownExamplesCrossImports, + examplesPlatformViewDirectory: ExamplesCrossImportChecker.knownExamplesCrossImports, + examplesSplashDirectory: ExamplesCrossImportChecker.knownExamplesCrossImports, + examplesTextureDirectory: ExamplesCrossImportChecker.knownExamplesCrossImports, + }; + } + + /// Create the `examples/xyz` directories for the test cases, excluding `examples/api` subdirectories. + void createExamplesDirectories(Directory examplesDirectory) { + final Map> knownFiles = getKnownFiles(examplesDirectory); + + for (final Directory directory in knownFiles.keys) { + // The `examples` directory is created in `setUp()`. + if (directory == examplesDirectory) { + continue; + } + + directory.createSync(recursive: true); + } + } + + /// Get the examples directory for the given [libraryName]. + Directory examplesFilesDirectoryFor(String libraryName, Directory examplesDirectory) { + const unsupportedPrefix = 'examples/api'; + + if (libraryName.startsWith(unsupportedPrefix) && + libraryName.length > unsupportedPrefix.length) { + throw ArgumentError( + 'For $libraryName, use getDirectoryForExamplesSlashApiLibrary(libraryName) instead, ' + 'which supports getting directories for examples/api lib and test subdirectories.', + ); + } + + return switch (libraryName) { + 'examples' => examplesDirectory, + 'examples/api' => examplesSlashApiDirectory, + 'examples/flutter_view' => examplesFlutterViewDirectory, + 'examples/hello_world' => examplesHelloWorldDirectory, + 'examples/image_list' => examplesImageListDirectory, + 'examples/layers' => examplesLayersDirectory, + 'examples/multiple_windows' => examplesMultipleWindowsDirectory, + 'examples/platform_channel' => examplesPlatformChannelDirectory, + 'examples/platform_channel_swift' => examplesPlatformChannelSwiftDirectory, + 'examples/platform_view' => examplesPlatformViewDirectory, + 'examples/splash' => examplesSplashDirectory, + 'examples/texture' => examplesTextureDirectory, + _ => throw ArgumentError('Unknown library name: $libraryName'), + }; + } +} + +// A mapping of `examples/**` test cases for the cross imports checker, excluding `examples/api/**`. +const crossImportsGenericExamplesTestCases = [ + 'examples', + 'examples/api', + 'examples/flutter_view', + 'examples/hello_world', + 'examples/image_list', + 'examples/layers', + 'examples/multiple_windows', + 'examples/platform_channel', + 'examples/platform_channel_swift', + 'examples/platform_view', + 'examples/splash', + 'examples/texture', +]; + +// A mapping of `examples/api/lib/**` and `examples/api/test/**` test cases for the cross imports checker, +// excluding `examples/api/lib/sample_templates` and `examples/api/test/sample_templates`. +const crossImportsExamplesApiTestCases = [ + 'examples/api/lib/animation', + 'examples/api/lib/foundation', + 'examples/api/lib/gestures', + 'examples/api/lib/painting', + 'examples/api/lib/rendering', + 'examples/api/lib/services', + 'examples/api/lib/ui', + 'examples/api/lib/widgets', + 'examples/api/test/animation', + 'examples/api/test/foundation', + 'examples/api/test/gestures', + 'examples/api/test/painting', + 'examples/api/test/rendering', + 'examples/api/test/services', + 'examples/api/test/ui', + 'examples/api/test/widgets', +]; diff --git a/dev/bots/test/check_tests_cross_imports_test.dart b/dev/bots/test/check_tests_cross_imports_test.dart index fe5a1ca4c7476..a6d1c287230bd 100644 --- a/dev/bots/test/check_tests_cross_imports_test.dart +++ b/dev/bots/test/check_tests_cross_imports_test.dart @@ -9,8 +9,8 @@ import 'package:file/memory.dart'; import 'package:path/path.dart' as path; import '../check_tests_cross_imports.dart'; -import '../utils.dart'; import 'common.dart'; +import 'cross_imports_checker_test_utils.dart'; void main() { late TestsCrossImportChecker checker; @@ -136,7 +136,7 @@ void main() { }, shouldHaveErrors: true); final String lines = [ '╔═╡ERROR #1╞════════════════════════════════════════════════════════════════════', - '║ Huzzah! The following tests in $libraryName no longer contain cross imports!', + '║ Huzzah! The following files in $libraryName no longer contain cross imports!', '║ $excludedSample', '║ However, they now need to be removed from the', '║ $knownCrossImportsListName list in the script /dev/bots/check_tests_cross_imports.dart.', @@ -332,79 +332,9 @@ void main() { } } -typedef AsyncVoidCallback = Future Function(); - -Future capture(AsyncVoidCallback callback, {bool shouldHaveErrors = false}) async { - final buffer = StringBuffer(); - final PrintCallback oldPrint = print; - try { - print = (Object? line) { - buffer.writeln(line); - }; - await callback(); - expect( - hasError, - shouldHaveErrors, - reason: buffer.isEmpty - ? '(No output to report.)' - : hasError - ? 'Unexpected errors:\n$buffer' - : 'Unexpected success:\n$buffer', - ); - } finally { - print = oldPrint; - resetErrorStatus(); - } - if (stdout.supportsAnsiEscapes) { - // Remove ANSI escapes when this test is running on a terminal. - return buffer.toString().replaceAll(RegExp(r'(\x9B|\x1B\[)[0-?]{1,3}[ -/]*[@-~]'), ''); - } else { - return buffer.toString(); - } -} - /// Returns whether the given [libraryName] matches the Cupertino library under `flutter/test`. bool isCupertino(String libraryName) => libraryName == 'packages/flutter/test/cupertino'; -File getFile(String filepath, Directory directory) { - final String platformFilepath = filepath.replaceAll('/', Platform.pathSeparator); - final String searchPattern = directory.basename + Platform.pathSeparator; - // Don't use `lastIndexOf`, as for files in test fixes - // i.e. `packages/flutter_test/test_fixes/flutter_test/matchers.dart` - // the overlap index could appear multiple times. - // Only take the first one. - final int overlapIndex = platformFilepath.indexOf(searchPattern); - - if (overlapIndex < 0) { - throw ArgumentError('filepath $filepath must be located in directory ${directory.path}.'); - } - - final String filename = platformFilepath.substring(overlapIndex + searchPattern.length); - return directory.childFile(filename); -} - -/// Writes [importString] into the given file. -/// -/// The default [importString] is `import 'package:flutter/material.dart';`. -void writeImport(File file, [String importString = "import 'package:flutter/material.dart';"]) { - file - ..createSync(recursive: true) - ..writeAsStringSync(importString); -} - -/// Writes [importString] into the given [filePaths] in [inDirectory]. -/// -/// The default [importString] is `import 'package:flutter/material.dart';`. -void writeImportInFiles( - Iterable filePaths, { - required Directory inDirectory, - String importString = "import 'package:flutter/material.dart';", -}) { - for (final filepath in filePaths) { - writeImport(getFile(filepath, inDirectory), importString); - } -} - // A utility that keeps track of the directories under test, // to avoid having to late initialize them individually in `setUp()`. class _CrossImportsTestDirectories { diff --git a/dev/bots/test/cross_imports_checker_test_utils.dart b/dev/bots/test/cross_imports_checker_test_utils.dart new file mode 100644 index 0000000000000..f6644cca98aaf --- /dev/null +++ b/dev/bots/test/cross_imports_checker_test_utils.dart @@ -0,0 +1,88 @@ +// 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:io'; + +import 'package:file/file.dart'; + +import '../utils.dart'; +import 'common.dart'; + +typedef AsyncVoidCallback = Future Function(); + +/// Capture the output of calling [callback] +/// and validate if it emitted any formatted errors. +/// +/// This function will fail the current test if either of the following is true: +/// - [shouldHaveErrors] is true and no errors were reported +/// - [shouldHaveErrors] is false and any errors were reported +/// +/// Returns the captured output. +Future capture(AsyncVoidCallback callback, {bool shouldHaveErrors = false}) async { + final buffer = StringBuffer(); + final PrintCallback oldPrint = print; + try { + print = (Object? line) { + buffer.writeln(line); + }; + await callback(); + expect( + hasError, + shouldHaveErrors, + reason: buffer.isEmpty + ? '(No output to report.)' + : hasError + ? 'Unexpected errors:\n$buffer' + : 'Unexpected success:\n$buffer', + ); + } finally { + print = oldPrint; + resetErrorStatus(); + } + if (stdout.supportsAnsiEscapes) { + // Remove ANSI escapes when this test is running on a terminal. + return buffer.toString().replaceAll(RegExp(r'(\x9B|\x1B\[)[0-?]{1,3}[ -/]*[@-~]'), ''); + } else { + return buffer.toString(); + } +} + +File getFile(String filepath, Directory directory) { + final String platformFilepath = filepath.replaceAll('/', Platform.pathSeparator); + final String searchPattern = directory.basename + Platform.pathSeparator; + // Don't use `lastIndexOf`, as for files in test fixes + // i.e. `packages/flutter_test/test_fixes/flutter_test/matchers.dart` + // the overlap index could appear multiple times. + // Only take the first one. + final int overlapIndex = platformFilepath.indexOf(searchPattern); + + if (overlapIndex < 0) { + throw ArgumentError('filepath $filepath must be located in directory ${directory.path}.'); + } + + final String filename = platformFilepath.substring(overlapIndex + searchPattern.length); + return directory.childFile(filename); +} + +/// Writes [importString] into the given file. +/// +/// The default [importString] is `import 'package:flutter/material.dart';`. +void writeImport(File file, [String importString = "import 'package:flutter/material.dart';"]) { + file + ..createSync(recursive: true) + ..writeAsStringSync(importString); +} + +/// Writes [importString] into the given [filePaths] in [inDirectory]. +/// +/// The default [importString] is `import 'package:flutter/material.dart';`. +void writeImportInFiles( + Iterable filePaths, { + required Directory inDirectory, + String importString = "import 'package:flutter/material.dart';", +}) { + for (final filepath in filePaths) { + writeImport(getFile(filepath, inDirectory), importString); + } +} diff --git a/dev/devicelab/bin/tasks/module_uiscene_test_ios.dart b/dev/devicelab/bin/tasks/module_uiscene_test_ios.dart index 1d32fc35cd41c..4d2621ea919f9 100644 --- a/dev/devicelab/bin/tasks/module_uiscene_test_ios.dart +++ b/dev/devicelab/bin/tasks/module_uiscene_test_ios.dart @@ -40,7 +40,7 @@ Future main(List args) async { var destinationOverride = false; if (destination != null) { destinationOverride = true; - destinationDir = Directory(destination); + destinationDir = Directory(path.join(destination, 'flutter_uiscene_test_generated_project')); if (destinationDir.existsSync()) { destinationDir.deleteSync(recursive: true); } diff --git a/dev/integration_tests/android_engine_test/android/gradle/wrapper/gradle-wrapper.properties b/dev/integration_tests/android_engine_test/android/gradle/wrapper/gradle-wrapper.properties index a20f2c46d22ec..bdc0141f5311c 100644 --- a/dev/integration_tests/android_engine_test/android/gradle/wrapper/gradle-wrapper.properties +++ b/dev/integration_tests/android_engine_test/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip diff --git a/dev/integration_tests/android_hardware_smoke_test/test_driver/driver_test.dart b/dev/integration_tests/android_hardware_smoke_test/test_driver/driver_test.dart index ffa137cfeb1fb..69bfae78532e7 100644 --- a/dev/integration_tests/android_hardware_smoke_test/test_driver/driver_test.dart +++ b/dev/integration_tests/android_hardware_smoke_test/test_driver/driver_test.dart @@ -29,13 +29,20 @@ void main() async { final String response = await flutterDriver.requestData( json.encode({keyCommand: commandGetGoldenVariant}), ); - final Map reply = (json.decode(response) as Map) - .cast(); + final Map reply = + (json.decode(response) as Map) + .cast(); final replyVariant = reply[keyGoldenVariant] as String?; - activeGoldenVariant = (replyVariant != null && replyVariant.isNotEmpty) ? '.$replyVariant' : ''; + activeGoldenVariant = switch (replyVariant) { + final String s when s.isNotEmpty => '.$s', + _ => '', + }; if (isLuci) { - await enableSkiaGoldComparator(namePrefix: 'android_hardware_smoke_test$activeGoldenVariant'); + await enableSkiaGoldComparator( + namePrefix: 'android_hardware_smoke_test$activeGoldenVariant', + localOutputDir: 'goldens', + ); } }); @@ -47,12 +54,16 @@ void main() async { Future templateTest(String testName) async { // Ask the app to render the test and return the rendered image bytes final String response = await flutterDriver.requestData( - json.encode({keyTestName: testName, keyPerformAppSideGoldenCompare: false}), + json.encode({ + keyTestName: testName, + keyPerformAppSideGoldenCompare: false, + }), ); // Expect a successful reply or skip status - final Map reply = (json.decode(response) as Map) - .cast(); + final Map reply = + (json.decode(response) as Map) + .cast(); if (reply[keyMessage] == 'Skipped') { markTestSkipped('Skipping $testName: ${reply[keyReason]}'); @@ -74,14 +85,27 @@ void main() async { final img.Image? decoded = img.decodePng(fullBytes); if (decoded == null) { - throw StateError('Failed to decode full screen screenshot for $testName'); + throw StateError( + 'Failed to decode full screen screenshot for $testName', + ); } - if (x < 0 || y < 0 || w <= 0 || h <= 0 || x + w > decoded.width || y + h > decoded.height) { + if (x < 0 || + y < 0 || + w <= 0 || + h <= 0 || + x + w > decoded.width || + y + h > decoded.height) { throw StateError( 'Crop bounds out of range for $testName: x=$x, y=$y, w=$w, h=$h, image.width=${decoded.width}, image.height=${decoded.height}', ); } - final img.Image cropped = img.copyCrop(decoded, x: x, y: y, width: w, height: h); + final img.Image cropped = img.copyCrop( + decoded, + x: x, + y: y, + width: w, + height: h, + ); imageBytes = Uint8List.fromList(img.encodePng(cropped)); } else { final imageBase64 = reply[keyImageBytes]! as String; @@ -89,7 +113,10 @@ void main() async { } // Compare the bytes to a golden file on the host filesystem using the cached variant - await expectLater(imageBytes, matchesGoldenFile('goldens/$testName$activeGoldenVariant.png')); + await expectLater( + imageBytes, + matchesGoldenFile('goldens/$testName$activeGoldenVariant.png'), + ); } test('should render and match blueRectangleTest golden', () async { @@ -116,15 +143,27 @@ void main() async { await templateTest('backdropFilterBlurTest'); }, timeout: Timeout.none); - test('should render and match $kPlatformViewTextureLayerTest golden', () async { - await templateTest(kPlatformViewTextureLayerTest); - }, timeout: Timeout.none); - - test('should render and match $kPlatformViewHybridCompositionTest golden', () async { - await templateTest(kPlatformViewHybridCompositionTest); - }, timeout: Timeout.none); - - test('should render and match $kPlatformViewHybridCompositionPlusPlusTest golden', () async { - await templateTest(kPlatformViewHybridCompositionPlusPlusTest); - }, timeout: Timeout.none); + test( + 'should render and match $kPlatformViewTextureLayerTest golden', + () async { + await templateTest(kPlatformViewTextureLayerTest); + }, + timeout: Timeout.none, + ); + + test( + 'should render and match $kPlatformViewHybridCompositionTest golden', + () async { + await templateTest(kPlatformViewHybridCompositionTest); + }, + timeout: Timeout.none, + ); + + test( + 'should render and match $kPlatformViewHybridCompositionPlusPlusTest golden', + () async { + await templateTest(kPlatformViewHybridCompositionPlusPlusTest); + }, + timeout: Timeout.none, + ); } diff --git a/dev/integration_tests/android_semantics_testing/android/gradle/wrapper/gradle-wrapper.properties b/dev/integration_tests/android_semantics_testing/android/gradle/wrapper/gradle-wrapper.properties index a20f2c46d22ec..bdc0141f5311c 100644 --- a/dev/integration_tests/android_semantics_testing/android/gradle/wrapper/gradle-wrapper.properties +++ b/dev/integration_tests/android_semantics_testing/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip diff --git a/dev/integration_tests/android_verified_input/android/gradle/wrapper/gradle-wrapper.properties b/dev/integration_tests/android_verified_input/android/gradle/wrapper/gradle-wrapper.properties index a20f2c46d22ec..bdc0141f5311c 100644 --- a/dev/integration_tests/android_verified_input/android/gradle/wrapper/gradle-wrapper.properties +++ b/dev/integration_tests/android_verified_input/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip diff --git a/dev/integration_tests/android_views/android/gradle/wrapper/gradle-wrapper.properties b/dev/integration_tests/android_views/android/gradle/wrapper/gradle-wrapper.properties index a20f2c46d22ec..bdc0141f5311c 100644 --- a/dev/integration_tests/android_views/android/gradle/wrapper/gradle-wrapper.properties +++ b/dev/integration_tests/android_views/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip diff --git a/dev/integration_tests/channels/android/gradle/wrapper/gradle-wrapper.properties b/dev/integration_tests/channels/android/gradle/wrapper/gradle-wrapper.properties index a20f2c46d22ec..bdc0141f5311c 100644 --- a/dev/integration_tests/channels/android/gradle/wrapper/gradle-wrapper.properties +++ b/dev/integration_tests/channels/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip diff --git a/dev/integration_tests/deferred_components_test/android/gradle/wrapper/gradle-wrapper.properties b/dev/integration_tests/deferred_components_test/android/gradle/wrapper/gradle-wrapper.properties index 0c65c6543841e..2f2958b923a0a 100644 --- a/dev/integration_tests/deferred_components_test/android/gradle/wrapper/gradle-wrapper.properties +++ b/dev/integration_tests/deferred_components_test/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/dev/integration_tests/display_cutout_rotation/android/gradle/wrapper/gradle-wrapper.properties b/dev/integration_tests/display_cutout_rotation/android/gradle/wrapper/gradle-wrapper.properties index a20f2c46d22ec..bdc0141f5311c 100644 --- a/dev/integration_tests/display_cutout_rotation/android/gradle/wrapper/gradle-wrapper.properties +++ b/dev/integration_tests/display_cutout_rotation/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip diff --git a/dev/integration_tests/external_textures/android/gradle/wrapper/gradle-wrapper.properties b/dev/integration_tests/external_textures/android/gradle/wrapper/gradle-wrapper.properties index 0c65c6543841e..2f2958b923a0a 100644 --- a/dev/integration_tests/external_textures/android/gradle/wrapper/gradle-wrapper.properties +++ b/dev/integration_tests/external_textures/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/dev/integration_tests/flavors/android/gradle/wrapper/gradle-wrapper.properties b/dev/integration_tests/flavors/android/gradle/wrapper/gradle-wrapper.properties index a20f2c46d22ec..bdc0141f5311c 100644 --- a/dev/integration_tests/flavors/android/gradle/wrapper/gradle-wrapper.properties +++ b/dev/integration_tests/flavors/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip diff --git a/dev/integration_tests/flutter_gallery/android/gradle/wrapper/gradle-wrapper.properties b/dev/integration_tests/flutter_gallery/android/gradle/wrapper/gradle-wrapper.properties index a20f2c46d22ec..bdc0141f5311c 100644 --- a/dev/integration_tests/flutter_gallery/android/gradle/wrapper/gradle-wrapper.properties +++ b/dev/integration_tests/flutter_gallery/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip diff --git a/dev/integration_tests/hybrid_android_views/android/gradle/wrapper/gradle-wrapper.properties b/dev/integration_tests/hybrid_android_views/android/gradle/wrapper/gradle-wrapper.properties index a20f2c46d22ec..bdc0141f5311c 100644 --- a/dev/integration_tests/hybrid_android_views/android/gradle/wrapper/gradle-wrapper.properties +++ b/dev/integration_tests/hybrid_android_views/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip diff --git a/dev/integration_tests/platform_interaction/android/gradle/wrapper/gradle-wrapper.properties b/dev/integration_tests/platform_interaction/android/gradle/wrapper/gradle-wrapper.properties index a20f2c46d22ec..bdc0141f5311c 100644 --- a/dev/integration_tests/platform_interaction/android/gradle/wrapper/gradle-wrapper.properties +++ b/dev/integration_tests/platform_interaction/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip diff --git a/dev/integration_tests/pure_android_host_apps/android_host_app_v2_embedding/gradle/wrapper/gradle-wrapper.properties b/dev/integration_tests/pure_android_host_apps/android_host_app_v2_embedding/gradle/wrapper/gradle-wrapper.properties index 56a30819350e2..3638a6c4d391e 100644 --- a/dev/integration_tests/pure_android_host_apps/android_host_app_v2_embedding/gradle/wrapper/gradle-wrapper.properties +++ b/dev/integration_tests/pure_android_host_apps/android_host_app_v2_embedding/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-REPLACEME-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-REPLACEME-bin.zip diff --git a/dev/integration_tests/record_use_test_app/android/gradle/wrapper/gradle-wrapper.properties b/dev/integration_tests/record_use_test_app/android/gradle/wrapper/gradle-wrapper.properties index a20f2c46d22ec..bdc0141f5311c 100644 --- a/dev/integration_tests/record_use_test_app/android/gradle/wrapper/gradle-wrapper.properties +++ b/dev/integration_tests/record_use_test_app/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip diff --git a/dev/integration_tests/release_smoke_test/android/gradle/wrapper/gradle-wrapper.properties b/dev/integration_tests/release_smoke_test/android/gradle/wrapper/gradle-wrapper.properties index a20f2c46d22ec..bdc0141f5311c 100644 --- a/dev/integration_tests/release_smoke_test/android/gradle/wrapper/gradle-wrapper.properties +++ b/dev/integration_tests/release_smoke_test/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip diff --git a/dev/integration_tests/spell_check/android/gradle/wrapper/gradle-wrapper.properties b/dev/integration_tests/spell_check/android/gradle/wrapper/gradle-wrapper.properties index a20f2c46d22ec..bdc0141f5311c 100644 --- a/dev/integration_tests/spell_check/android/gradle/wrapper/gradle-wrapper.properties +++ b/dev/integration_tests/spell_check/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip diff --git a/dev/integration_tests/ui/android/gradle/wrapper/gradle-wrapper.properties b/dev/integration_tests/ui/android/gradle/wrapper/gradle-wrapper.properties index a20f2c46d22ec..bdc0141f5311c 100644 --- a/dev/integration_tests/ui/android/gradle/wrapper/gradle-wrapper.properties +++ b/dev/integration_tests/ui/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip diff --git a/dev/integration_tests/web_e2e_tests/lib/deferred_loading_lib.dart b/dev/integration_tests/web_e2e_tests/lib/deferred_loading_lib.dart new file mode 100644 index 0000000000000..703fc12362a12 --- /dev/null +++ b/dev/integration_tests/web_e2e_tests/lib/deferred_loading_lib.dart @@ -0,0 +1,7 @@ +// 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. + +int getDeferredValue() { + return int.parse('42'); +} diff --git a/dev/integration_tests/web_e2e_tests/test_driver/deferred_loading_integration.dart b/dev/integration_tests/web_e2e_tests/test_driver/deferred_loading_integration.dart new file mode 100644 index 0000000000000..8628a6ce84186 --- /dev/null +++ b/dev/integration_tests/web_e2e_tests/test_driver/deferred_loading_integration.dart @@ -0,0 +1,25 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'package:web_e2e_tests/deferred_loading_lib.dart' deferred as deferred_lib; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('deferred library loading works on web', (WidgetTester tester) async { + runApp(const MaterialApp(home: Scaffold(body: Text('Initial State')))); + await tester.pumpAndSettle(); + + // Load the deferred library and call code within it. + await deferred_lib.loadLibrary(); + + final int value = deferred_lib.getDeferredValue(); + expect(value, equals(42)); + await tester.pumpAndSettle(); + }); +} diff --git a/dev/integration_tests/web_e2e_tests/test_driver/deferred_loading_integration_test.dart b/dev/integration_tests/web_e2e_tests/test_driver/deferred_loading_integration_test.dart new file mode 100644 index 0000000000000..b2d2a1770b2fd --- /dev/null +++ b/dev/integration_tests/web_e2e_tests/test_driver/deferred_loading_integration_test.dart @@ -0,0 +1,7 @@ +// 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:integration_test/integration_test_driver.dart' as test; + +Future main() async => test.integrationDriver(); diff --git a/dev/integration_tests/windowing_test/lib/main.dart b/dev/integration_tests/windowing_test/lib/main.dart index 341c45e462c1d..98e43468db478 100644 --- a/dev/integration_tests/windowing_test/lib/main.dart +++ b/dev/integration_tests/windowing_test/lib/main.dart @@ -14,7 +14,7 @@ import 'package:flutter/src/widgets/_window.dart'; import 'package:flutter/src/widgets/_window_positioner.dart'; import 'package:flutter_driver/driver_extension.dart'; -late final RegularWindowController controller; +late final WindowController controller; final ValueNotifier dialogController = ValueNotifier(null); /// A generic "secondary" window used by the `isDestroyed` end-to-end tests. @@ -180,7 +180,7 @@ void main() { try { switch (windowType) { case 'regular': - secondaryController = RegularWindowController( + secondaryController = WindowController( size: const Size(200, 200), title: 'Secondary', ); @@ -228,13 +228,13 @@ void main() { } }, ); - controller = RegularWindowController( + controller = WindowController( size: const Size(640, 480), title: 'Integration Test', - delegate: RegularWindowControllerDelegate(), + delegate: WindowControllerDelegate(), ); - runWidget(RegularWindow(controller: controller, child: const MyApp())); + runWidget(Window(controller: controller, child: const MyApp())); windowCreated.complete(); } diff --git a/dev/manual_tests/android/gradle/wrapper/gradle-wrapper.properties b/dev/manual_tests/android/gradle/wrapper/gradle-wrapper.properties index a20f2c46d22ec..bdc0141f5311c 100644 --- a/dev/manual_tests/android/gradle/wrapper/gradle-wrapper.properties +++ b/dev/manual_tests/android/gradle/wrapper/gradle-wrapper.properties @@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip diff --git a/dev/tools/android_driver_extensions/lib/skia_gold.dart b/dev/tools/android_driver_extensions/lib/skia_gold.dart index a326891b0f5c9..909fd2563a9f4 100644 --- a/dev/tools/android_driver_extensions/lib/skia_gold.dart +++ b/dev/tools/android_driver_extensions/lib/skia_gold.dart @@ -35,7 +35,11 @@ const String _kGoldctlPresubmitKey = 'GOLD_TRYJOB'; /// for more information. /// /// May optionally provide a [namePrefix] to be used when uploading images. -Future enableSkiaGoldComparator({String? namePrefix}) async { +/// +/// If [localOutputDir] is provided, local screenshots downloaded from Skia Gold +/// will be stored flat in this directory relative to the script execution path +/// (nested subdirectories in golden file URIs are not currently supported). +Future enableSkiaGoldComparator({String? namePrefix, String? localOutputDir}) async { assert( goldenFileComparator is NaiveLocalFileComparator, 'The flutter_goldens_fork library should be used from a *_test.dart file ' @@ -75,31 +79,44 @@ Future enableSkiaGoldComparator({String? namePrefix}) async { skiaGoldClient, namePrefix: namePrefix, presubmit: isPresubmit, + localOutputDir: localOutputDir, ); } /// Configures [goldenFileComparator] to use Skia Gold (for unit testing). +/// +/// If [localOutputDir] is provided, local screenshots downloaded from Skia Gold +/// will be stored flat in this directory relative to the script execution path +/// (nested subdirectories in golden file URIs are not currently supported). @visibleForTesting Future enableSkiaGoldComparatorForTesting( SkiaGoldClient skiaGoldClient, { required bool presubmit, String? namePrefix, + String? localOutputDir, }) async { await skiaGoldClient.auth(); goldenFileComparator = _SkiaGoldComparator( skiaGoldClient, namePrefix: namePrefix, isPresubmit: presubmit, + localOutputDir: localOutputDir, ); } final class _SkiaGoldComparator extends GoldenFileComparator { - _SkiaGoldComparator(this.skiaClient, {required this.isPresubmit, this.namePrefix, Uri? baseDir}) - : baseDir = baseDir ?? Uri.parse(path.dirname(io.Platform.script.path)); + _SkiaGoldComparator( + this.skiaClient, { + required this.isPresubmit, + this.namePrefix, + this.localOutputDir, + Uri? baseDir, + }) : baseDir = baseDir ?? Uri.directory(path.dirname(io.Platform.script.toFilePath())); final Uri baseDir; final SkiaGoldClient skiaClient; final String? namePrefix; + final String? localOutputDir; final bool isPresubmit; @override @@ -149,6 +166,10 @@ final class _SkiaGoldComparator extends GoldenFileComparator { } io.File _getGoldenFile(Uri uri) { + if (localOutputDir != null) { + final String fileName = uri.pathSegments.last; + return io.File(path.join(baseDir.toFilePath(), localOutputDir, fileName)); + } return io.File.fromUri(baseDir.resolveUri(uri)); } diff --git a/dev/tracing_tests/android/gradle/wrapper/gradle-wrapper.properties b/dev/tracing_tests/android/gradle/wrapper/gradle-wrapper.properties index 0c65c6543841e..2f2958b923a0a 100644 --- a/dev/tracing_tests/android/gradle/wrapper/gradle-wrapper.properties +++ b/dev/tracing_tests/android/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/docs/contributing/git-worktrees.md b/docs/contributing/git-worktrees.md new file mode 100644 index 0000000000000..d647a20513eee --- /dev/null +++ b/docs/contributing/git-worktrees.md @@ -0,0 +1,377 @@ +--- +type: Contributor Doc +title: Git & Git Worktree Workflow Guide for Flutter Development +description: How to efficiently manage multiple concurrent branches and pull requests using Git worktrees. +resource: A practical guide to leveraging Git worktrees in Flutter development for tracking multiple release channels (master, beta, stable), managing concurrent feature branches, and conducting PR reviews without duplicating repository clones or managing git stashes. +tags: [git, worktrees, contributing, branches, PR review, feature development] +timestamp: 2026-07-23T15:26:31-0700 +--- + +# Git & Worktree Workflow Guide for Flutter Development + +Traditional `git` development follows a simple pattern for smaller projects: clone, commit, push, update. Medium projects tend to add branching and release workflows. Flutter is a large project with many moving parts and engineers wearing different hats at different times: framework, engine, infrastructure, release, android, iOS, gardener, sheriff, triage. + +This document covers a recommended workflow that should be flexible for all engineers contributing to Flutter, reduce cloned repository size and wasted space, and provide guidance for common tasks. + +## Setup + +Having one [flutter development environment](https://github.com/flutter/flutter/blob/main/docs/contributing/Setting-up-the-Framework-development-environment.md#set-up-your-environment) incurs a cost when changing branches between master, stable, and feature development - triggering a redownload of flutter artifacts, recompiling the flutter tool, and oftentimes requiring stashing of work. Running third party tools (vscode, dart analyzer, or agent) can also run into trouble when the local branch is changed. Having multiple git clones requires duplication of the `.git` folder for the same source code, juggling environment path changes or using OS aliasing techniques to call the correct version of flutter. + +Instead, we use git worktrees: a way to manage multiple working directories, each with a different branch checked out, all linked to the same Git repository. Using [flutter_worktree](https://github.com/jtmcdole/flutter_worktree#installation) we can end up with a flutter tree setup to track upstream/origin correctly, and have master/stable already checked out. + +```shell +~/flutter/ + ├── .bare/ (The bare repo or main tracking folder) + ├── .git (pointer to .bare/) + ├── fswitch.sh ("fswitch" command line with tab completion - win32:.ps1) + ├── master/ (Always clean, main development branch) + ├── stable/ (Locked to latest stable release for repros) + ├── review-179637/ (Temporary folder for reviewing PR #1234) + └── feature/new-widget/ (Long-running feature work) +``` + +This has the benefit of sharing the `.bare` git repository amongst all trees. The source files for each branch must be *unpacked* from `.bare` and the `/bin/cached` binaries are downloaded to the tree if you run the flutter tool. The install script for flutter_worktree will create the `fswitch.sh` file which you can use in your bash/zsh/windows profile to quickly `fswitch ` and update your environment's path without re-downloading (on Windows, Linux, and macOS). + +With the following directory structure; and with `fswitch.sh` sourced in your profile (e.g. `~/.zshrc`), you can now have multiple terminals open and dedicated to master and stable with a simple `fswitch master` / `fswitch stable`. This updates the current shell session’s OS PATH search to point to `master/bin` or `stable/bin`, allowing for VsCode, Antigravity, and other tooling to find the right version of Flutter for tooling. + +```shell +--- /Users/codefu/src/flutter --- + 1.9 GiB [#############################] /master + 932.7 MiB [############## ] /stable + 738.2 MiB [########### ] /pr-review + 487.0 MiB [####### ] /.bare + 8.0 KiB [ ] fswitch.sh + 4.0 KiB [ ] .git +``` + +> [!WARNING] +> Do not run tools like vscode from the root folder as this can cause them to stall trying to index all of the worktrees. + +### Common Scenarios + +> [!NOTE] +> This document follows the Flutter conventions for naming remotes: +> +> * **upstream**: `git@github.com:flutter/flutter.git` (Fetch target for master/beta/stable) +> * **origin**: `git@github.com:/flutter.git` (Push target for feature PRs) + +#### Code Reviews + +Instead of doing code reviews on [github.com](https://github.com) and lacking the feature rich dart analyzer, or being able to use an agent and other advanced tooling; check out the PR locally. + +Without the `gh` tool: +```shell +# 1. Fetch the PR HEAD +git fetch upstream pull/189954/head:pr-189954 + +# 2. Checkout the PR - automatically follows pull/189954/head +git worktree add pr-189954 + +# 3. Perform the review +cd pr-189954 + +# git log HEAD -n1 +# commit d277b8d7652b1baaf5844dc70040c60914b9eb5c (HEAD -> pr-189954) +# Author: engine-flutter-autoroll +# Date: Thu Jul 23 21:35:02 2026 +0000 + +# ... vscode, vim, antigravity. + +# 4. Delete the worktree when done. Remember to change to any other worktree or the root (with the `.bare` folder) +git worktree remove pr-189954 +``` + +With the `gh` tool it's easier: + +```shell +git worktree add prreview +cd prreview +gh pr checkout 189954 +``` + +#### New Feature Development + +You picked up a feature to implement a new widget, assign yourself the issue, and start work: + +```shell +# Start a new worktree branched from latest HEAD +git worktree add feature/new-widget + +# Switch and start work +cd feature/new-widget +code . + +# Push it upstream +git push --set-upstream origin $(git branch --show-current) + +# ... get reviews, make changes, pass tests + +# Delete the tree when done +git worktree remove feature/new-widget +``` + +There's no need to stash or upload changes until you are ready. You can update master / stable / other feature branches without losing context in `feature/new-widget`. + +#### Bug Investigation (Stable) + +If you've picked up a well documented issue to investigate; you can simply `fswitch stable` and test against stable. + +```shell +# Switch to stable branch +fswitch stable +# Or if you want to bisect starting at some baseref +git worktree add bugHunt baseref +fswitch bugHunt # updates the binaries in the environment PATH +cd bugHunt # changes to the bugHunt worktree +# ... git bisect +# Get the reprocode from the issue +mkdir repro_code && .... + +# Debug however you need to +flutter run + +# Alternate to master and see if its already fixed? +fswitch master +flutter clean +flutter run + +# You could even check out an old stable +cd ~/src/flutter +git worktree add beta upstream/beta +fswitch beta +flutter clean +flutter run +``` + +If you verify the bug and want to start a new PR; you can just create a worktree like a feature rather than juggling changes in your stable/master branch. + +## Modern Git Commands + +Git commands you should be using (and why). + +### Git Switch + +`git branch` is for managing branches (creating, listing, deleting) - while `git switch` is for **navigating** **between** them. Prior to 2019, we would use `git checkout` to switch between branches, but this is an overloaded command and leads to confusion…. E.g. `switch` operates on branches whereas `checkout` can operate on files (`git checkout -- file.txt`) + +```shell +# Create and change to new feature +git switch -c new-feature + +# Just switch +git switch master + +# House keeping +git branch -d new-feature +``` + +> [!NOTE] +> If `branch` is already checked out in another worktree, git will refuse to switch to it with a message like: +> +> ```shell +> fatal: 'master' is already used by worktree at '/Users/codefu/src/flutter/master' +> ``` + +### Git Restore + +Instead of using `git checkout` as a swiss army knife; use `git restore`: + +If you edit a file but realize you made a mess and just want it back to how it was in the last commit: + +```shell +git restore config.json +``` + +If you added a file too early, i.e. the file is in **staging**: + +```shell +git restore --staged secret_keys.env +``` + +### Git Reset + +This is for time traveling in your tree and has three modes: soft, mixed, and hard. + +#### Oops, I made a typo in the commit (soft) + +You committed your work, but realized you forgot to include one file, or you messed up the commit message. + +```shell +# The commit is gone, but the files are currently green (staged). +# You can fix the file/message and run git commit again. +git reset --soft HEAD~1 +``` + +#### I want to split a big commit into two smaller ones (--mixed) + +This is the default mode when running `git reset` + +```shell +# The commit is gone. The files are there but red (unstaged). +# You can now git add file A, commit it, then git add file B, +# and commit that separately. +git reset HEAD~1 +``` + +#### Throwing in the Towel + +You've tried fixing a bug, spent 3 hours on it, and realized everything is borked. You just want to go back to when the code worked. + +```shell +# All work since the last commit is erased +# Yar, there be dragons. + +git reset --hard HEAD~1 +``` + +## Other Git Topics + +### Rebase vs Merge + +There are two ways to update a Pull Request (PR) branch with upstream changes: **merging** and **rebasing**. Because Flutter is a large-scale project with automated infrastructure, specific assumptions are made regarding a PR's history, diffing, baseline compatibility, and engine artifacts. Flutter runs best with a rebase workflow. + +**Technical reasoning:** + +* `git merge master` (*discouraged*): Creates a non-linear history by generating a new "merge commit" at the tip of your branch. While a merge does shift the local `git merge-base` forward to include the latest `master` commits, it introduces a diamond-shaped commit graph. Automated tooling or metrics scripts checking chronological commit age or topological order can misinterpret the branch history, leading to flaky CI/CD evaluations. +* `git rebase upstream/master` (*encouraged*): Rewrites your branch history by picking up your unique commits and planting them directly on top of the latest `master` commit. This maintains a perfectly **linear history**. The base of your branch becomes the absolute tip of `master`, ensuring that content hashes, tests, and build artifacts are validated against a clean codebase rather than a mixed history. + +**Social reasoning:** + +* **Merge**: If you merge `master` into your branch multiple times over a long development cycle, your PR history becomes cluttered with "Merge branch 'master' into..." commits. This noise obscures your actual work, making it difficult for maintainers to review your specific changes. +* **Rebase**: It presents a clean, chronological story: *"Here is my work, applied directly to the latest codebase."* It signals to the reviewer that you have verified your code works cleanly alongside the most recent upstream changes. + +**Conflict Reasoning:** +Given the high velocity of the Flutter repository, conflicts with `master` are common. + +* **With a merge**, you resolve conflicts inside a noisy, standalone "merge commit." +* **With a rebase**, you resolve conflicts **within your own individual commits**. This keeps the final code clean and ensures your discrete commits remain atomic and functional if they ever need to be cherry-picked later. + +**Force Pushing Safely** + +Because a `rebase` rewrites your branch's commit **history**, a standard `git push` will be rejected by GitHub. You must force push. + +**Never use** `git push -f`. Instead, always use a lease. This instructs Git to check if anyone else has pushed to your remote branch since you last fetched, preventing you from accidentally overwriting a coworker's or maintainer's work. + +```shell +git push --force-with-lease +``` + +> [!TIP] +> Always git rebase on `upstream/master` on Flutter PRs. + +### Interactive Rebase Workflow + +Follow this sequence to cleanly update your feature branch and squash minor commits before requesting a review: + +```shell +# Update your upstream master references and clean up deleted branches +git fetch upstream --tags --prune + +# Or optionally fetch everything. Will take longer. +git fetch --all --tags --prune + +# Start an interactive rebase on top of the fresh upstream master +git rebase -i upstream/master +``` + +**What this does:** + +1. Opens your preferred editor with a list of your commits. +2. Allows you to `squash` (combine commits) or `fixup` (combine commits and discard the message). Use this to merge "typo fix" or "wip" commits into meaningful units of work. +3. Re-applies your newly cleaned, atomic commits sequentially directly on top of the latest upstream master + +### Stacked PRs + +What's better than one giant PR that might be expensive to review? Multiple small PRs that build on top of each other - each building successfully on their own. GitHub doesn't properly support / render stacked PRs like GitLab / Graphite / others; but we can still achieve a happy path. + +Let's say you have broken down a feature into two smaller ones, `feature-1a` and `feature-1b`. + +**Base-feature** +Merge-Base: `master` +Head: `feature-1a` +GitHub UI: Shows only changes in `feature-1a` + +**Stacked PR** +Merge-Base: `feature-1a` +Head: `feature-1b` +GitHub UI: Shows changes in `feature-1b` - **only for branches on flutter/flutter** +Clean-diff: https://github.com//flutter/compare/... + +```shell +# 1. Start on latest HEAD +git worktree add feature-1a + +# 2. Create Feature 1 +cd feature-1a + +# ... work, test, commit ... + +# 3. Create the second part (Stacking on Feature 1) +git switch -c feature-1b + +# ... work, test, commit ... + +# Push both PRs +git push origin feature-1a +git push origin feature-1b + +# Feedback comes in: changes requested in feature-1a; you are currently in feature 1b +# If its simple; you can interactive rebase and edit +git rebase -i --update-refs upstream/master + +# If its not simple; you can checkout feature-1a, do some work, and rebase feature 1b +### + git switch feature-1a + + # hack hack + git add + git commit -m "Fix review comments" + + # Update feature-1b + git switch feature-1b + git rebase feature-1a +### + +# Once done - update the PRs with lease +git push origin feature-1a # assumes you only made commits to feature-1a +git push --force-with-lease origin feature-1b +``` + +If you find yourself rebasing this stacked set of features on top of master; you can set the following to have git automatically move tracked branches: + +```shell +git config --global rebase.updateRefs true +``` + +Example: + +```shell +(Base) A --- B --- C [master] + \ +(Stack 1) D [feature-1a] + \ +(Stack 2) E [feature-1b] (HEAD) <- feature-1b is checked out + +git rebase upstream/master + +(Base) A --- B --- C [master] + \ +(Stack 1) D' [feature-1a] <-- MOVED AUTOMATICALLY! + \ +(Stack 2) E' [feature-1b] (HEAD) +``` + +#### Landing feature-1a / updating feature-1b + +Since Flutter squashes PRs before merging them to master, the history at master is NOT the same as the history in your stacked changes. If you were to rebase feature-1b onto master, you'll hit conflicts. To get around this trap, you simply need to: + +```shell +# 1. Update to get the new squash commit with feature-1a. +git fetch upstream --tags --prune + +# 2. Rebase feature-1b onto master, ignoring the old feature-1a commits +# --onto +git rebase --onto upstream/master feature-1a feature-1b + +# 3. Push the update. +git push --force-with-lease origin feature-1b +``` diff --git a/docs/ecosystem/contributing/README.md b/docs/ecosystem/contributing/README.md index 3fdd223ebd26c..165248a04a567 100644 --- a/docs/ecosystem/contributing/README.md +++ b/docs/ecosystem/contributing/README.md @@ -25,6 +25,7 @@ For consistency, all CHANGELOG entries should follow a common style: - Entries should end with a `.`. - Breaking changes should be introduced with `**BREAKING CHANGE**:`, or `**BREAKING CHANGES**:` if there is a sub-list of changes. - Breaking change notifications should include information about how to migrate. If extensive migration is required, this can be a reference to a longer description elsewhere (usually README.md) rather than inline instructions. +- Code references should be enclosed in backticks. Example: ``` diff --git a/docs/platforms/android/Migrating-Flutter-Gradle-Plugin-to-AGP-public-API.md b/docs/platforms/android/Migrating-Flutter-Gradle-Plugin-to-AGP-public-API.md new file mode 100644 index 0000000000000..43a37eea10412 --- /dev/null +++ b/docs/platforms/android/Migrating-Flutter-Gradle-Plugin-to-AGP-public-API.md @@ -0,0 +1,205 @@ +# Migrating the Flutter Gradle Plugin to the AGP Public API Surface + +This document is the contributor-facing record of the migration of the Flutter +Gradle Plugin (FGP) off the legacy Android Gradle Plugin (AGP) DSL/Variant API +and AGP internals, onto the public API surface shipped in the +`com.android.tools.build:gradle-api` artifact. + +Umbrella issues: + +- newDsl flip: https://github.com/flutter/flutter/issues/180137 +- Variant API migration: https://github.com/flutter/flutter/issues/166550 + +The user-facing breaking-change page draft lives next to this file in +[`website-page-draft.md`](website-page-draft.md). It must be published to +`docs.flutter.dev/release/breaking-changes/` before the newDsl flip (phase P9) +reaches the beta channel. + +## Why + +AGP 9 (January 2026) deprecated the old DSL and Variant APIs behind the +`android.newDsl=false` escape hatch. AGP 10 (late 2026) removes those APIs +entirely **and** removes access to AGP internals — only the public surface of +the `gradle-api` artifact remains. Today the FGP: + +- compiles against the FULL `com.android.tools.build:gradle` artifact + (`packages/flutter_tools/gradle/build.gradle.kts`); +- uses the legacy variant API (`applicationVariants`, `libraryVariants`, + `variant.outputs`, `assembleProvider`, `packageApplicationProvider`, + `versionCodeOverride`); +- uses the legacy `BaseExtension` + (`FlutterPluginUtils.getLegacyAndroidExtension`); +- imports one internal DSL class (`com.android.build.gradle.internal.dsl.BuildType` + in `plugins/PluginHandler.kt`); +- imports one internal utility + (`com.android.build.gradle.internal.utils.getKotlinAndroidPluginVersion` in + `VersionFetcher.kt`); +- drives `flutter build aar` with legacy dynamic Groovy in + `aar_init_script.gradle`. + +Flutter templates pin AGP 9.1.0 but ship `android.newDsl=false`, and a tool +migrator (`disable_new_dsl_migration.dart`) adds the opt-out to existing +projects. That opt-out dies with AGP 10. + +## End state + +- The FGP uses only public APIs and compiles against `gradle-api`. +- Templates no longer ship `android.newDsl=false`. +- The opt-out **add** migrator is replaced by a **removal** migrator that + deletes only the Flutter-added opt-out lines. +- A fresh `flutter create` app builds with newDsl on. + +## Decision records + +1. **Min AGP floor: out of scope.** A separate in-flight version bump owns the + floor; this migration builds on whatever floor is in effect at landing. + Every replacement API used here was verified public in `gradle-api:8.11.1` + (decompiled jar inspection). If implementation finds a replacement API that + genuinely requires a higher min AGP: document which API and why no + compatible alternative exists in this file, then bump — otherwise version + floors are untouched by this work. +2. **"Public in 8.x" does not mean binary-compatible on 9.x.** + `AgpCommonExtensionWrapper.kt` exists precisely because the public + `CommonExtension` broke between AGP 8 and 9. Mitigation: a CI/test axis + compiling the FGP against gradle-api 9.x is mandatory from phase P2 onward, + plus a bytecode check (javap grep) that no compiled FGP class references + `CommonExtension` as an owner. +3. **`android.builtInKotlin=false` stays out of scope.** Flipping it requires + the separate built-in-Kotlin migration workstream. Users get a second + (smaller) gradle.properties churn later; the breaking-change page states + this explicitly. Corollary: the P9 removal migrator must anchor on the + `android.newDsl` property line — never on marker-comment wording alone — + because the template's builtInKotlin marker comment is nearly identical. +4. **Per-ABI versionCode mechanism.** Do NOT re-implement AGP's flavor-merge + precedence via a `finalizeDsl` snapshot. Preferred mechanism (spiked first + in P6): read-then-set on `VariantOutput.versionCode` inside `onVariants` — + it is seeded with the merged value; set `abiOffset * 1000 + current`, + avoiding a self-referential `.map`. Fall back to a snapshot only if + read-then-set is impossible; record the outcome here. + - *Spike result:* _pending (P6)_. +5. **`buildModeFor` semantics.** Every variant-scope call uses the + `(name, debuggable)` overload with the public `Component.debuggable`. + Name-based inference is confined to the one DSL-scope case with no public + signal (the library-plugin build-type copy in `PluginHandler`). This + preserves add-to-app custom-debuggable matching (a host `staging` + debuggable build type maps to debug engine artifacts). + +## Replacement map + +| Legacy usage | Where | Public replacement | Phase | +| --- | --- | --- | --- | +| `internal.utils.getKotlinAndroidPluginVersion` | `VersionFetcher.kt` | delete; rely on existing fallback chain (`kotlin_version` property → `KotlinAndroidPluginWrapper.pluginVersion` → reflection); null when KGP absent is OK | P1 | +| `compileSdkVersion` string compare (`"android-NN"` substring) | `FlutterPluginUtils.getCompileSdkFromProject`, `PluginHandler` warning | wrapper `compileSdk` / `compileSdkPreview`; numeric compare with defined preview semantics | P1 | +| `BaseExtension.ndkVersion` | `FlutterPluginUtils.getConfiguredNdkVersion` | wrapper `ndkVersion` | P1 | +| `buildModeFor(BuildType)` (legacy model type) | `FlutterPluginUtils.kt` | `buildModeFor(name, debuggable)` overload | P2 | +| `getLegacyAndroidExtension(project).buildTypes` loops | `PluginHandler.kt` | wrapper new-DSL `buildTypes` container | P2 | +| `internal.dsl.BuildType` live aliasing into plugin projects | `PluginHandler.kt` | `initWith`-based copy on new-DSL `BuildType`; app-specific props only when both sides are `ApplicationBuildType` | P3 | +| `BaseExtension` / `getLegacyAndroidExtension` (remaining call sites) | `FlutterPluginUtils.kt` | wrapper accessors incl. `externalNativeBuild` | P4 | +| eager `applicationVariants.configureEach` task creation; mergeAssets/processResources hooks | `FlutterPlugin.kt`, `FlutterPluginUtils.kt` | consolidated `onVariants` block; `CopyFlutterAssetsTask` + `variant.sources.assets.addGeneratedSourceDirectory` | P5 | +| `variant.outputs` + `packageApplicationProvider` + `doLast` APK copy; `versionCodeOverride` | `FlutterPluginUtils.kt` | `CopyFlutterApksTask` (`SingleArtifact.APK` + `BuiltArtifactsLoader`); read-then-set `VariantOutput.versionCode` | P6 | +| `libraryVariants.all` × host `applicationVariants.all` cross-wiring | `FlutterPlugin.kt` (add-to-app) | library-side `onVariants` with `Component.debuggable`; no host-project lookup | P7 | +| dynamic Groovy legacy API in `aar_init_script.gradle` | `aar_init_script.gradle` | `components`-based enumeration; ext-property guard | P8 | +| `android.newDsl=false` template/migrator | templates, `disable_new_dsl_migration.dart` | drop from templates; `RemoveNewDslOptOutMigration` | P9 | +| FULL `gradle` artifact dependency | `build.gradle.kts` | `gradle-api` artifact (compile-time proof of zero internal usage) | P10 | + +## Phase map + +Each phase is one PR-sized change on its own branch. P8 is an independent lane +(Groovy script, disjoint files); P0/P1 are disjoint from each other; everything +else serializes through `FlutterPlugin.kt` / `FlutterPluginUtils.kt`. + +| Phase | Branch | Size | Summary | +| --- | --- | --- | --- | +| P0 | `agp-api-doc` | S | this doc + website page draft | +| P1 | `agp-internal-utils` | S | VersionFetcher internal util removal; numeric compileSdk compare; ndkVersion via wrapper | +| P2 | `agp-buildmode-deps` | M | `buildModeFor` overloads; new-DSL flutter dependencies; 9.x compile axis | +| P3 | `agp-plugin-buildtypes` | M | `initWith` copy for plugin build types; drop internal import; internal-import lint | +| P4 | `agp-ndk-fallback` | S | delete `BaseExtension`; externalNativeBuild via wrapper | +| P5 | `agp-assets-onvariants` | L | lazy task registration (5a) + generated-asset-dir wiring (5b) | +| P6 | `agp-apk-copy-versioncode` | L | `CopyFlutterApksTask`; per-ABI versionCode; app path legacy-free | +| P7 | `agp-add-to-app` | L | library-side `onVariants`; delete host cross-wiring + P5a legacy fork | +| P8 | `agp-aar-script` | M | aar_init_script public-API cleanup | +| P9 | `agp-newdsl-flip` | M | templates drop opt-out; removal migrator; new error handlers | +| P10 | `agp-gradle-api` | M | dependency swap to `gradle-api`; test migration | + +## Cross-cutting rules + +- **R1 Lockstep:** any PR changing FGP-emitted message text updates the + matching `gradle_errors.dart` matcher and its Dart test in the same PR. +- **R2 Revert notes:** each PR description carries "revert-safe until phase X + lands"; once superseded, policy is fix-forward. At least one full post-submit + CI soak between dependent phases (no same-day stacking of P2–P4). +- **R3 9.x axis:** from P2, gradle unit tests additionally compile against + gradle-api 9.x in CI, plus the javap `CommonExtension` bytecode check. +- **R4 Config-cache:** master baseline established first; the per-phase + assertion is "no NEW config-cache violations", not full reuse. +- **R5 Internal-import lint:** once P3 lands, a checked-in test forbids + `com.android.build.gradle.internal.*` imports in `src/main`. +- **R6 Staged newDsl=true axis:** app flows green from end of P6; add-to-app + from P7; aar from P8. The full matrix is the P9 gate. + +## Revert-window table + +| Phase | Revert window | +| --- | --- | +| P0 | always revert-safe | +| P1–P4 | each until the next phase in the chain lands; then fix-forward | +| P5 | until P6 lands | +| P6 / P7 | mutually tolerant (disjoint app/module paths) until P10 | +| P8 | revert-safe even after P10, but not after P9 | +| P9 | cleanly revertible in isolation | +| P10 | cleanly revertible in isolation | + +## Features that must break (tracked; updated as implementation learns) + +1. **User build scripts using legacy APIs** (`applicationVariants.all` + APK-rename recipes) fail under newDsl — the biggest break. Mitigated by new + error handlers (P9) and the website page. +2. **flutter-apk copy**: same names/paths (`app[-abi][-flavor]-.apk`, + byte-matching the current concatenation order), but an UP-TO-DATE-capable + finalizer task replaces the `doLast` block; new task names appear in + `gradlew tasks`. +3. **Per-ABI versionCode**: post-`finalizeDsl` user mutations (`afterEvaluate` + CI patterns) may behave differently; a runtime divergence warning is added. +4. **Custom build types → plugins**: live-aliased instances become `initWith` + copies; library plugins cannot receive `isDebuggable` (no public setter on + `LibraryBuildType`) — plugin-side `BuildConfig.DEBUG`/JNI debuggability may + differ for custom debuggable build types; matching preserved via + `matchingFallbacks`. +5. **Asset merge**: flutter assets become a merged source dir instead of a + post-merge overwrite; collisions resolve by AGP source-set priority. +6. **Add-to-app**: the explicit `:app:mergeAssets.dependsOn` edge and + host-project lookup are removed; `flutter.hostAppProjectName` becomes a + no-op with a deprecation warning naming a removal milestone; ordering + against `copyFlutterAssets` task names may break. +7. **Task realization/type**: flutter tasks become lazy `TaskProvider`s, and + `copyFlutterAssets` changes type from `org.gradle.api.tasks.Copy` to a + custom task class — `tasks.named(..., Copy::class)` casts fail. +8. **`flutter build aar`**: the singleVariant dedup guard becomes an + ext-property/try-catch with a specified error message; variant enumeration + moves from `libraryVariants` to `components` — partial user `singleVariant` + declarations surface differently. +9. **newDsl flip**: new projects lose the opt-out; the removal migrator deletes + only marker-tagged `android.newDsl` lines (template marker "This newDsl flag + was added by the Flutter template"; migrator marker "This newDsl flag was + added automatically by Flutter migrator"), anchored on the property line so + the adjacent builtInKotlin lines are never touched; hand-added opt-outs are + respected. +10. **compileSdk mismatch warning** becomes a numeric compare with defined + preview-vs-numeric semantics; the message keeps a distinctive substring of + the old phrasing for searchability. + +## Verification matrix + +Full matrix at P6, P7, P9, P10; targeted per-phase otherwise. + +1. `cd packages/flutter_tools/gradle && ./gradlew test` (+ the R3 9.x axis) +2. Targeted `integration.shard` tests named in each phase +3. Scratch-app matrix: apk/appbundle × 3 modes; `--flavor`; `--split-per-abi` + (+ apkanalyzer versionCode assertions, including the + flavor-defined-versionCode case); `--deferred-components`; plugin with a + custom build type; `flutter build aar`; add-to-app source & AAR host flows; + `flutter run` / hot restart / `flutter attach`; Windows smoke for the copy + tasks +4. AGP axis: current floor AND 9.1 + `newDsl=false`; staged `newDsl=true` per R6 +5. Config-cache per R4 diff --git a/docs/platforms/android/website-page-draft.md b/docs/platforms/android/website-page-draft.md new file mode 100644 index 0000000000000..de89c1b5ba0c5 --- /dev/null +++ b/docs/platforms/android/website-page-draft.md @@ -0,0 +1,191 @@ +# Android builds use the new Android Gradle Plugin DSL and Variant APIs + +*Draft breaking-change page for `docs.flutter.dev/release/breaking-changes/`. +This file is the source of truth until the page is published to +flutter/website; publishing must complete before the newDsl flip reaches the +beta channel. Contributor-facing details live in +[Migrating-Flutter-Gradle-Plugin-to-AGP-public-API.md](Migrating-Flutter-Gradle-Plugin-to-AGP-public-API.md).* + +## Summary + +The Flutter Gradle Plugin now uses only the public Android Gradle Plugin (AGP) +API, and new and migrated Flutter projects build with AGP's new DSL enabled +(`android.newDsl` is no longer set to `false` by Flutter). Gradle build +scripts that use the legacy AGP APIs — most commonly +`android.applicationVariants` — fail to configure and must be migrated to the +AGP Variant API. + +## Background + +AGP 9 deprecated the legacy DSL and Variant APIs behind the +`android.newDsl=false` flag. AGP 10 removes them entirely. Flutter previously +added `android.newDsl=false` to your `gradle.properties` (via the project +templates and an automatic migration) to keep legacy builds working. That +opt-out stops working with AGP 10, so Flutter has migrated its own Gradle +plugin to the public API and removed the opt-out from templates. A migration +now *removes* the opt-out lines that Flutter previously added — it only touches +lines carrying Flutter's marker comments, and prints a message when it does. +Opt-outs you added by hand are left alone. + +`android.builtInKotlin=false` is **not** affected by this change. It is owned +by the separate built-in-Kotlin migration (tracked in ), which means one more (smaller) +`gradle.properties` change later. + +## Migration guide + +### Renaming APKs (`applicationVariants.all`) + +Before: + +```groovy +android { + applicationVariants.all { variant -> + variant.outputs.all { output -> + outputFileName = "myapp-${variant.versionName}.apk" + } + } +} +``` + +After (Variant API, `build.gradle` / `build.gradle.kts`): + +```kotlin +androidComponents { + onVariants(selector().all()) { variant -> + variant.outputs.forEach { output -> + // Use variant.name / output.filters and your own naming scheme. + } + } +} +``` + +For output *file* renames, prefer consuming the built APKs from +`SingleArtifact.APK` with a task wired through +`variant.artifacts.use(...)`, or copy/rename in a finalizer task. Flutter's +own copy step already places APKs at +`build/app/outputs/flutter-apk/app[-abi][-flavor]-.apk` with unchanged +names and paths. + +### Setting per-ABI or per-variant versionCode + +Before: + +```groovy +android.applicationVariants.all { variant -> + variant.outputs.each { output -> + output.versionCodeOverride = abiCodes.get(output.getFilter(OutputFile.ABI)) * 1000 + variant.versionCode + } +} +``` + +After: + +```kotlin +androidComponents { + onVariants(selector().all()) { variant -> + variant.outputs.forEach { output -> + val abi = output.filters.find { it.filterType == FilterConfiguration.FilterType.ABI }?.identifier + val base = output.versionCode.get() ?: 1 + output.versionCode.set((abiCodes[abi] ?: 0) * 1000 + base) + } + } +} +``` + +Note: Flutter itself sets per-ABI version codes for `--split-per-abi` inside +`onVariants`. If your CI mutates version codes in `afterEvaluate`, that runs at +a different time than before; Flutter prints a warning when it detects a +divergence between the DSL value and the final output value. + +### Custom build types and plugins + +Flutter copies your app's custom build types onto Flutter plugin projects so +they resolve. With the new DSL these are `initWith` copies rather than live +aliases: + +- Set `matchingFallbacks` on custom build types so dependent Android libraries + resolve, for example: + + ```kotlin + android { + buildTypes { + create("staging") { + initWith(getByName("debug")) + matchingFallbacks += listOf("debug", "release") + } + } + } + ``` + +- Library (plugin) projects cannot be marked debuggable through the public + API, so a plugin's `BuildConfig.DEBUG` and native (JNI) debuggability can + differ from before for custom *debuggable* build types. Variant matching + still works via `matchingFallbacks`. + +### Add-to-app (Flutter module in a host app) + +- Flutter no longer looks up or configures the host `:app` project from the + module. The dependency between your host's asset merging and Flutter's asset + copy is expressed through the Variant API instead of an explicit + `mergeAssets.dependsOn(...)` edge. Build scripts that reference + Flutter's `copyFlutterAssets` tasks by name or type may break: the + tasks are now registered lazily and are no longer of type + `org.gradle.api.tasks.Copy`. +- `flutter.hostAppProjectName` in `gradle.properties` is now a no-op. Flutter + prints a deprecation warning naming the removal milestone. It was only used + for the host-project lookup, which no longer exists. +- Flutter maps host build types to Flutter build modes using the public + "debuggable" flag: `profile` stays `profile`, debuggable build types map to + `debug`, everything else maps to `release`. If your host has no `profile` + build type, add `matchingFallbacks`: + + ```kotlin + create("staging") { + initWith(getByName("debug")) + isDebuggable = true // staging gets debug Flutter artifacts + matchingFallbacks += listOf("debug", "release") + } + ``` + +### Flutter plugin authors + +- Do not read `android.applicationVariants` / `android.libraryVariants` in + plugin build scripts; use `androidComponents.onVariants`. +- Do not assume Flutter's tasks exist at configuration time or have specific + types; look tasks up lazily (`tasks.named`) without a type, or better, wire + through Variant API artifacts. +- Test your plugin's example app with AGP 9+ **without** `android.newDsl=false`. + +### `flutter build aar` + +Variant enumeration for AAR builds now uses the public `components` API. If +your module's build script declares `singleVariant(...)` publishing itself, +Flutter detects the overlap and reports it with an actionable error instead of +failing inside AGP. + +## Escape hatch (temporary) + +If you cannot migrate immediately, add the opt-out by hand to +`android/gradle.properties`: + +```properties +android.newDsl=false +``` + +**This stops working with AGP 10** (removal of the legacy APIs). Treat it as a +short-term unblock only; hand-added opt-outs are never touched by Flutter's +migrator. + +## Timeline + +Landed in version: TBD
+In stable release: TBD + +## References + +- AGP 9 release notes (new DSL): + https://developer.android.com/build/releases/agp-9-0-0-release-notes +- Flutter umbrella issues: + [flutter/flutter#180137](https://github.com/flutter/flutter/issues/180137), + [flutter/flutter#166550](https://github.com/flutter/flutter/issues/166550) diff --git a/engine/src/flutter/.sourcekit-lsp/config.json b/engine/src/flutter/.sourcekit-lsp/config.json new file mode 100644 index 0000000000000..e7d11af0287e1 --- /dev/null +++ b/engine/src/flutter/.sourcekit-lsp/config.json @@ -0,0 +1,7 @@ +{ + "compilationDatabase": { + "searchPaths": [ + "../" + ] + } +} diff --git a/engine/src/flutter/analysis_options.yaml b/engine/src/flutter/analysis_options.yaml index a7188fc6b458a..6bc1082ce7873 100644 --- a/engine/src/flutter/analysis_options.yaml +++ b/engine/src/flutter/analysis_options.yaml @@ -7,7 +7,7 @@ # # The reasoning for deviating from the general style must be documented below. -include: ../../../analysis_options.yaml +include: ../../../analysis_options_common.yaml analyzer: exclude: diff --git a/engine/src/flutter/build/archives/BUILD.gn b/engine/src/flutter/build/archives/BUILD.gn index 8b4b8f01ab767..9c063a3b00a27 100644 --- a/engine/src/flutter/build/archives/BUILD.gn +++ b/engine/src/flutter/build/archives/BUILD.gn @@ -219,6 +219,7 @@ _dart_sdk_without_entitlement_contents = [ "dart-sdk/bin/snapshots/dart2bytecode.dart.snapshot", "dart-sdk/bin/snapshots/dart2js_aot.dart.snapshot", "dart-sdk/bin/snapshots/dart2wasm_product.snapshot", + "dart-sdk/bin/snapshots/dart_runtime_service_vm_aot.dart.snapshot", "dart-sdk/bin/snapshots/dart_tooling_daemon_aot.dart.snapshot", "dart-sdk/bin/snapshots/dartdev_aot.dart.snapshot", "dart-sdk/bin/snapshots/dartdevc_aot.dart.snapshot", diff --git a/engine/src/flutter/common/settings.h b/engine/src/flutter/common/settings.h index 66af329c01358..d0c2628d241f3 100644 --- a/engine/src/flutter/common/settings.h +++ b/engine/src/flutter/common/settings.h @@ -160,7 +160,6 @@ struct Settings { bool enable_dart_profiling = false; bool profile_startup = false; bool disable_dart_asserts = false; - bool enable_serial_gc = false; bool profile_microtasks = false; // Whether embedder only allows secure connections. diff --git a/engine/src/flutter/display_list/effects/dl_image_filter.cc b/engine/src/flutter/display_list/effects/dl_image_filter.cc index 752baf6ae4e66..1a66b7664f999 100644 --- a/engine/src/flutter/display_list/effects/dl_image_filter.cc +++ b/engine/src/flutter/display_list/effects/dl_image_filter.cc @@ -34,9 +34,11 @@ std::shared_ptr DlImageFilter::MakeMatrix( std::shared_ptr DlImageFilter::MakeRuntimeEffect( sk_sp runtime_effect, std::vector> samplers, - std::shared_ptr> uniform_data) { + std::shared_ptr> uniform_data, + DlImageSampling input_sampling) { return DlRuntimeEffectImageFilter::Make( - std::move(runtime_effect), std::move(samplers), std::move(uniform_data)); + std::move(runtime_effect), std::move(samplers), std::move(uniform_data), + input_sampling); } std::shared_ptr DlImageFilter::MakeColorFilter( diff --git a/engine/src/flutter/display_list/effects/dl_image_filter.h b/engine/src/flutter/display_list/effects/dl_image_filter.h index 911fe62778247..8104c54de85c1 100644 --- a/engine/src/flutter/display_list/effects/dl_image_filter.h +++ b/engine/src/flutter/display_list/effects/dl_image_filter.h @@ -67,7 +67,8 @@ class DlImageFilter : public DlAttribute { static std::shared_ptr MakeRuntimeEffect( sk_sp runtime_effect, std::vector> samplers, - std::shared_ptr> uniform_data); + std::shared_ptr> uniform_data, + DlImageSampling input_sampling = DlImageSampling::kNearestNeighbor); static std::shared_ptr MakeColorFilter( const std::shared_ptr& filter); diff --git a/engine/src/flutter/display_list/effects/dl_image_filter_unittests.cc b/engine/src/flutter/display_list/effects/dl_image_filter_unittests.cc index aedbed332bb37..de5c1daa052b7 100644 --- a/engine/src/flutter/display_list/effects/dl_image_filter_unittests.cc +++ b/engine/src/flutter/display_list/effects/dl_image_filter_unittests.cc @@ -907,6 +907,21 @@ TEST(DisplayListImageFilter, RuntimeEffectEquality) { EXPECT_NE(filter_a, filter_c); } +TEST(DisplayListImageFilter, RuntimeEffectEqualityWithInputSampling) { + DlRuntimeEffectImageFilter filter_a(nullptr, {nullptr}, + std::make_shared>()); + DlRuntimeEffectImageFilter filter_b(nullptr, {nullptr}, + std::make_shared>()); + DlRuntimeEffectImageFilter filter_c(nullptr, {nullptr}, + std::make_shared>(), + DlImageSampling::kLinear); + + EXPECT_EQ(filter_a.input_sampling(), DlImageSampling::kNearestNeighbor); + EXPECT_EQ(filter_c.input_sampling(), DlImageSampling::kLinear); + EXPECT_EQ(filter_a, filter_b); + EXPECT_NE(filter_a, filter_c); +} + TEST(DisplayListImageFilter, RuntimeEffectEqualityWithSamplers) { auto image_a = DlColorSource::MakeImage(nullptr, DlTileMode::kClamp, DlTileMode::kDecal); diff --git a/engine/src/flutter/display_list/effects/image_filters/dl_runtime_effect_image_filter.cc b/engine/src/flutter/display_list/effects/image_filters/dl_runtime_effect_image_filter.cc index 9c6c285532d61..d2c52f10ce4d9 100644 --- a/engine/src/flutter/display_list/effects/image_filters/dl_runtime_effect_image_filter.cc +++ b/engine/src/flutter/display_list/effects/image_filters/dl_runtime_effect_image_filter.cc @@ -9,9 +9,11 @@ namespace flutter { std::shared_ptr DlRuntimeEffectImageFilter::Make( sk_sp runtime_effect, std::vector> samplers, - std::shared_ptr> uniform_data) { + std::shared_ptr> uniform_data, + DlImageSampling input_sampling) { return std::make_shared( - std::move(runtime_effect), std::move(samplers), std::move(uniform_data)); + std::move(runtime_effect), std::move(samplers), std::move(uniform_data), + input_sampling); } DlRect* DlRuntimeEffectImageFilter::map_local_bounds( @@ -42,7 +44,8 @@ bool DlRuntimeEffectImageFilter::equals_(const DlImageFilter& other) const { auto that = static_cast(&other); if (runtime_effect_ != that->runtime_effect_ || samplers_.size() != that->samplers().size() || - uniform_data_->size() != that->uniform_data()->size()) { + uniform_data_->size() != that->uniform_data()->size() || + input_sampling_ != that->input_sampling()) { return false; } for (auto i = 0u; i < samplers_.size(); i++) { diff --git a/engine/src/flutter/display_list/effects/image_filters/dl_runtime_effect_image_filter.h b/engine/src/flutter/display_list/effects/image_filters/dl_runtime_effect_image_filter.h index 391cfd9f475f9..ec72e58430b94 100644 --- a/engine/src/flutter/display_list/effects/image_filters/dl_runtime_effect_image_filter.h +++ b/engine/src/flutter/display_list/effects/image_filters/dl_runtime_effect_image_filter.h @@ -17,20 +17,24 @@ class DlRuntimeEffectImageFilter final : public DlImageFilter { explicit DlRuntimeEffectImageFilter( sk_sp runtime_effect, std::vector> samplers, - std::shared_ptr> uniform_data) + std::shared_ptr> uniform_data, + DlImageSampling input_sampling = DlImageSampling::kNearestNeighbor) : runtime_effect_(std::move(runtime_effect)), samplers_(std::move(samplers)), - uniform_data_(std::move(uniform_data)) {} + uniform_data_(std::move(uniform_data)), + input_sampling_(input_sampling) {} std::shared_ptr shared() const override { return std::make_shared( - this->runtime_effect_, this->samplers_, this->uniform_data_); + this->runtime_effect_, this->samplers_, this->uniform_data_, + this->input_sampling_); } static std::shared_ptr Make( sk_sp runtime_effect, std::vector> samplers, - std::shared_ptr> uniform_data); + std::shared_ptr> uniform_data, + DlImageSampling input_sampling = DlImageSampling::kNearestNeighbor); DlImageFilterType type() const override { return DlImageFilterType::kRuntimeEffect; @@ -66,6 +70,8 @@ class DlRuntimeEffectImageFilter final : public DlImageFilter { return uniform_data_; } + DlImageSampling input_sampling() const { return input_sampling_; } + protected: bool equals_(const DlImageFilter& other) const override; @@ -73,6 +79,7 @@ class DlRuntimeEffectImageFilter final : public DlImageFilter { sk_sp runtime_effect_; std::vector> samplers_; std::shared_ptr> uniform_data_; + DlImageSampling input_sampling_; }; } // namespace flutter diff --git a/engine/src/flutter/engine.code-workspace b/engine/src/flutter/engine.code-workspace index b3c6a99ff0faf..f26720113c4b2 100644 --- a/engine/src/flutter/engine.code-workspace +++ b/engine/src/flutter/engine.code-workspace @@ -137,6 +137,9 @@ ], "dotnet.defaultSolution": "disable", "dart.showTodos": false, + "swift.sourcekit-lsp.supported-languages": [ + "swift" + ], "testMate.cpp.test.advancedExecutables": [ { "name": "impeller_unittests_arm64", diff --git a/engine/src/flutter/fml/BUILD.gn b/engine/src/flutter/fml/BUILD.gn index 69f88e485a7f4..85c6171dbd200 100644 --- a/engine/src/flutter/fml/BUILD.gn +++ b/engine/src/flutter/fml/BUILD.gn @@ -85,6 +85,8 @@ source_set("fml") { "task_queue_id.h", "task_runner.cc", "task_runner.h", + "task_runner_util.cc", + "task_runner_util.h", "task_source.cc", "task_source.h", "thread.cc", @@ -355,6 +357,7 @@ if (enable_unittests) { "synchronization/semaphore_unittest.cc", "synchronization/sync_switch_unittest.cc", "synchronization/waitable_event_unittest.cc", + "task_runner_util_unittests.cc", "task_source_unittests.cc", "thread_unittests.cc", "time/chrono_timestamp_provider.cc", diff --git a/engine/src/flutter/fml/platform/win/wstring_conversion.cc b/engine/src/flutter/fml/platform/win/wstring_conversion.cc index ef87f60e89e6b..41e34862f868d 100644 --- a/engine/src/flutter/fml/platform/win/wstring_conversion.cc +++ b/engine/src/flutter/fml/platform/win/wstring_conversion.cc @@ -15,12 +15,12 @@ using WideStringConverter = std::string WideStringToUtf8(const std::wstring_view str) { WideStringConverter converter; - return converter.to_bytes(str.data()); + return converter.to_bytes(str.data(), str.data() + str.size()); } std::wstring Utf8ToWideString(const std::string_view str) { WideStringConverter converter; - return converter.from_bytes(str.data()); + return converter.from_bytes(str.data(), str.data() + str.size()); } std::u16string WideStringToUtf16(const std::wstring_view str) { diff --git a/engine/src/flutter/fml/task_runner_util.cc b/engine/src/flutter/fml/task_runner_util.cc new file mode 100644 index 0000000000000..2e12163bf9059 --- /dev/null +++ b/engine/src/flutter/fml/task_runner_util.cc @@ -0,0 +1,34 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "flutter/fml/task_runner_util.h" + +namespace fml { + +WrapperBasicTaskRunner::WrapperBasicTaskRunner( + fml::RefPtr task_runner) + : task_runner_(std::move(task_runner)) {} + +void WrapperBasicTaskRunner::PostTask(const fml::closure& task) { + task_runner_->PostTask(task); +} + +ConditionalBasicTaskRunner::ConditionalBasicTaskRunner( + fml::RefPtr task_runner, + std::function is_usable) + : task_runner_(std::move(task_runner)), + is_usable_( + std::make_shared>(std::move(is_usable))) {} + +void ConditionalBasicTaskRunner::PostTask(const fml::closure& task) { + auto task_wrapper = [task, weak_is_usable = std::weak_ptr(is_usable_)] { + std::shared_ptr> is_usable = weak_is_usable.lock(); + if (is_usable && (*is_usable)()) { + task(); + } + }; + task_runner_->PostTask(task_wrapper); +} + +} // namespace fml diff --git a/engine/src/flutter/fml/task_runner_util.h b/engine/src/flutter/fml/task_runner_util.h new file mode 100644 index 0000000000000..e766256f4bfa4 --- /dev/null +++ b/engine/src/flutter/fml/task_runner_util.h @@ -0,0 +1,57 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef FLUTTER_FML_TASK_RUNNER_UTIL_H_ +#define FLUTTER_FML_TASK_RUNNER_UTIL_H_ + +#include + +#include "flutter/fml/memory/ref_ptr.h" +#include "flutter/fml/task_runner.h" + +namespace fml { + +/// A BasicTaskRunner that posts tasks to another task runner. +/// +/// This can be used to adapt an fml::RefPtr to APIs that +/// take a BasicTaskRunner that is not managed by fml::RefPtr. +class WrapperBasicTaskRunner : public BasicTaskRunner { + public: + explicit WrapperBasicTaskRunner(fml::RefPtr task_runner); + + virtual ~WrapperBasicTaskRunner() = default; + + void PostTask(const fml::closure& task) override; + + private: + fml::RefPtr task_runner_; + + FML_DISALLOW_COPY_AND_ASSIGN(WrapperBasicTaskRunner); +}; + +/// A BasicTaskRunner that wraps another task runner and takes a function +/// that indicates whether that task runner is still usable. +/// +/// Before each posted task is run, ConditionalBasicTaskRunner will call the +/// is_usable function on the underlying task runner's thread. If is_usable +/// returns false, then the task will not be executed. +class ConditionalBasicTaskRunner : public BasicTaskRunner { + public: + explicit ConditionalBasicTaskRunner(fml::RefPtr task_runner, + std::function is_usable); + + virtual ~ConditionalBasicTaskRunner() = default; + + void PostTask(const fml::closure& task) override; + + private: + fml::RefPtr task_runner_; + const std::shared_ptr> is_usable_; + + FML_DISALLOW_COPY_AND_ASSIGN(ConditionalBasicTaskRunner); +}; + +} // namespace fml + +#endif // FLUTTER_FML_TASK_RUNNER_UTIL_H_ diff --git a/engine/src/flutter/fml/task_runner_util_unittests.cc b/engine/src/flutter/fml/task_runner_util_unittests.cc new file mode 100644 index 0000000000000..0579ff9b980f2 --- /dev/null +++ b/engine/src/flutter/fml/task_runner_util_unittests.cc @@ -0,0 +1,58 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include +#include + +#include "flutter/fml/synchronization/waitable_event.h" +#include "flutter/fml/task_runner_util.h" +#include "flutter/fml/thread.h" +#include "gtest/gtest.h" + +namespace fml { +namespace testing { + +TEST(TaskRunnerUtilTests, WrapperBasicTaskRunnerPostTask) { + fml::Thread thread; + + WrapperBasicTaskRunner wrapper(thread.GetTaskRunner()); + + std::thread::id wrapper_thread_id; + wrapper.PostTask([&]() { wrapper_thread_id = std::this_thread::get_id(); }); + + thread.Join(); + + EXPECT_NE(wrapper_thread_id, std::this_thread::get_id()); +} + +TEST(TaskRunnerUtilTests, ConditionalBasicTaskRunnerPostTask) { + fml::Thread thread; + std::atomic_bool active = true; + ConditionalBasicTaskRunner runner(thread.GetTaskRunner(), + [&active]() -> bool { return active; }); + + fml::AutoResetWaitableEvent latch; + std::atomic_bool task1_called = false; + runner.PostTask([&]() { + task1_called.store(true); + latch.Signal(); + }); + latch.Wait(); + + active.store(false); + + std::atomic_bool task2_called = false; + runner.PostTask([&]() { task2_called.store(true); }); + + thread.GetTaskRunner()->PostTask([&]() { latch.Signal(); }); + latch.Wait(); + + thread.Join(); + + EXPECT_TRUE(task1_called.load()); + EXPECT_FALSE(task2_called.load()); +} + +} // namespace testing +} // namespace fml diff --git a/engine/src/flutter/impeller/core/BUILD.gn b/engine/src/flutter/impeller/core/BUILD.gn index c47f5512f0c6f..ce414e2e6c0dc 100644 --- a/engine/src/flutter/impeller/core/BUILD.gn +++ b/engine/src/flutter/impeller/core/BUILD.gn @@ -43,10 +43,13 @@ impeller_component("core") { "vertex_buffer.h", ] + public_deps = [ "//third_party/abseil-cpp/absl/status" ] + deps = [ "../base", "../geometry", "//flutter/fml", + "//third_party/abseil-cpp/absl/strings", ] } @@ -65,5 +68,6 @@ impeller_component("allocator_unittests") { "../geometry", "//flutter/impeller/renderer/testing:mocks", "//flutter/testing:testing_lib", + "//third_party/abseil-cpp/absl/strings", ] } diff --git a/engine/src/flutter/impeller/core/allocator.cc b/engine/src/flutter/impeller/core/allocator.cc index 9f2a28f8995f1..6b6018f11f20a 100644 --- a/engine/src/flutter/impeller/core/allocator.cc +++ b/engine/src/flutter/impeller/core/allocator.cc @@ -48,6 +48,11 @@ std::shared_ptr Allocator::CreateBuffer( std::shared_ptr Allocator::CreateTexture(const TextureDescriptor& desc, bool threadsafe) { + if (const absl::Status status = desc.Validate(); !status.ok()) { + VALIDATION_LOG << "The texture descriptor is invalid. " << status.message(); + return nullptr; + } + const auto max_size = GetMaxTextureSizeSupported(); if (desc.size.width > max_size.width || desc.size.height > max_size.height) { VALIDATION_LOG << "Requested texture size " << desc.size diff --git a/engine/src/flutter/impeller/core/allocator_unittests.cc b/engine/src/flutter/impeller/core/allocator_unittests.cc index 3566b164fbd27..5d02c1c320acc 100644 --- a/engine/src/flutter/impeller/core/allocator_unittests.cc +++ b/engine/src/flutter/impeller/core/allocator_unittests.cc @@ -9,6 +9,7 @@ #include "impeller/core/texture_descriptor.h" #include "impeller/geometry/size.h" #include "impeller/renderer/testing/mocks.h" +#include "third_party/abseil-cpp/absl/strings/match.h" namespace impeller { namespace testing { @@ -77,6 +78,32 @@ TEST(AllocatorTest, TextureDescriptorCompatibility) { ASSERT_EQ(desc_a, desc_b); ASSERT_NE(desc_a, desc_c); } + // Array layer count. + { + TextureDescriptor desc_a = {.type = TextureType::kTexture2DArray, + .array_layer_count = 4}; + TextureDescriptor desc_b = {.type = TextureType::kTexture2DArray, + .array_layer_count = 4}; + TextureDescriptor desc_c = {.type = TextureType::kTexture2DArray, + .array_layer_count = 8}; + + EXPECT_EQ(desc_a, desc_b); + EXPECT_NE(desc_a, desc_c); + } +} + +TEST(AllocatorTest, TextureDescriptorArrayValidity) { + // A 2D array descriptor is valid with a layer count and invalid with zero + // layers. + TextureDescriptor desc = {.type = TextureType::kTexture2DArray, + .format = PixelFormat::kR8G8B8A8UNormInt, + .size = ISize(16, 16), + .array_layer_count = 4}; + EXPECT_TRUE(desc.IsValid()); + + desc.array_layer_count = 0; + EXPECT_FALSE(desc.IsValid()); + EXPECT_TRUE(absl::StrContains(desc.Validate().message(), "layer")); } TEST(AllocatorTest, RangeTest) { diff --git a/engine/src/flutter/impeller/core/formats.h b/engine/src/flutter/impeller/core/formats.h index b2d1520191aba..93b157b402d8a 100644 --- a/engine/src/flutter/impeller/core/formats.h +++ b/engine/src/flutter/impeller/core/formats.h @@ -452,6 +452,11 @@ enum class TextureType { kTexture2DMultisample, kTextureCube, kTextureExternalOES, + // A 2D texture with multiple layers, sampled as `sampler2DArray`. The layer + // count is carried by `TextureDescriptor::array_layer_count`. Kept last so + // the integer values of the existing types (mirrored by Flutter GPU) are + // unchanged. + kTexture2DArray, }; constexpr const char* TextureTypeToString(TextureType type) { @@ -464,6 +469,8 @@ constexpr const char* TextureTypeToString(TextureType type) { return "TextureCube"; case TextureType::kTextureExternalOES: return "TextureExternalOES"; + case TextureType::kTexture2DArray: + return "Texture2DArray"; } FML_UNREACHABLE(); } @@ -473,6 +480,7 @@ constexpr bool IsMultisampleCapable(TextureType type) { case TextureType::kTexture2D: case TextureType::kTextureCube: case TextureType::kTextureExternalOES: + case TextureType::kTexture2DArray: return false; case TextureType::kTexture2DMultisample: return true; diff --git a/engine/src/flutter/impeller/core/texture.cc b/engine/src/flutter/impeller/core/texture.cc index b12845db6a027..b427a1f41bf09 100644 --- a/engine/src/flutter/impeller/core/texture.cc +++ b/engine/src/flutter/impeller/core/texture.cc @@ -64,6 +64,8 @@ bool Texture::IsSliceValid(size_t slice) const { return slice == 0; case TextureType::kTextureCube: return slice <= 5; + case TextureType::kTexture2DArray: + return slice < static_cast(desc_.array_layer_count); } FML_UNREACHABLE(); } diff --git a/engine/src/flutter/impeller/core/texture.h b/engine/src/flutter/impeller/core/texture.h index 7f1dd7ad7222d..1f82695784373 100644 --- a/engine/src/flutter/impeller/core/texture.h +++ b/engine/src/flutter/impeller/core/texture.h @@ -55,6 +55,11 @@ class Texture { /// modified and the mipmaps hasn't been regenerated. bool NeedsMipmapGeneration() const; + /// Returns true if `slice` addresses a valid layer for this texture's type + /// (0 for 2D textures, 0-5 for cube maps, and below the descriptor's + /// `array_layer_count` for 2D array textures). + bool IsSliceValid(size_t slice) const; + protected: explicit Texture(TextureDescriptor desc); @@ -72,8 +77,6 @@ class Texture { const TextureDescriptor desc_; bool is_opaque_ = false; - bool IsSliceValid(size_t slice) const; - Texture(const Texture&) = delete; Texture& operator=(const Texture&) = delete; diff --git a/engine/src/flutter/impeller/core/texture_descriptor.cc b/engine/src/flutter/impeller/core/texture_descriptor.cc index e55c7ada7068e..4d17ce124f76c 100644 --- a/engine/src/flutter/impeller/core/texture_descriptor.cc +++ b/engine/src/flutter/impeller/core/texture_descriptor.cc @@ -6,8 +6,33 @@ #include +#include "third_party/abseil-cpp/absl/strings/str_cat.h" + namespace impeller { +absl::Status TextureDescriptor::Validate() const { + if (format == PixelFormat::kUnknown) { + return absl::InvalidArgumentError("A pixel format must be specified."); + } + if (size.IsEmpty()) { + return absl::InvalidArgumentError(absl::StrCat( + "The size ", size.width, "x", size.height, " must be nonempty.")); + } + if (mip_count < 1u) { + return absl::InvalidArgumentError("The mip count must be at least one."); + } + if (type == TextureType::kTexture2DArray && array_layer_count < 1u) { + return absl::InvalidArgumentError( + "A 2D array texture must have at least one layer."); + } + if (!SamplingOptionsAreValid()) { + return absl::InvalidArgumentError(absl::StrCat( + "The sample count ", static_cast(sample_count), + " is not valid for the texture type ", TextureTypeToString(type), ".")); + } + return absl::OkStatus(); +} + std::string TextureDescriptorToString(const TextureDescriptor& desc) { std::stringstream stream; stream << "StorageMode=" << StorageModeToString(desc.storage_mode) << ","; diff --git a/engine/src/flutter/impeller/core/texture_descriptor.h b/engine/src/flutter/impeller/core/texture_descriptor.h index e6f48c7b996a6..15dd5bb3e670b 100644 --- a/engine/src/flutter/impeller/core/texture_descriptor.h +++ b/engine/src/flutter/impeller/core/texture_descriptor.h @@ -8,6 +8,7 @@ #include #include "impeller/core/formats.h" #include "impeller/geometry/size.h" +#include "third_party/abseil-cpp/absl/status/status.h" namespace impeller { @@ -44,6 +45,9 @@ struct TextureDescriptor { TextureUsageMask usage = TextureUsage::kShaderRead; SampleCount sample_count = SampleCount::kCount1; CompressionType compression_type = CompressionType::kLossless; + /// The number of layers in a `kTexture2DArray`. Ignored by other texture + /// types (a cube map implies 6 layers from its type). + uint16_t array_layer_count = 1u; /// @brief The number of bytes required to store an image of the given texel /// dimensions in this format. Block-compressed formats round the @@ -53,14 +57,14 @@ struct TextureDescriptor { return BytesForTextureRegion(format, width, height); } - constexpr size_t GetByteSizeOfBaseMipLevel() const { + size_t GetByteSizeOfBaseMipLevel() const { if (!IsValid()) { return 0u; } return GetByteSizeForDimensions(size.width, size.height); } - constexpr size_t GetByteSizeOfAllMipLevels() const { + size_t GetByteSizeOfAllMipLevels() const { if (!IsValid()) { return 0u; } @@ -75,7 +79,7 @@ struct TextureDescriptor { return result; } - constexpr size_t GetBytesPerRow() const { + size_t GetBytesPerRow() const { if (!IsValid()) { return 0u; } @@ -89,12 +93,11 @@ struct TextureDescriptor { constexpr bool operator==(const TextureDescriptor& other) const = default; - constexpr bool IsValid() const { - return format != PixelFormat::kUnknown && // - !size.IsEmpty() && // - mip_count >= 1u && // - SamplingOptionsAreValid(); - } + /// @brief Returns why this descriptor cannot describe a real texture, or + /// OkStatus when it can. + absl::Status Validate() const; + + bool IsValid() const { return Validate().ok(); } }; std::string TextureDescriptorToString(const TextureDescriptor& desc); diff --git a/engine/src/flutter/impeller/display_list/canvas.cc b/engine/src/flutter/impeller/display_list/canvas.cc index 529653f280988..02b6dd130c8dc 100644 --- a/engine/src/flutter/impeller/display_list/canvas.cc +++ b/engine/src/flutter/impeller/display_list/canvas.cc @@ -1111,36 +1111,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()) { - Rect rrect_bounds = round_rect.GetBounds(); - auto [expanded, alpha_scaled_color] = - ExpandRectToPixelMinimum(rrect_bounds, paint.color, - GetCurrentTransform(), /*scale_alpha=*/true); + 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; } - // Pixel-minimum expansion is applicable if the expanded bounds is - // different from the original bounds. - if (expanded != rrect_bounds) { - // At a 1-pixel size, the rounded corners can be ignored. Draw a regular - // rect matching the expanded bounds. - auto params = UberSDFParameters::MakeRect( - /*color=*/alpha_scaled_color, - /*rect=*/expanded, - /*stroke=*/std::nullopt); - AddRenderSDFEntityToCurrentPass(paint, params); - 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) diff --git a/engine/src/flutter/impeller/display_list/image_filter.cc b/engine/src/flutter/impeller/display_list/image_filter.cc index e87964a9d5b37..06981f432b575 100644 --- a/engine/src/flutter/impeller/display_list/image_filter.cc +++ b/engine/src/flutter/impeller/display_list/image_filter.cc @@ -117,13 +117,15 @@ std::shared_ptr WrapInput(const ContentContext& renderer, runtime_filter->runtime_effect()->runtime_stage(); std::vector texture_inputs; - size_t index = 0; + bool is_first = true; for (const std::shared_ptr& sampler : runtime_filter->samplers()) { - if (index == 0 && sampler == nullptr) { - // Insert placeholder for filter. + if (is_first) { + is_first = false; + // The first sampler is always the image filter input. texture_inputs.push_back( - {.sampler_descriptor = skia_conversions::ToSamplerDescriptor({}), + {.sampler_descriptor = skia_conversions::ToSamplerDescriptor( + runtime_filter->input_sampling()), .texture = nullptr}); continue; } @@ -137,7 +139,6 @@ std::shared_ptr WrapInput(const ContentContext& renderer, std::shared_ptr texture = image->image()->asImpellerImage()->GetCachedTexture(renderer); FML_DCHECK(texture); - index++; texture_inputs.push_back({ .sampler_descriptor = skia_conversions::ToSamplerDescriptor(image->sampling()), diff --git a/engine/src/flutter/impeller/entity/contents/tiled_texture_contents.cc b/engine/src/flutter/impeller/entity/contents/tiled_texture_contents.cc index 864db9d91b04e..500ec844f1c13 100644 --- a/engine/src/flutter/impeller/entity/contents/tiled_texture_contents.cc +++ b/engine/src/flutter/impeller/entity/contents/tiled_texture_contents.cc @@ -111,6 +111,9 @@ bool TiledTextureContents::IsOpaque(const Matrix& transform) const { if (color_filter_) { return false; } + if (!texture_) { + return false; + } return texture_->IsOpaque() && !AppliesAlphaForStrokeCoverage(transform); } @@ -223,6 +226,9 @@ std::optional TiledTextureContents::RenderToSnapshot( const ContentContext& renderer, const Entity& entity, const SnapshotOptions& options) const { + if (!texture_) { + return std::nullopt; + } std::optional geometry_coverage = GetGeometry()->GetCoverage({}); if (GetInverseEffectTransform().IsIdentity() && GetGeometry()->IsAxisAlignedRect() && diff --git a/engine/src/flutter/impeller/entity/entity_unittests.cc b/engine/src/flutter/impeller/entity/entity_unittests.cc index 476af2b6c4402..3ae6e57a03ba8 100644 --- a/engine/src/flutter/impeller/entity/entity_unittests.cc +++ b/engine/src/flutter/impeller/entity/entity_unittests.cc @@ -2191,6 +2191,22 @@ TEST_P(EntityTest, TiledTextureContentsIsOpaque) { EXPECT_FALSE(contents.IsOpaque(matrix)); } +TEST_P(EntityTest, TiledTextureContentsIsOpaqueNullTexture) { + Matrix matrix; + auto geom = Geometry::MakeCover(); + TiledTextureContents contents(geom.get()); + contents.SetTexture(nullptr); + EXPECT_FALSE(contents.IsOpaque(matrix)); +} + +TEST_P(EntityTest, TiledTextureContentsRenderToSnapshotNullTexture) { + auto geom = Geometry::MakeCover(); + TiledTextureContents contents(geom.get()); + contents.SetTexture(nullptr); + auto snapshot = contents.RenderToSnapshot(GetContentContext(), Entity(), {}); + EXPECT_FALSE(snapshot.has_value()); +} + TEST_P(EntityTest, PointFieldGeometryCoverage) { std::vector points = {{10, 20}, {100, 200}}; PointFieldGeometry geometry(points.data(), 2, 5.0, false); diff --git a/engine/src/flutter/impeller/geometry/rect.h b/engine/src/flutter/impeller/geometry/rect.h index 0f67b795e174e..084e4113a374a 100644 --- a/engine/src/flutter/impeller/geometry/rect.h +++ b/engine/src/flutter/impeller/geometry/rect.h @@ -719,6 +719,13 @@ struct TRect { 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)); diff --git a/engine/src/flutter/impeller/geometry/rect_unittests.cc b/engine/src/flutter/impeller/geometry/rect_unittests.cc index df6d677241428..b8362c24eaf97 100644 --- a/engine/src/flutter/impeller/geometry/rect_unittests.cc +++ b/engine/src/flutter/impeller/geometry/rect_unittests.cc @@ -1444,13 +1444,21 @@ TEST(RectTest, RectExpandToMinTransformedSizeNullForScaledToZero) { std::nullopt); } -TEST(RectTest, - RectExpandToMinTransformedSizeRectReturnsUnmodifiedWhenLargeEnough) { +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); diff --git a/engine/src/flutter/impeller/renderer/BUILD.gn b/engine/src/flutter/impeller/renderer/BUILD.gn index ee339a915fe95..73fb979e07c13 100644 --- a/engine/src/flutter/impeller/renderer/BUILD.gn +++ b/engine/src/flutter/impeller/renderer/BUILD.gn @@ -98,6 +98,7 @@ impeller_component("renderer") { deps = [ "//flutter/fml", + "//third_party/abseil-cpp/absl/container:linked_hash_map", "//third_party/abseil-cpp/absl/strings", ] } @@ -108,6 +109,7 @@ template("renderer_unittests_component") { "blit_pass_unittests.cc", "capabilities_unittests.cc", "device_buffer_unittests.cc", + "pipeline_compile_queue_unittests.cc", "pipeline_descriptor_unittests.cc", "pipeline_library_unittests.cc", "pool_unittests.cc", diff --git a/engine/src/flutter/impeller/renderer/backend/gles/BUILD.gn b/engine/src/flutter/impeller/renderer/backend/gles/BUILD.gn index deec3f1c2fd69..6ae9f50baec3d 100644 --- a/engine/src/flutter/impeller/renderer/backend/gles/BUILD.gn +++ b/engine/src/flutter/impeller/renderer/backend/gles/BUILD.gn @@ -25,6 +25,7 @@ impeller_component("gles_unittests") { "test/mock_gles.cc", "test/mock_gles.h", "test/mock_gles_unittests.cc", + "test/pipeline_compile_queue_gles_unittests.cc", "test/pipeline_library_gles_unittests.cc", "test/proc_table_gles_unittests.cc", "test/reactor_unittests.cc", @@ -35,6 +36,7 @@ impeller_component("gles_unittests") { ] deps = [ ":gles", + "//flutter/fml", "//flutter/impeller/playground:playground_test", "//flutter/testing:testing_lib", ] @@ -69,6 +71,8 @@ impeller_component("gles") { "gpu_tracer_gles.h", "handle_gles.cc", "handle_gles.h", + "pipeline_compile_queue_gles.cc", + "pipeline_compile_queue_gles.h", "pipeline_gles.cc", "pipeline_gles.h", "pipeline_library_gles.cc", diff --git a/engine/src/flutter/impeller/renderer/backend/gles/blit_command_gles.cc b/engine/src/flutter/impeller/renderer/backend/gles/blit_command_gles.cc index 8bd7d1b377d06..80e415a7e41fb 100644 --- a/engine/src/flutter/impeller/renderer/backend/gles/blit_command_gles.cc +++ b/engine/src/flutter/impeller/renderer/backend/gles/blit_command_gles.cc @@ -181,6 +181,12 @@ bool BlitCopyBufferToTextureCommandGLES::Encode( texture_type = GL_TEXTURE_CUBE_MAP; texture_target = GL_TEXTURE_CUBE_MAP_POSITIVE_X + slice; break; + case TextureType::kTexture2DArray: + // TODO(bdero): Upload into 2D array layers via glTexSubImage3D from the + // blit path. Direct uploads via Texture::SetContents are supported. + VALIDATION_LOG << "Blitting into a 2D array texture is not yet supported " + "on the OpenGLES backend."; + return false; case TextureType::kTextureExternalOES: texture_type = GL_TEXTURE_EXTERNAL_OES; texture_target = GL_TEXTURE_EXTERNAL_OES; diff --git a/engine/src/flutter/impeller/renderer/backend/gles/buffer_bindings_gles.cc b/engine/src/flutter/impeller/renderer/backend/gles/buffer_bindings_gles.cc index e1ce430e81464..2055e5164edd7 100644 --- a/engine/src/flutter/impeller/renderer/backend/gles/buffer_bindings_gles.cc +++ b/engine/src/flutter/impeller/renderer/backend/gles/buffer_bindings_gles.cc @@ -130,9 +130,13 @@ bool BufferBindingsGLES::ReadUniformsBindingsV3(const ProcTableGLES& gl, GLuint block_index = gl.GetUniformBlockIndex(program, name.data()); gl.UniformBlockBinding(program_handle_, block_index, i); + GLint block_data_size = 0; + gl.GetActiveUniformBlockiv(program, i, GL_UNIFORM_BLOCK_DATA_SIZE, + &block_data_size); ubo_locations_[std::string{name.data(), static_cast(length)}] = - std::make_pair(block_index, i); + UBOInfo{static_cast(block_index), static_cast(i), + block_data_size}; } use_ubo_ = true; return ReadUniformsBindingsV2(gl, program); @@ -370,15 +374,14 @@ bool BufferBindingsGLES::BindUniformBufferV3( const BufferView& buffer, const ShaderMetadata* metadata, const DeviceBufferGLES& device_buffer_gles) { - absl::flat_hash_map>::iterator it = - ubo_locations_.find(metadata->name); + auto it = ubo_locations_.find(metadata->name); if (it == ubo_locations_.end()) { // This should only happen if we have GLESv3 but are using v2 shaders, // as GLESv3 shaders compiled by impeller always have // **named** uniform buffer blocks return BindUniformBufferV2(gl, buffer, metadata, device_buffer_gles); } - const auto& [block_index, binding_point] = it->second; + const auto& ubo_info = it->second; if (!device_buffer_gles.BindAndUploadDataIfNecessary( DeviceBufferGLES::BindingType::kUniformBuffer)) { return false; @@ -387,8 +390,15 @@ bool BufferBindingsGLES::BindUniformBufferV3( if (!handle.has_value()) { return false; } - gl.BindBufferRange(GL_UNIFORM_BUFFER, binding_point, handle.value(), - buffer.GetRange().offset, buffer.GetRange().length); + size_t length = std::max(buffer.GetRange().length, + static_cast(ubo_info.data_size)); + if (buffer.GetRange().offset + length > + device_buffer_gles.GetDeviceBufferDescriptor().size) { + VALIDATION_LOG << "Uniform buffer range exceeds device buffer size."; + return false; + } + gl.BindBufferRange(GL_UNIFORM_BUFFER, ubo_info.binding_point, handle.value(), + buffer.GetRange().offset, length); return true; } diff --git a/engine/src/flutter/impeller/renderer/backend/gles/buffer_bindings_gles.h b/engine/src/flutter/impeller/renderer/backend/gles/buffer_bindings_gles.h index 02cc6a30042cc..12cef0328bc1b 100644 --- a/engine/src/flutter/impeller/renderer/backend/gles/buffer_bindings_gles.h +++ b/engine/src/flutter/impeller/renderer/backend/gles/buffer_bindings_gles.h @@ -82,7 +82,12 @@ class BufferBindingsGLES { std::vector> vertex_attrib_arrays_; absl::flat_hash_map uniform_locations_; - absl::flat_hash_map> ubo_locations_; + struct UBOInfo { + GLint block_index = 0; + GLuint binding_point = 0; + GLint data_size = 0; + }; + absl::flat_hash_map ubo_locations_; using BindingMap = absl::flat_hash_map>; BindingMap binding_map_ = {}; diff --git a/engine/src/flutter/impeller/renderer/backend/gles/buffer_bindings_gles_unittests.cc b/engine/src/flutter/impeller/renderer/backend/gles/buffer_bindings_gles_unittests.cc index ea39bc2eec436..9bae5b1beb717 100644 --- a/engine/src/flutter/impeller/renderer/backend/gles/buffer_bindings_gles_unittests.cc +++ b/engine/src/flutter/impeller/renderer/backend/gles/buffer_bindings_gles_unittests.cc @@ -8,11 +8,15 @@ #include "impeller/renderer/backend/gles/buffer_bindings_gles.h" #include "impeller/renderer/backend/gles/device_buffer_gles.h" #include "impeller/renderer/backend/gles/formats_gles.h" +#include "impeller/renderer/backend/gles/reactor_gles.h" #include "impeller/renderer/backend/gles/test/mock_gles.h" #include "impeller/renderer/command.h" namespace impeller { namespace testing { +namespace { +const GLint kBlockDataSize = 16; +} using ::testing::_; @@ -265,5 +269,97 @@ TEST(BufferBindingsGLESTest, BindVertexAttributesSetsInstanceRateDivisor) { /*instance=*/0)); } +namespace { +class TestWorker : public ReactorGLES::Worker { + public: + bool CanReactorReactOnCurrentThreadNow( + const ReactorGLES& reactor) const override { + return true; + } +}; +} // namespace + +void TestBindUniformBufferRange(size_t buffer_view_length, + size_t expected_bound_size) { + BufferBindingsGLES bindings; + auto mock_gles_impl = std::make_unique<::testing::NiceMock>(); + + const GLuint kProgram = 1; + + ON_CALL(*mock_gles_impl, + GetProgramiv(/*program=*/kProgram, + /*pname=*/GL_ACTIVE_UNIFORM_BLOCKS, /*params=*/_)) + .WillByDefault(::testing::SetArgPointee<2>(1)); + ON_CALL(*mock_gles_impl, + GetActiveUniformBlockiv(/*program=*/kProgram, + /*uniformBlockIndex=*/0, + /*pname=*/GL_UNIFORM_BLOCK_NAME_LENGTH, + /*params=*/_)) + .WillByDefault(::testing::SetArgPointee<3>(9)); + ON_CALL(*mock_gles_impl, + GetActiveUniformBlockName(/*program=*/kProgram, + /*uniformBlockIndex=*/0, + /*bufSize=*/9, /*length=*/_, + /*uniformBlockName=*/_)) + .WillByDefault([](GLuint program, GLuint index, GLsizei bufSize, + GLsizei* length, GLchar* name) { + *length = 8; + std::memcpy(name, "FragInfo", 9); + }); + ON_CALL( + *mock_gles_impl, + GetUniformBlockIndex(/*program=*/kProgram, + /*uniformBlockName=*/::testing::StrEq("FragInfo"))) + .WillByDefault(::testing::Return(0)); + ON_CALL(*mock_gles_impl, + GetActiveUniformBlockiv(/*program=*/kProgram, + /*uniformBlockIndex=*/0, + /*pname=*/GL_UNIFORM_BLOCK_DATA_SIZE, + /*params=*/_)) + .WillByDefault(::testing::SetArgPointee<3>(kBlockDataSize)); + + EXPECT_CALL(*mock_gles_impl, + BindBufferRange(/*target=*/GL_UNIFORM_BUFFER, /*index=*/0, + /*buffer=*/_, /*offset=*/0, + /*size=*/expected_bound_size)) + .Times(1); + + std::shared_ptr mock_gl = MockGLES::Init(std::move(mock_gles_impl)); + ASSERT_TRUE(bindings.ReadUniformsBindings(mock_gl->GetProcTable(), kProgram)); + + ProcTableGLES::Resolver resolver = kMockResolverGLES; + auto proc_table = std::make_unique(resolver); + auto worker = std::make_shared(); + auto reactor = std::make_shared(std::move(proc_table)); + reactor->AddWorker(worker); + + std::vector bound_buffers; + std::vector bound_textures; + + ShaderMetadata shader_metadata = {.name = "FragInfo"}; + auto backing_store = std::make_unique(); + ASSERT_TRUE(backing_store->Truncate(Bytes{1024})); + DeviceBufferGLES device_buffer(DeviceBufferDescriptor{.size = 1024}, reactor, + std::move(backing_store)); + BufferView buffer_view(&device_buffer, Range(0, buffer_view_length)); + bound_buffers.push_back(BufferResource(&shader_metadata, buffer_view)); + + EXPECT_TRUE(bindings.BindUniformData(mock_gl->GetProcTable(), bound_textures, + bound_buffers, Range{0, 0}, + Range{0, 1})); +} + +TEST(BufferBindingsGLESTest, + BindUniformBufferUsesMaxOfBufferViewAndBlockDataSize) { + TestBindUniformBufferRange(/*buffer_view_length=*/4, + /*expected_bound_size=*/kBlockDataSize); +} + +TEST(BufferBindingsGLESTest, + BindUniformBufferUsesBufferViewLengthWhenGreaterThanBlockDataSize) { + TestBindUniformBufferRange(/*buffer_view_length=*/32, + /*expected_bound_size=*/32); +} + } // namespace testing } // namespace impeller diff --git a/engine/src/flutter/impeller/renderer/backend/gles/capabilities_gles.cc b/engine/src/flutter/impeller/renderer/backend/gles/capabilities_gles.cc index 21e45f47a09d9..cf7d9b25ca8b8 100644 --- a/engine/src/flutter/impeller/renderer/backend/gles/capabilities_gles.cc +++ b/engine/src/flutter/impeller/renderer/backend/gles/capabilities_gles.cc @@ -207,6 +207,17 @@ CapabilitiesGLES::CapabilitiesGLES(const ProcTableGLES& gl) { desc->GetGlVersion().major_version >= 3 || desc->HasExtension(kAppleTextureMaxLevelExt); + // 2D array textures (GL_TEXTURE_2D_ARRAY, sampled as sampler2DArray) need the + // 3D texture upload entry points. These are core on desktop GL 3.0 and + // OpenGL ES 3.0, and reachable below them via GL_EXT_texture_array (desktop + // GL 2.x) or GL_NV_texture_array (OpenGL ES 2.0). Gate on the resolved procs + // rather than the version so a context that advertises an extension but does + // not actually provide the entry points is treated as unsupported, and so + // ES 2.0 devices that do expose them are supported. + supports_texture_array_ = gl.TexImage3D.IsAvailable() && + gl.TexSubImage3D.IsAvailable() && + gl.CompressedTexSubImage3D.IsAvailable(); + // Anisotropic filtering is not part of any core GL or GLES version; it is // always gated on GL_EXT_texture_filter_anisotropic. The query and the // texture parameter are applied with core ES 2.0 entry points (GetFloatv @@ -238,6 +249,10 @@ bool CapabilitiesGLES::SupportsTextureMaxLevel() const { return supports_texture_max_level_; } +bool CapabilitiesGLES::SupportsTextureArray() const { + return supports_texture_array_; +} + size_t CapabilitiesGLES::GetMaxTextureUnits(ShaderStage stage) const { switch (stage) { case ShaderStage::kVertex: diff --git a/engine/src/flutter/impeller/renderer/backend/gles/capabilities_gles.h b/engine/src/flutter/impeller/renderer/backend/gles/capabilities_gles.h index 2ddde81473707..f2f59699a0d6f 100644 --- a/engine/src/flutter/impeller/renderer/backend/gles/capabilities_gles.h +++ b/engine/src/flutter/impeller/renderer/backend/gles/capabilities_gles.h @@ -92,6 +92,13 @@ class CapabilitiesGLES final /// chain cannot be made mipmap complete and samples as black. bool SupportsTextureMaxLevel() const; + /// @brief Whether 2D array textures (`GL_TEXTURE_2D_ARRAY`, `sampler2DArray`) + /// are available. Core on desktop GL 3.0+ and OpenGL ES 3.0+, and also + /// available below them through GL_EXT_texture_array (desktop GL 2.x) + /// or GL_NV_texture_array (OpenGL ES 2.0). When absent, callers must + /// fall back to a texture atlas. + bool SupportsTextureArray() const; + // |Capabilities| bool SupportsOffscreenMSAA() const override; @@ -173,6 +180,7 @@ class CapabilitiesGLES final bool supports_implicit_msaa_ = false; bool supports_32bit_primitive_indices_ = false; bool supports_texture_max_level_ = false; + bool supports_texture_array_ = false; bool is_angle_ = false; bool is_es_ = false; bool supports_texture_compression_bc_ = false; diff --git a/engine/src/flutter/impeller/renderer/backend/gles/context_gles.cc b/engine/src/flutter/impeller/renderer/backend/gles/context_gles.cc index 3cdadd3bd29e8..ff274c48a45e7 100644 --- a/engine/src/flutter/impeller/renderer/backend/gles/context_gles.cc +++ b/engine/src/flutter/impeller/renderer/backend/gles/context_gles.cc @@ -22,16 +22,19 @@ std::shared_ptr ContextGLES::Create( const Flags& flags, std::unique_ptr gl, const std::vector>& shader_libraries, - bool enable_gpu_tracing) { - return std::shared_ptr(new ContextGLES( - flags, std::move(gl), shader_libraries, enable_gpu_tracing)); + bool enable_gpu_tracing, + std::shared_ptr io_task_runner) { + return std::shared_ptr( + new ContextGLES(flags, std::move(gl), shader_libraries, + enable_gpu_tracing, std::move(io_task_runner))); } ContextGLES::ContextGLES( const Flags& flags, std::unique_ptr gl, const std::vector>& shader_libraries_mappings, - bool enable_gpu_tracing) + bool enable_gpu_tracing, + std::shared_ptr io_task_runner) : Context(flags) { reactor_ = std::make_shared(std::move(gl)); if (!reactor_->IsValid()) { @@ -52,8 +55,8 @@ ContextGLES::ContextGLES( // Create the pipeline library. { - pipeline_library_ = - std::shared_ptr(new PipelineLibraryGLES(reactor_)); + pipeline_library_ = std::shared_ptr( + new PipelineLibraryGLES(reactor_, std::move(io_task_runner))); } // Create allocators. diff --git a/engine/src/flutter/impeller/renderer/backend/gles/context_gles.h b/engine/src/flutter/impeller/renderer/backend/gles/context_gles.h index 1c54844b5e256..ba4a2847213f0 100644 --- a/engine/src/flutter/impeller/renderer/backend/gles/context_gles.h +++ b/engine/src/flutter/impeller/renderer/backend/gles/context_gles.h @@ -28,7 +28,8 @@ class ContextGLES final : public Context, const Flags& flags, std::unique_ptr gl, const std::vector>& shader_libraries, - bool enable_gpu_tracing); + bool enable_gpu_tracing, + std::shared_ptr io_task_runner = nullptr); // |Context| ~ContextGLES() override; @@ -70,7 +71,8 @@ class ContextGLES final : public Context, const Flags& flags, std::unique_ptr gl, const std::vector>& shader_libraries, - bool enable_gpu_tracing); + bool enable_gpu_tracing, + std::shared_ptr io_task_runner = nullptr); // |Context| std::string DescribeGpuModel() const override; diff --git a/engine/src/flutter/impeller/renderer/backend/gles/formats_gles.h b/engine/src/flutter/impeller/renderer/backend/gles/formats_gles.h index 1d127ed99dde6..4810dd631cd8d 100644 --- a/engine/src/flutter/impeller/renderer/backend/gles/formats_gles.h +++ b/engine/src/flutter/impeller/renderer/backend/gles/formats_gles.h @@ -195,6 +195,8 @@ constexpr GLenum ToTextureType(TextureType type) { return GL_TEXTURE_2D_MULTISAMPLE; case TextureType::kTextureCube: return GL_TEXTURE_CUBE_MAP; + case TextureType::kTexture2DArray: + return GL_TEXTURE_2D_ARRAY; case TextureType::kTextureExternalOES: return GL_TEXTURE_EXTERNAL_OES; } @@ -209,6 +211,8 @@ constexpr std::optional ToTextureTarget(TextureType type) { return GL_TEXTURE_2D; case TextureType::kTextureCube: return GL_TEXTURE_CUBE_MAP; + case TextureType::kTexture2DArray: + return GL_TEXTURE_2D_ARRAY; case TextureType::kTextureExternalOES: return GL_TEXTURE_EXTERNAL_OES; } diff --git a/engine/src/flutter/impeller/renderer/backend/gles/pipeline_compile_queue_gles.cc b/engine/src/flutter/impeller/renderer/backend/gles/pipeline_compile_queue_gles.cc new file mode 100644 index 0000000000000..b8955445deffe --- /dev/null +++ b/engine/src/flutter/impeller/renderer/backend/gles/pipeline_compile_queue_gles.cc @@ -0,0 +1,72 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "impeller/renderer/backend/gles/pipeline_compile_queue_gles.h" + +#include "flutter/fml/logging.h" +#include "flutter/fml/trace_event.h" +#include "impeller/base/validation.h" + +namespace impeller { + +std::shared_ptr PipelineCompileQueueGLES::Create( + std::shared_ptr worker_task_runner) { + if (!worker_task_runner) { + return nullptr; + } + return std::shared_ptr( + new PipelineCompileQueueGLES(std::move(worker_task_runner))); +} + +PipelineCompileQueueGLES::PipelineCompileQueueGLES( + std::shared_ptr worker_task_runner) + : worker_task_runner_(std::move(worker_task_runner)) {} + +PipelineCompileQueueGLES::~PipelineCompileQueueGLES() = default; + +void PipelineCompileQueueGLES::OnJobAdded() { + // To prevent potential deadlocks and reduce lock contention, avoid calling + // external or virtual methods (such as DrainPendingJobs, which posts tasks + // to the task runner) while holding a mutex. Instead, minimize the scope of + // the lock by using a local boolean flag to trigger the draining process + // outside the lock block. + bool should_drain = false; + { + Lock lock(processing_mutex_); + if (!is_processing_) { + is_processing_ = true; + should_drain = true; + } + } + if (should_drain) { + DrainPendingJobs(); + } +} + +void PipelineCompileQueueGLES::PostJob(const fml::closure& job) { + if (!job) { + return; + } + + worker_task_runner_->PostTask(job); +} + +void PipelineCompileQueueGLES::DrainPendingJobs() { + PostJob([weak_queue = weak_from_this()]() { + if (auto queue = std::static_pointer_cast( + weak_queue.lock())) { + queue->DoOneJob(); + { + Lock lock(queue->processing_mutex_); + if (!queue->HasPendingJobs()) { + queue->is_processing_ = false; + return; + } + } + queue->DrainPendingJobs(); + } + }); +} + +} // namespace impeller diff --git a/engine/src/flutter/impeller/renderer/backend/gles/pipeline_compile_queue_gles.h b/engine/src/flutter/impeller/renderer/backend/gles/pipeline_compile_queue_gles.h new file mode 100644 index 0000000000000..cf013d6950e9d --- /dev/null +++ b/engine/src/flutter/impeller/renderer/backend/gles/pipeline_compile_queue_gles.h @@ -0,0 +1,63 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef FLUTTER_IMPELLER_RENDERER_BACKEND_GLES_PIPELINE_COMPILE_QUEUE_GLES_H_ +#define FLUTTER_IMPELLER_RENDERER_BACKEND_GLES_PIPELINE_COMPILE_QUEUE_GLES_H_ + +#include "flutter/fml/closure.h" +#include "flutter/fml/task_runner.h" +#include "impeller/base/thread.h" +#include "impeller/renderer/pipeline_compile_queue.h" + +namespace impeller { + +//------------------------------------------------------------------------------ +/// @brief A task queue designed for managing compilation of pipeline state +/// objects for OpenGL ES backend. +/// +/// This subclass uses a fml::TaskRunner as the worker task runner +/// and implements a sequential job processing mechanism to prevent +/// blocking the IO task runner. +/// +/// Key characteristics: +/// - Uses fml::RefPtr for worker_task_runner_ +/// - Processes jobs sequentially: loads one job at a time before +/// proceeding to the next, preventing IO task runner blocking +/// - Uses DrainPendingJobs() to recursively process jobs one by one +/// - Employs is_processing_ flag and processing_mutex_ to control +/// sequential processing +/// +/// The sequential processing ensures that pipeline compilation jobs +/// do not overwhelm the task runner, which is particularly +/// important for GLES backend where resource loading patterns +/// differ from Vulkan. +/// +class PipelineCompileQueueGLES : public PipelineCompileQueue { + public: + static std::shared_ptr Create( + std::shared_ptr worker_task_runner); + + ~PipelineCompileQueueGLES() override; + + PipelineCompileQueueGLES(const PipelineCompileQueueGLES&) = delete; + + PipelineCompileQueueGLES& operator=(const PipelineCompileQueueGLES&) = delete; + + void PostJob(const fml::closure& job) override; + + void OnJobAdded() override; + + private: + explicit PipelineCompileQueueGLES( + std::shared_ptr worker_task_runner); + void DrainPendingJobs(); + + std::shared_ptr worker_task_runner_; + Mutex processing_mutex_; + bool is_processing_ IPLR_GUARDED_BY(processing_mutex_) = false; +}; + +} // namespace impeller + +#endif // FLUTTER_IMPELLER_RENDERER_BACKEND_GLES_PIPELINE_COMPILE_QUEUE_GLES_H_ diff --git a/engine/src/flutter/impeller/renderer/backend/gles/pipeline_library_gles.cc b/engine/src/flutter/impeller/renderer/backend/gles/pipeline_library_gles.cc index 240891dfc4b60..f5643ee559018 100644 --- a/engine/src/flutter/impeller/renderer/backend/gles/pipeline_library_gles.cc +++ b/engine/src/flutter/impeller/renderer/backend/gles/pipeline_library_gles.cc @@ -16,8 +16,12 @@ namespace impeller { -PipelineLibraryGLES::PipelineLibraryGLES(std::shared_ptr reactor) - : reactor_(std::move(reactor)) {} +PipelineLibraryGLES::PipelineLibraryGLES( + std::shared_ptr reactor, + std::shared_ptr io_task_runner) + : reactor_(std::move(reactor)), + compile_queue_( + PipelineCompileQueueGLES::Create(std::move(io_task_runner))) {} static std::string GetShaderInfoLog(const ProcTableGLES& gl, GLuint shader) { GLint log_length = 0; @@ -296,17 +300,34 @@ PipelineFuture PipelineLibraryGLES::GetPipeline( PipelineFuture{descriptor, promise->get_future()}; pipelines_[descriptor] = pipeline_future; - const auto result = reactor_->AddOperation([promise, // - weak_this = weak_from_this(), // - descriptor, // - vert_function, // - frag_function, // - threadsafe // - ](const ReactorGLES& reactor) { - promise->set_value(CreatePipeline(weak_this, descriptor, vert_function, - frag_function, threadsafe)); - }); - FML_CHECK(result); + std::weak_ptr weak_this = weak_from_this(); + std::shared_ptr reactor = reactor_; + auto generation_task = [promise, weak_this, descriptor, vert_function, + frag_function, threadsafe, reactor]() { + auto thiz = weak_this.lock(); + if (!thiz) { + promise->set_value(nullptr); + return; + } + const bool result = reactor->AddOperation([promise, // + weak_this, // + descriptor, // + vert_function, // + frag_function, // + threadsafe // + ](const ReactorGLES& reactor) { + promise->set_value(CreatePipeline(weak_this, descriptor, vert_function, + frag_function, threadsafe)); + }); + FML_CHECK(result); + }; + + if (async && compile_queue_) { + compile_queue_->PostJobForDescriptor(descriptor, + std::move(generation_task)); + } else { + generation_task(); + } return pipeline_future; } @@ -373,4 +394,8 @@ void PipelineLibraryGLES::SetProgramForKey( programs_[key] = std::move(program); } +PipelineCompileQueue* PipelineLibraryGLES::GetPipelineCompileQueue() const { + return compile_queue_.get(); +} + } // namespace impeller diff --git a/engine/src/flutter/impeller/renderer/backend/gles/pipeline_library_gles.h b/engine/src/flutter/impeller/renderer/backend/gles/pipeline_library_gles.h index 25c51c8113d38..f2ce764f3b7eb 100644 --- a/engine/src/flutter/impeller/renderer/backend/gles/pipeline_library_gles.h +++ b/engine/src/flutter/impeller/renderer/backend/gles/pipeline_library_gles.h @@ -9,7 +9,9 @@ #include #include "flutter/fml/hash_combine.h" +#include "flutter/fml/task_runner.h" #include "impeller/base/thread.h" +#include "impeller/renderer/backend/gles/pipeline_compile_queue_gles.h" #include "impeller/renderer/backend/gles/reactor_gles.h" #include "impeller/renderer/backend/gles/unique_handle_gles.h" #include "impeller/renderer/pipeline_library.h" @@ -91,8 +93,11 @@ class PipelineLibraryGLES final PipelineMap pipelines_; Mutex programs_mutex_; ProgramMap programs_ IPLR_GUARDED_BY(programs_mutex_); + std::shared_ptr compile_queue_; - explicit PipelineLibraryGLES(std::shared_ptr reactor); + explicit PipelineLibraryGLES( + std::shared_ptr reactor, + std::shared_ptr io_task_runner); // |PipelineLibrary| bool IsValid() const override; @@ -127,6 +132,8 @@ class PipelineLibraryGLES final void SetProgramForKey(const ProgramKey& key, std::shared_ptr program); + // |PipelineLibrary| + PipelineCompileQueue* GetPipelineCompileQueue() const override; }; } // namespace impeller diff --git a/engine/src/flutter/impeller/renderer/backend/gles/proc_table_gles.cc b/engine/src/flutter/impeller/renderer/backend/gles/proc_table_gles.cc index 16fd3a3748df7..5630238cb8dd2 100644 --- a/engine/src/flutter/impeller/renderer/backend/gles/proc_table_gles.cc +++ b/engine/src/flutter/impeller/renderer/backend/gles/proc_table_gles.cc @@ -72,6 +72,20 @@ ProcTableGLES::Resolver WrappedResolver( }; } +// Resolves `gl_name` and installs it as `proc`'s implementation. Used to alias +// extension entry points that share a core proc's signature under a different +// name (e.g. the *NV-suffixed GL_NV_texture_array procs). +template +static void BindProcAlias(Proc& proc, + const Resolver& resolver, + const ErrorFn& error_fn, + const char* gl_name) { + if (auto fn_ptr = resolver(gl_name)) { + proc.function = reinterpret_cast(fn_ptr); + proc.error_fn = error_fn; + } +} + ProcTableGLES::ProcTableGLES( // NOLINT(google-readability-function-size) Resolver resolver) { // The reason this constructor has anywhere near enough code to tip off @@ -122,10 +136,28 @@ ProcTableGLES::ProcTableGLES( // NOLINT(google-readability-function-size) proc_ivar.error_fn = error_fn; \ } - if (description_->GetGlVersion().IsAtLeast(Version(3))) { + const bool supports_gl3 = description_->GetGlVersion().IsAtLeast(Version(3)); + if (supports_gl3) { FOR_EACH_IMPELLER_GLES3_PROC(IMPELLER_PROC); } + // 2D array textures need the 3D texture entry points. They are core on + // GL/GLES 3.0, exposed on desktop GL 2.x through GL_EXT_texture_array (which + // uses the same entry-point names), and on OpenGL ES 2.0 through + // GL_NV_texture_array (which suffixes them with NV). The NV entry points + // share the core signatures, so they are resolved as aliases into the core + // procs and the rest of the backend can call them without branching. + if (supports_gl3 || description_->HasExtension("GL_EXT_texture_array")) { + FOR_EACH_IMPELLER_TEXTURE_ARRAY_PROC(IMPELLER_PROC); + } else if (description_->HasExtension("GL_NV_texture_array")) { + BindProcAlias(TexImage3D, resolver, error_fn, "glTexImage3DNV"); + BindProcAlias(TexSubImage3D, resolver, error_fn, "glTexSubImage3DNV"); + BindProcAlias(CompressedTexImage3D, resolver, error_fn, + "glCompressedTexImage3DNV"); + BindProcAlias(CompressedTexSubImage3D, resolver, error_fn, + "glCompressedTexSubImage3DNV"); + } + FOR_EACH_IMPELLER_EXT_PROC(IMPELLER_PROC); #undef IMPELLER_PROC diff --git a/engine/src/flutter/impeller/renderer/backend/gles/proc_table_gles.h b/engine/src/flutter/impeller/renderer/backend/gles/proc_table_gles.h index e9e52f36db542..a093159c25eee 100644 --- a/engine/src/flutter/impeller/renderer/backend/gles/proc_table_gles.h +++ b/engine/src/flutter/impeller/renderer/backend/gles/proc_table_gles.h @@ -271,6 +271,17 @@ void(glDepthRange)(GLdouble n, GLdouble f); PROC(BlitFramebuffer); \ PROC(InvalidateFramebuffer); +// 3D texture entry points, used by 2D array textures. These are core on +// GL/GLES 3.0 and also reachable below it: desktop GL 2.x exposes these exact +// entry points through GL_EXT_texture_array, and OpenGL ES 2.0 exposes them +// through GL_NV_texture_array under *NV-suffixed names (resolved as aliases +// into these procs). See ProcTableGLES setup. +#define FOR_EACH_IMPELLER_TEXTURE_ARRAY_PROC(PROC) \ + PROC(TexImage3D); \ + PROC(TexSubImage3D); \ + PROC(CompressedTexImage3D); \ + PROC(CompressedTexSubImage3D); + #define FOR_EACH_IMPELLER_EXT_PROC(PROC) \ PROC(DebugMessageControlKHR); \ PROC(DebugMessageCallbackKHR); \ @@ -319,6 +330,7 @@ class ProcTableGLES { FOR_EACH_IMPELLER_ES_ONLY_PROC(IMPELLER_PROC); FOR_EACH_IMPELLER_DESKTOP_ONLY_PROC(IMPELLER_PROC); FOR_EACH_IMPELLER_GLES3_PROC(IMPELLER_PROC); + FOR_EACH_IMPELLER_TEXTURE_ARRAY_PROC(IMPELLER_PROC); FOR_EACH_IMPELLER_EXT_PROC(IMPELLER_PROC); #undef IMPELLER_PROC diff --git a/engine/src/flutter/impeller/renderer/backend/gles/test/capabilities_unittests.cc b/engine/src/flutter/impeller/renderer/backend/gles/test/capabilities_unittests.cc index fe5f0f799d131..edb515240abd0 100644 --- a/engine/src/flutter/impeller/renderer/backend/gles/test/capabilities_unittests.cc +++ b/engine/src/flutter/impeller/renderer/backend/gles/test/capabilities_unittests.cc @@ -89,5 +89,43 @@ TEST(CapabilitiesGLES, MaxSamplerAnisotropyWithExtension) { EXPECT_GE(capabilities->GetMaxSamplerAnisotropy(), 2u); } +TEST(CapabilitiesGLES, SupportsTextureArrayOnES3) { + // 2D array textures are core on OpenGL ES 3.0, no extension needed. + auto mock_gles = MockGLES::Init(std::nullopt, "OpenGL ES 3.0"); + auto capabilities = mock_gles->GetProcTable().GetCapabilities(); + EXPECT_TRUE(capabilities->SupportsTextureArray()); +} + +TEST(CapabilitiesGLES, DoesNotSupportTextureArrayOnES2WithoutExtension) { + auto const extensions = std::vector{"GL_KHR_debug"}; + auto mock_gles = MockGLES::Init(extensions, "OpenGL ES 2.0"); + auto capabilities = mock_gles->GetProcTable().GetCapabilities(); + EXPECT_FALSE(capabilities->SupportsTextureArray()); +} + +TEST(CapabilitiesGLES, SupportsTextureArrayViaNVExtensionOnES2) { + // OpenGL ES 2.0 has no core array textures, but GL_NV_texture_array exposes + // them through *NV-suffixed 3D texture entry points. + auto const extensions = std::vector{ + "GL_KHR_debug", // + "GL_NV_texture_array", // + }; + auto mock_gles = MockGLES::Init(extensions, "OpenGL ES 2.0"); + auto capabilities = mock_gles->GetProcTable().GetCapabilities(); + EXPECT_TRUE(capabilities->SupportsTextureArray()); +} + +TEST(CapabilitiesGLES, SupportsTextureArrayViaEXTExtension) { + // GL_EXT_texture_array exposes the core-named 3D texture entry points below + // GL 3.0 (desktop GL 2.x). + auto const extensions = std::vector{ + "GL_KHR_debug", // + "GL_EXT_texture_array", // + }; + auto mock_gles = MockGLES::Init(extensions, "OpenGL ES 2.0"); + auto capabilities = mock_gles->GetProcTable().GetCapabilities(); + EXPECT_TRUE(capabilities->SupportsTextureArray()); +} + } // namespace testing } // namespace impeller diff --git a/engine/src/flutter/impeller/renderer/backend/gles/test/mock_gles.cc b/engine/src/flutter/impeller/renderer/backend/gles/test/mock_gles.cc index bec7631eb730e..f4d48151f1537 100644 --- a/engine/src/flutter/impeller/renderer/backend/gles/test/mock_gles.cc +++ b/engine/src/flutter/impeller/renderer/backend/gles/test/mock_gles.cc @@ -303,6 +303,52 @@ void mockBindTexture(GLenum target, GLuint texture) { static_assert(CheckSameSignature::value); +void mockBindBufferRange(GLenum target, + GLuint index, + GLuint buffer, + GLintptr offset, + GLsizeiptr size) { + CallMockMethod(&IMockGLESImpl::BindBufferRange, target, index, buffer, offset, + size); +} +static_assert(CheckSameSignature::value); + +void mockGetProgramiv(GLuint program, GLenum pname, GLint* params) { + CallMockMethod(&IMockGLESImpl::GetProgramiv, program, pname, params); +} +static_assert(CheckSameSignature::value); + +void mockGetActiveUniformBlockiv(GLuint program, + GLuint uniformBlockIndex, + GLenum pname, + GLint* params) { + CallMockMethod(&IMockGLESImpl::GetActiveUniformBlockiv, program, + uniformBlockIndex, pname, params); +} +static_assert(CheckSameSignature::value); + +void mockGetActiveUniformBlockName(GLuint program, + GLuint uniformBlockIndex, + GLsizei bufSize, + GLsizei* length, + GLchar* uniformBlockName) { + CallMockMethod(&IMockGLESImpl::GetActiveUniformBlockName, program, + uniformBlockIndex, bufSize, length, uniformBlockName); +} +static_assert(CheckSameSignature::value); + +GLuint mockGetUniformBlockIndex(GLuint program, + const GLchar* uniformBlockName) { + return CallMockMethod(&IMockGLESImpl::GetUniformBlockIndex, program, + uniformBlockName); +} +static_assert(CheckSameSignature::value); + GLboolean mockIsTexture(GLuint texture) { return CallMockMethod(&IMockGLESImpl::IsTexture, texture); } @@ -537,6 +583,16 @@ const ProcTableGLES::Resolver kMockResolverGLES = [](const char* name) { return reinterpret_cast(mockDrawElementsInstanced); } else if (strcmp(name, "glVertexAttribDivisor") == 0) { return reinterpret_cast(mockVertexAttribDivisor); + } else if (strcmp(name, "glBindBufferRange") == 0) { + return reinterpret_cast(mockBindBufferRange); + } else if (strcmp(name, "glGetProgramiv") == 0) { + return reinterpret_cast(mockGetProgramiv); + } else if (strcmp(name, "glGetActiveUniformBlockiv") == 0) { + return reinterpret_cast(mockGetActiveUniformBlockiv); + } else if (strcmp(name, "glGetActiveUniformBlockName") == 0) { + return reinterpret_cast(mockGetActiveUniformBlockName); + } else if (strcmp(name, "glGetUniformBlockIndex") == 0) { + return reinterpret_cast(mockGetUniformBlockIndex); } else { return reinterpret_cast(&doNothing); } diff --git a/engine/src/flutter/impeller/renderer/backend/gles/test/mock_gles.h b/engine/src/flutter/impeller/renderer/backend/gles/test/mock_gles.h index 1fe8ae74a9f42..89fd4dd46fa6a 100644 --- a/engine/src/flutter/impeller/renderer/backend/gles/test/mock_gles.h +++ b/engine/src/flutter/impeller/renderer/backend/gles/test/mock_gles.h @@ -125,6 +125,25 @@ class IMockGLESImpl { const void* indices, GLsizei instancecount) {} virtual void VertexAttribDivisor(GLuint index, GLuint divisor) {} + virtual void BindBufferRange(GLenum target, + GLuint index, + GLuint buffer, + GLintptr offset, + GLsizeiptr size) {} + virtual void GetProgramiv(GLuint program, GLenum pname, GLint* params) {} + virtual void GetActiveUniformBlockiv(GLuint program, + GLuint uniformBlockIndex, + GLenum pname, + GLint* params) {} + virtual void GetActiveUniformBlockName(GLuint program, + GLuint uniformBlockIndex, + GLsizei bufSize, + GLsizei* length, + GLchar* uniformBlockName) {} + virtual GLuint GetUniformBlockIndex(GLuint program, + const GLchar* uniformBlockName) { + return 0; + } }; class MockGLESImpl : public IMockGLESImpl { @@ -299,6 +318,35 @@ class MockGLESImpl : public IMockGLESImpl { VertexAttribDivisor, (GLuint index, GLuint divisor), (override)); + MOCK_METHOD(void, + BindBufferRange, + (GLenum target, + GLuint index, + GLuint buffer, + GLintptr offset, + GLsizeiptr size), + (override)); + MOCK_METHOD(void, + GetProgramiv, + (GLuint program, GLenum pname, GLint* params), + (override)); + MOCK_METHOD( + void, + GetActiveUniformBlockiv, + (GLuint program, GLuint uniformBlockIndex, GLenum pname, GLint* params), + (override)); + MOCK_METHOD(void, + GetActiveUniformBlockName, + (GLuint program, + GLuint uniformBlockIndex, + GLsizei bufSize, + GLsizei* length, + GLchar* uniformBlockName), + (override)); + MOCK_METHOD(GLuint, + GetUniformBlockIndex, + (GLuint program, const GLchar* uniformBlockName), + (override)); }; /// @brief Provides a mocked version of the |ProcTableGLES| class. diff --git a/engine/src/flutter/impeller/renderer/backend/gles/test/pipeline_compile_queue_gles_unittests.cc b/engine/src/flutter/impeller/renderer/backend/gles/test/pipeline_compile_queue_gles_unittests.cc new file mode 100644 index 0000000000000..ff726faa11f20 --- /dev/null +++ b/engine/src/flutter/impeller/renderer/backend/gles/test/pipeline_compile_queue_gles_unittests.cc @@ -0,0 +1,199 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "impeller/renderer/backend/gles/pipeline_compile_queue_gles.h" + +#include +#include +#include + +#include "flutter/fml/synchronization/count_down_latch.h" +#include "flutter/fml/task_runner.h" +#include "flutter/fml/task_runner_util.h" +#include "flutter/fml/thread.h" +#include "flutter/testing/testing.h" +#include "impeller/renderer/pipeline_descriptor.h" + +namespace impeller { +namespace testing { + +namespace { + +std::shared_ptr CreateBasicTaskRunner( + const fml::Thread& thread) { + return std::make_shared(thread.GetTaskRunner()); +} + +} // namespace + +TEST(PipelineCompileQueueGLESTest, CreateReturnsNullWithNullTaskRunner) { + auto queue = PipelineCompileQueueGLES::Create(nullptr); + EXPECT_EQ(queue, nullptr); +} + +TEST(PipelineCompileQueueGLESTest, CreateSucceedsWithValidTaskRunner) { + fml::Thread thread; + auto queue = PipelineCompileQueueGLES::Create(CreateBasicTaskRunner(thread)); + EXPECT_NE(queue, nullptr); + thread.Join(); +} + +TEST(PipelineCompileQueueGLESTest, PostJobDoesNothingWithNullClosure) { + fml::Thread thread; + auto queue = PipelineCompileQueueGLES::Create(CreateBasicTaskRunner(thread)); + ASSERT_NE(queue, nullptr); + queue->PostJob(nullptr); + thread.Join(); +} + +TEST(PipelineCompileQueueGLESTest, OnJobAddedProcessesJobsSequentially) { + fml::Thread thread; + auto queue = PipelineCompileQueueGLES::Create(CreateBasicTaskRunner(thread)); + ASSERT_NE(queue, nullptr); + + std::atomic completed_jobs{0}; + fml::CountDownLatch latch(3); + + PipelineDescriptor desc1; + desc1.SetSampleCount(SampleCount::kCount1); + desc1.SetCullMode(CullMode::kNone); + + PipelineDescriptor desc2; + desc2.SetSampleCount(SampleCount::kCount1); + desc2.SetCullMode(CullMode::kFrontFace); + + PipelineDescriptor desc3; + desc3.SetSampleCount(SampleCount::kCount1); + desc3.SetCullMode(CullMode::kBackFace); + + queue->PostJobForDescriptor(desc1, [&]() { + std::this_thread::sleep_for(std::chrono::milliseconds(80)); + completed_jobs++; + latch.CountDown(); + }); + + queue->PostJobForDescriptor(desc2, [&]() { + std::this_thread::sleep_for(std::chrono::milliseconds(80)); + completed_jobs++; + latch.CountDown(); + }); + + queue->PostJobForDescriptor(desc3, [&]() { + std::this_thread::sleep_for(std::chrono::milliseconds(80)); + completed_jobs++; + latch.CountDown(); + }); + + latch.Wait(); + + EXPECT_EQ(completed_jobs, 3); + + thread.Join(); +} + +TEST(PipelineCompileQueueGLESTest, + PostJobForDescriptorWithDuplicateRunsEagerly) { + fml::Thread thread; + auto queue = PipelineCompileQueueGLES::Create(CreateBasicTaskRunner(thread)); + ASSERT_NE(queue, nullptr); + + std::atomic first_job_count{0}; + std::atomic second_job_count{0}; + fml::CountDownLatch latch(2); + + PipelineDescriptor desc; + + queue->PostJobForDescriptor(desc, [&]() { + first_job_count++; + latch.CountDown(); + }); + + queue->PostJobForDescriptor(desc, [&]() { + second_job_count++; + latch.CountDown(); + }); + + latch.Wait(); + + EXPECT_EQ(first_job_count, 1); + EXPECT_EQ(second_job_count, 1); + thread.Join(); +} + +TEST(PipelineCompileQueueGLESTest, IsProcessingResetsAfterAllJobsComplete) { + fml::Thread thread; + auto queue = PipelineCompileQueueGLES::Create(CreateBasicTaskRunner(thread)); + ASSERT_NE(queue, nullptr); + + fml::CountDownLatch latch(1); + + queue->PostJobForDescriptor(PipelineDescriptor{}, + [&]() { latch.CountDown(); }); + + latch.Wait(); + + fml::CountDownLatch latch2(1); + queue->PostJobForDescriptor(PipelineDescriptor{}, + [&]() { latch2.CountDown(); }); + + latch2.Wait(); + + SUCCEED(); + thread.Join(); +} + +TEST(PipelineCompileQueueGLESTest, DestroyQueueWithPendingTasks) { + fml::Thread thread; + std::atomic completed_jobs{0}; + fml::CountDownLatch latch(3); + + { + auto queue = + PipelineCompileQueueGLES::Create(CreateBasicTaskRunner(thread)); + ASSERT_NE(queue, nullptr); + + PipelineDescriptor desc1; + desc1.SetSampleCount(SampleCount::kCount1); + desc1.SetCullMode(CullMode::kNone); + + PipelineDescriptor desc2; + desc2.SetSampleCount(SampleCount::kCount1); + desc2.SetCullMode(CullMode::kFrontFace); + + PipelineDescriptor desc3; + desc3.SetSampleCount(SampleCount::kCount1); + desc3.SetCullMode(CullMode::kBackFace); + + queue->PostJobForDescriptor(desc1, [&]() { + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + completed_jobs++; + latch.CountDown(); + }); + + queue->PostJobForDescriptor(desc2, [&]() { + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + completed_jobs++; + latch.CountDown(); + }); + + queue->PostJobForDescriptor(desc3, [&]() { + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + completed_jobs++; + latch.CountDown(); + }); + + // Queue will be destroyed here with pending jobs. + // The destructor should ensure that the pending jobs are either executed + // or posted to the queue's thread. + } + + // Wait for completion of the jobs. + latch.Wait(); + EXPECT_EQ(completed_jobs, 3); + + thread.Join(); +} + +} // namespace testing +} // namespace impeller diff --git a/engine/src/flutter/impeller/renderer/backend/gles/test/texture_gles_unittests.cc b/engine/src/flutter/impeller/renderer/backend/gles/test/texture_gles_unittests.cc index 2a2090696218b..06adbe9f032c4 100644 --- a/engine/src/flutter/impeller/renderer/backend/gles/test/texture_gles_unittests.cc +++ b/engine/src/flutter/impeller/renderer/backend/gles/test/texture_gles_unittests.cc @@ -2,6 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +#include + #include "flutter/impeller/playground/playground_test.h" #include "flutter/impeller/renderer/backend/gles/context_gles.h" #include "flutter/impeller/renderer/backend/gles/texture_gles.h" @@ -148,6 +150,39 @@ TEST_P(TextureGLESTest, Leak) { EXPECT_FALSE(handle.has_value()); } +TEST_P(TextureGLESTest, CanCreateAndUpload2DArrayTexture) { + ContextGLES& context_gles = ContextGLES::Cast(*GetContext()); + if (!context_gles.GetReactor() + ->GetProcTable() + .GetCapabilities() + ->SupportsTextureArray()) { + GTEST_SKIP() << "2D array textures are not supported on this context."; + } + + TextureDescriptor desc; + desc.storage_mode = StorageMode::kHostVisible; + desc.size = {2, 2}; + desc.format = PixelFormat::kR8G8B8A8UNormInt; + desc.type = TextureType::kTexture2DArray; + desc.array_layer_count = 3; + desc.mip_count = 1; + + auto texture = GetContext()->GetResourceAllocator()->CreateTexture(desc); + ASSERT_TRUE(texture); + EXPECT_EQ(static_cast(texture->GetTextureDescriptor().array_layer_count), + 3); + EXPECT_TRUE(texture->IsSliceValid(2)); + EXPECT_FALSE(texture->IsSliceValid(3)); + + // Every layer can be uploaded. + std::vector layer(2u * 2u * 4u, 0xFF); + for (size_t slice = 0; slice < static_cast(desc.array_layer_count); + ++slice) { + EXPECT_TRUE(texture->SetContents(layer.data(), layer.size(), slice)); + } + EXPECT_TRUE(context_gles.GetReactor()->React()); +} + TEST_P(TextureGLESTest, CreatingAndBindingEmptyTexturesDoesNotCrash) { ContextGLES& context_gles = ContextGLES::Cast(*GetContext()); const ProcTableGLES& gl = context_gles.GetReactor()->GetProcTable(); diff --git a/engine/src/flutter/impeller/renderer/backend/gles/texture_gles.cc b/engine/src/flutter/impeller/renderer/backend/gles/texture_gles.cc index 4c00888683432..a19756936f464 100644 --- a/engine/src/flutter/impeller/renderer/backend/gles/texture_gles.cc +++ b/engine/src/flutter/impeller/renderer/backend/gles/texture_gles.cc @@ -163,6 +163,7 @@ TextureGLES::TextureGLES(std::shared_ptr reactor, if (!GetTextureDescriptor().IsValid()) { return; } + // Ensure the texture doesn't exceed device capabilities. const auto tex_size = GetTextureDescriptor().size; const auto max_size = @@ -261,12 +262,31 @@ bool TextureGLES::OnSetContents(std::shared_ptr mapping, texture_type = GL_TEXTURE_CUBE_MAP; texture_target = GL_TEXTURE_CUBE_MAP_POSITIVE_X + slice; break; + case TextureType::kTexture2DArray: + texture_type = GL_TEXTURE_2D_ARRAY; + texture_target = GL_TEXTURE_2D_ARRAY; + break; case TextureType::kTextureExternalOES: texture_type = GL_TEXTURE_EXTERNAL_OES; texture_target = GL_TEXTURE_EXTERNAL_OES; break; } + // Array textures allocate all layers up front (glTexImage3D); a per-layer + // upload then fills one layer with glTexSubImage3D, so make sure the storage + // exists before uploading. + const bool is_array = tex_descriptor.type == TextureType::kTexture2DArray; + if (is_array) { + // Bail out synchronously on contexts without array support (e.g. ES 2.0). + // The glTexImage3D/glTexSubImage3D procs are null there, so queuing the + // upload would dereference a null proc on the reactor thread. + if (!reactor_->GetProcTable().GetCapabilities()->SupportsTextureArray()) { + VALIDATION_LOG << "2D array textures are not supported on this context."; + return false; + } + InitializeContentsIfNecessary(); + } + std::optional gles_format = ToPixelFormatGLES(tex_descriptor.format, /*supports_bgra=*/ @@ -284,7 +304,9 @@ bool TextureGLES::OnSetContents(std::shared_ptr mapping, size = tex_descriptor.size, // image_size = tex_descriptor.GetByteSizeOfBaseMipLevel(), // texture_type, // - texture_target // + texture_target, // + is_array, // + slice // ](const auto& reactor) { auto gl_handle = reactor.GetGLHandle(handle); if (!gl_handle.has_value()) { @@ -303,7 +325,37 @@ bool TextureGLES::OnSetContents(std::shared_ptr mapping, TRACE_EVENT1("impeller", "TexImage2DUpload", "Bytes", std::to_string(mapping->GetSize()).c_str()); gl.PixelStorei(GL_UNPACK_ALIGNMENT, 1); - if (format.is_compressed) { + if (is_array) { + // Storage for every layer is allocated by the initializer; fill the + // requested layer (slice) of the already-allocated base mip level. + if (format.is_compressed) { + gl.CompressedTexSubImage3D( + /*target=*/texture_target, // + /*level=*/0u, // + /*xoffset=*/0u, // + /*yoffset=*/0u, // + /*zoffset=*/static_cast(slice), // + /*width=*/size.width, // + /*height=*/size.height, // + /*depth=*/1, // + /*format=*/format.internal_format, // + /*image_size=*/image_size, // + /*data=*/tex_data); // + } else { + gl.TexSubImage3D( + /*target=*/texture_target, // + /*level=*/0u, // + /*xoffset=*/0u, // + /*yoffset=*/0u, // + /*zoffset=*/static_cast(slice), // + /*width=*/size.width, // + /*height=*/size.height, // + /*depth=*/1, // + /*format=*/format.external_format, // + /*type=*/format.type, // + /*data=*/tex_data); // + } + } else if (format.is_compressed) { gl.CompressedTexImage2D(texture_target, // target 0u, // LOD level format.internal_format, // internal format @@ -453,6 +505,31 @@ void TextureGLES::InitializeContentsIfNecessary() { ); MarkSliceMipLevelInitialized(face, 0); } + } else if (desc.type == TextureType::kTexture2DArray) { + if (!gl.GetCapabilities()->SupportsTextureArray()) { + VALIDATION_LOG << "2D array textures are not supported on this " + "context."; + return; + } + // Array textures allocate the whole base mip level (all layers) in one + // glTexImage3D call; individual layers are then filled with + // glTexSubImage3D. Non-zero mip levels are allocated lazily. + gl.BindTexture(GL_TEXTURE_2D_ARRAY, handle.value()); + gl.TexImage3D( + /*target=*/GL_TEXTURE_2D_ARRAY, // + /*level=*/0u, // + /*internal_format=*/gles_format->internal_format, // + /*width=*/size.width, // + /*height=*/size.height, // + /*depth=*/desc.array_layer_count, // + /*border=*/0u, // + /*format=*/gles_format->external_format, // + /*type=*/gles_format->type, // + /*data=*/nullptr // + ); + // glTexImage3D allocated every layer of the base level at once, so a + // single entry covers the whole level. + MarkSliceMipLevelInitialized(0, 0); } else { // 2D / multisampled. External-OES textures are always wrapped, so // they returned at the is_wrapped_ check above. Only the base mip @@ -609,6 +686,8 @@ bool TextureGLES::GenerateMipmap() { return false; case TextureType::kTextureCube: break; + case TextureType::kTexture2DArray: + break; case TextureType::kTextureExternalOES: break; } @@ -665,21 +744,50 @@ bool TextureGLES::EnsureSliceMipLevelStorage(size_t slice, size_t mip_level) { return false; } ISize size = GetSize(); + const GLsizei mip_width = + static_cast(std::max(1, size.width >> mip_level)); + const GLsizei mip_height = + static_cast(std::max(1, size.height >> mip_level)); + + if (desc.type == TextureType::kTexture2DArray) { + // glTexImage3D allocates this mip level for every layer at once, so a + // single entry (slice 0) tracks the whole level regardless of which layer + // was requested. Guard on that entry so a request for a non-zero layer + // does not re-allocate and orphan already-uploaded layers. + if (IsSliceMipLevelInitialized(0, mip_level)) { + return true; + } + gl.BindTexture(GL_TEXTURE_2D_ARRAY, handle.value()); + gl.TexImage3D( + /*target=*/GL_TEXTURE_2D_ARRAY, // + /*level=*/static_cast(mip_level), // + /*internal_format=*/gles_format->internal_format, // + /*width=*/mip_width, // + /*height=*/mip_height, // + /*depth=*/desc.array_layer_count, // + /*border=*/0u, // + /*format=*/gles_format->external_format, // + /*type=*/gles_format->type, // + /*data=*/nullptr // + ); + MarkSliceMipLevelInitialized(0, mip_level); + return true; + } + bool is_cube = desc.type == TextureType::kTextureCube; GLenum image_target = is_cube ? GL_TEXTURE_CUBE_MAP_POSITIVE_X + slice : GL_TEXTURE_2D; gl.BindTexture(is_cube ? GL_TEXTURE_CUBE_MAP : GL_TEXTURE_2D, handle.value()); - gl.TexImage2D(image_target, // target - static_cast(mip_level), // LOD level - gles_format->internal_format, // internal - static_cast( - std::max(1, size.width >> mip_level)), // width - static_cast( - std::max(1, size.height >> mip_level)), // height - 0u, // border - gles_format->external_format, // format - gles_format->type, // type - nullptr // data + gl.TexImage2D( + /*target=*/image_target, // + /*level=*/static_cast(mip_level), // + /*internal_format=*/gles_format->internal_format, // + /*width=*/mip_width, // + /*height=*/mip_height, // + /*border=*/0u, // + /*format=*/gles_format->external_format, // + /*type=*/gles_format->type, // + /*data=*/nullptr // ); MarkSliceMipLevelInitialized(slice, mip_level); return true; diff --git a/engine/src/flutter/impeller/renderer/backend/gles/texture_gles.h b/engine/src/flutter/impeller/renderer/backend/gles/texture_gles.h index c1a52f0946a56..8db5ad868215b 100644 --- a/engine/src/flutter/impeller/renderer/backend/gles/texture_gles.h +++ b/engine/src/flutter/impeller/renderer/backend/gles/texture_gles.h @@ -188,8 +188,12 @@ class TextureGLES final : public Texture, // pipeline) keeps its single base-level allocation, and per-level uploads // only pay for the levels they actually touch. // - // Sized for up to 6 cubemap faces × 16 mip levels (covers a 32k base - // dimension); requested levels above this are simply not tracked. + // One entry per independently-allocated slice: 6 for a cube (each face is a + // separate `glTexImage2D`), 1 otherwise. Array textures also use a single + // entry because `glTexImage3D` allocates every layer of a mip level at once, + // so there is nothing per-layer to track. Each entry covers up to 16 mip + // levels (a 32k base dimension); requested levels above this are simply not + // tracked. static constexpr size_t kMaxTrackedMipLevels = 16; std::array, 6> slice_mip_initialized_ = {}; const bool is_wrapped_; diff --git a/engine/src/flutter/impeller/renderer/backend/metal/formats_mtl.h b/engine/src/flutter/impeller/renderer/backend/metal/formats_mtl.h index 7c36087dc7edb..a0845438e3a60 100644 --- a/engine/src/flutter/impeller/renderer/backend/metal/formats_mtl.h +++ b/engine/src/flutter/impeller/renderer/backend/metal/formats_mtl.h @@ -440,6 +440,8 @@ constexpr MTLTextureType ToMTLTextureType(TextureType type) { return MTLTextureType2DMultisample; case TextureType::kTextureCube: return MTLTextureTypeCube; + case TextureType::kTexture2DArray: + return MTLTextureType2DArray; case TextureType::kTextureExternalOES: VALIDATION_LOG << "kTextureExternalOES can not be used with the Metal backend."; diff --git a/engine/src/flutter/impeller/renderer/backend/metal/formats_mtl.mm b/engine/src/flutter/impeller/renderer/backend/metal/formats_mtl.mm index cb6eee57f543a..c377a5b5f6142 100644 --- a/engine/src/flutter/impeller/renderer/backend/metal/formats_mtl.mm +++ b/engine/src/flutter/impeller/renderer/backend/metal/formats_mtl.mm @@ -94,6 +94,9 @@ mtl_desc.width = desc.size.width; mtl_desc.height = desc.size.height; mtl_desc.mipmapLevelCount = desc.mip_count; + if (desc.type == TextureType::kTexture2DArray) { + mtl_desc.arrayLength = desc.array_layer_count; + } mtl_desc.usage = MTLTextureUsageUnknown; if (desc.usage & TextureUsage::kUnknown) { mtl_desc.usage |= MTLTextureUsageUnknown; diff --git a/engine/src/flutter/impeller/renderer/backend/vulkan/BUILD.gn b/engine/src/flutter/impeller/renderer/backend/vulkan/BUILD.gn index 3135074b219ec..afa6489d4bf72 100644 --- a/engine/src/flutter/impeller/renderer/backend/vulkan/BUILD.gn +++ b/engine/src/flutter/impeller/renderer/backend/vulkan/BUILD.gn @@ -41,6 +41,7 @@ impeller_component("vulkan_unittests") { "surface_context_vk_unittests.cc", "test/gpu_tracer_unittests.cc", "test/mock_vulkan_unittests.cc", + "test/pipeline_compile_queue_vulkan_unittests.cc", "test/sampler_library_vk_unittests.cc", "test/swapchain_unittests.cc", ] @@ -94,6 +95,8 @@ impeller_component("vulkan") { "pipeline_cache_data_vk.h", "pipeline_cache_vk.cc", "pipeline_cache_vk.h", + "pipeline_compile_queue_vulkan.cc", + "pipeline_compile_queue_vulkan.h", "pipeline_library_vk.cc", "pipeline_library_vk.h", "pipeline_vk.cc", diff --git a/engine/src/flutter/impeller/renderer/backend/vulkan/allocator_vk.cc b/engine/src/flutter/impeller/renderer/backend/vulkan/allocator_vk.cc index 20313ea2aad52..9d1820aff132b 100644 --- a/engine/src/flutter/impeller/renderer/backend/vulkan/allocator_vk.cc +++ b/engine/src/flutter/impeller/renderer/backend/vulkan/allocator_vk.cc @@ -308,7 +308,7 @@ class AllocatedTextureSourceVK final : public TextureSourceVK { }; image_info.samples = ToVKSampleCount(desc.sample_count); image_info.mipLevels = desc.mip_count; - image_info.arrayLayers = ToArrayLayerCount(desc.type); + image_info.arrayLayers = ToArrayLayerCount(desc); image_info.tiling = vk::ImageTiling::eOptimal; image_info.initialLayout = vk::ImageLayout::eUndefined; image_info.usage = AllocatorVK::ToVKImageUsageFlags( @@ -412,7 +412,7 @@ class AllocatedTextureSourceVK final : public TextureSourceVK { view_info.format = image_info.format; view_info.subresourceRange.aspectMask = ToVKImageAspectFlags(desc.format); view_info.subresourceRange.levelCount = image_info.mipLevels; - view_info.subresourceRange.layerCount = ToArrayLayerCount(desc.type); + view_info.subresourceRange.layerCount = ToArrayLayerCount(desc); // Vulkan does not have an image format that is equivalent to // `MTLPixelFormatA8Unorm`, so we use `R8Unorm` instead. Given that the @@ -439,7 +439,7 @@ class AllocatedTextureSourceVK final : public TextureSourceVK { const bool is_render_target = !!(desc.usage & TextureUsage::kRenderTarget); const uint32_t rt_mip_count = is_render_target ? image_info.mipLevels : 1u; const uint32_t rt_layer_count = - is_render_target ? ToArrayLayerCount(desc.type) : 1u; + is_render_target ? ToArrayLayerCount(desc) : 1u; std::vector rt_image_views; rt_image_views.reserve(rt_mip_count * rt_layer_count); for (uint32_t mip = 0; mip < rt_mip_count; mip++) { @@ -480,7 +480,7 @@ class AllocatedTextureSourceVK final : public TextureSourceVK { if (views.empty()) { return VK_NULL_HANDLE; } - const uint32_t layer_count = ToArrayLayerCount(GetTextureDescriptor().type); + const uint32_t layer_count = ToArrayLayerCount(GetTextureDescriptor()); const size_t index = static_cast(mip_level) * layer_count + array_layer; return index < views.size() ? views[index].get() : views[0].get(); diff --git a/engine/src/flutter/impeller/renderer/backend/vulkan/formats_vk.h b/engine/src/flutter/impeller/renderer/backend/vulkan/formats_vk.h index d8fa3721207bd..1c32f7fc6ce97 100644 --- a/engine/src/flutter/impeller/renderer/backend/vulkan/formats_vk.h +++ b/engine/src/flutter/impeller/renderer/backend/vulkan/formats_vk.h @@ -12,6 +12,7 @@ #include "impeller/base/validation.h" #include "impeller/core/formats.h" #include "impeller/core/shader_types.h" +#include "impeller/core/texture_descriptor.h" #include "impeller/renderer/backend/vulkan/vk.h" #include "vulkan/vulkan_enums.hpp" @@ -608,11 +609,13 @@ constexpr vk::ImageAspectFlags ToVKImageAspectFlags(PixelFormat format) { FML_UNREACHABLE(); } -constexpr uint32_t ToArrayLayerCount(TextureType type) { - switch (type) { +constexpr uint32_t ToArrayLayerCount(const TextureDescriptor& desc) { + switch (desc.type) { case TextureType::kTexture2D: case TextureType::kTexture2DMultisample: return 1u; + case TextureType::kTexture2DArray: + return desc.array_layer_count; case TextureType::kTextureCube: return 6u; case TextureType::kTextureExternalOES: @@ -627,6 +630,8 @@ constexpr vk::ImageViewType ToVKImageViewType(TextureType type) { case TextureType::kTexture2D: case TextureType::kTexture2DMultisample: return vk::ImageViewType::e2D; + case TextureType::kTexture2DArray: + return vk::ImageViewType::e2DArray; case TextureType::kTextureCube: return vk::ImageViewType::eCube; case TextureType::kTextureExternalOES: @@ -640,6 +645,7 @@ constexpr vk::ImageCreateFlags ToVKImageCreateFlags(TextureType type) { switch (type) { case TextureType::kTexture2D: case TextureType::kTexture2DMultisample: + case TextureType::kTexture2DArray: return {}; case TextureType::kTextureCube: return vk::ImageCreateFlagBits::eCubeCompatible; diff --git a/engine/src/flutter/impeller/renderer/backend/vulkan/pipeline_compile_queue_vulkan.cc b/engine/src/flutter/impeller/renderer/backend/vulkan/pipeline_compile_queue_vulkan.cc new file mode 100644 index 0000000000000..aed83a4cebdeb --- /dev/null +++ b/engine/src/flutter/impeller/renderer/backend/vulkan/pipeline_compile_queue_vulkan.cc @@ -0,0 +1,45 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "impeller/renderer/backend/vulkan/pipeline_compile_queue_vulkan.h" + +#include "flutter/fml/logging.h" +#include "flutter/fml/trace_event.h" + +namespace impeller { + +std::shared_ptr PipelineCompileQueueVulkan::Create( + std::shared_ptr worker_task_runner) { + if (!worker_task_runner) { + return nullptr; + } + return std::shared_ptr( + new PipelineCompileQueueVulkan(std::move(worker_task_runner))); +} + +PipelineCompileQueueVulkan::PipelineCompileQueueVulkan( + std::shared_ptr worker_task_runner) + : PipelineCompileQueue(), + worker_task_runner_(std::move(worker_task_runner)) {} + +PipelineCompileQueueVulkan::~PipelineCompileQueueVulkan() = default; + +void PipelineCompileQueueVulkan::OnJobAdded() { + PostJob([weak_queue = weak_from_this()]() { + if (auto queue = std::static_pointer_cast( + weak_queue.lock())) { + queue->DoOneJob(); + } + }); +} + +void PipelineCompileQueueVulkan::PostJob(const fml::closure& job) { + if (!job) { + return; + } + + worker_task_runner_->PostTask(job); +} + +} // namespace impeller diff --git a/engine/src/flutter/impeller/renderer/backend/vulkan/pipeline_compile_queue_vulkan.h b/engine/src/flutter/impeller/renderer/backend/vulkan/pipeline_compile_queue_vulkan.h new file mode 100644 index 0000000000000..5a65c0ebc4997 --- /dev/null +++ b/engine/src/flutter/impeller/renderer/backend/vulkan/pipeline_compile_queue_vulkan.h @@ -0,0 +1,56 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#ifndef FLUTTER_IMPELLER_RENDERER_BACKEND_VULKAN_PIPELINE_COMPILE_QUEUE_VULKAN_H_ +#define FLUTTER_IMPELLER_RENDERER_BACKEND_VULKAN_PIPELINE_COMPILE_QUEUE_VULKAN_H_ + +#include "flutter/fml/closure.h" +#include "flutter/fml/task_runner.h" +#include "impeller/renderer/pipeline_compile_queue.h" + +namespace impeller { + +//------------------------------------------------------------------------------ +/// @brief A task queue designed for managing compilation of pipeline state +/// objects for Vulkan backend. +/// +/// This subclass uses a fml::BasicTaskRunner as the worker task +/// runner and dispatches compile jobs directly without sequential +/// processing constraints. +/// +/// Key characteristics: +/// - Uses std::shared_ptr for +/// worker_task_runner_ +/// - Dispatches jobs directly to the task runner in OnJobAdded() +/// - Does not implement sequential processing like GLES version +/// +/// The Vulkan backend benefits from the parallel nature of pipeline +/// compilation, allowing multiple compile jobs to be processed +/// concurrently through the task runner. +/// +class PipelineCompileQueueVulkan : public PipelineCompileQueue { + public: + static std::shared_ptr Create( + std::shared_ptr worker_task_runner); + + ~PipelineCompileQueueVulkan() override; + + PipelineCompileQueueVulkan(const PipelineCompileQueueVulkan&) = delete; + + PipelineCompileQueueVulkan& operator=(const PipelineCompileQueueVulkan&) = + delete; + + void PostJob(const fml::closure& job) override; + + void OnJobAdded() override; + + private: + explicit PipelineCompileQueueVulkan( + std::shared_ptr worker_task_runner); + std::shared_ptr worker_task_runner_; +}; + +} // namespace impeller + +#endif // FLUTTER_IMPELLER_RENDERER_BACKEND_VULKAN_PIPELINE_COMPILE_QUEUE_VULKAN_H_ diff --git a/engine/src/flutter/impeller/renderer/backend/vulkan/pipeline_library_vk.cc b/engine/src/flutter/impeller/renderer/backend/vulkan/pipeline_library_vk.cc index 95b3351049b39..6e8d9544f7dc5 100644 --- a/engine/src/flutter/impeller/renderer/backend/vulkan/pipeline_library_vk.cc +++ b/engine/src/flutter/impeller/renderer/backend/vulkan/pipeline_library_vk.cc @@ -27,7 +27,7 @@ PipelineLibraryVK::PipelineLibraryVK( device_holder, std::move(cache_directory))), worker_task_runner_(std::move(worker_task_runner)), - compile_queue_(PipelineCompileQueue::Create(worker_task_runner_)) { + compile_queue_(PipelineCompileQueueVulkan::Create(worker_task_runner_)) { FML_DCHECK(worker_task_runner_); if (!pso_cache_->IsValid() || !worker_task_runner_) { return; diff --git a/engine/src/flutter/impeller/renderer/backend/vulkan/pipeline_library_vk.h b/engine/src/flutter/impeller/renderer/backend/vulkan/pipeline_library_vk.h index 3ead3c7dc700d..4304db9b58293 100644 --- a/engine/src/flutter/impeller/renderer/backend/vulkan/pipeline_library_vk.h +++ b/engine/src/flutter/impeller/renderer/backend/vulkan/pipeline_library_vk.h @@ -13,10 +13,10 @@ #include "impeller/base/thread.h" #include "impeller/renderer/backend/vulkan/compute_pipeline_vk.h" #include "impeller/renderer/backend/vulkan/pipeline_cache_vk.h" +#include "impeller/renderer/backend/vulkan/pipeline_compile_queue_vulkan.h" #include "impeller/renderer/backend/vulkan/pipeline_vk.h" #include "impeller/renderer/backend/vulkan/vk.h" #include "impeller/renderer/pipeline.h" -#include "impeller/renderer/pipeline_compile_queue.h" #include "impeller/renderer/pipeline_library.h" namespace impeller { @@ -49,7 +49,7 @@ class PipelineLibraryVK final PipelineKey pipeline_key_ IPLR_GUARDED_BY(pipelines_mutex_) = 1; bool is_valid_ = false; bool cache_dirty_ = false; - std::shared_ptr compile_queue_; + std::shared_ptr compile_queue_; PipelineLibraryVK( const std::shared_ptr& device_holder, diff --git a/engine/src/flutter/impeller/renderer/backend/vulkan/swapchain/khr/khr_swapchain_image_vk.cc b/engine/src/flutter/impeller/renderer/backend/vulkan/swapchain/khr/khr_swapchain_image_vk.cc index 613a2e1bcd8af..8b54b61ecff4e 100644 --- a/engine/src/flutter/impeller/renderer/backend/vulkan/swapchain/khr/khr_swapchain_image_vk.cc +++ b/engine/src/flutter/impeller/renderer/backend/vulkan/swapchain/khr/khr_swapchain_image_vk.cc @@ -18,7 +18,7 @@ KHRSwapchainImageVK::KHRSwapchainImageVK(TextureDescriptor desc, view_info.subresourceRange.baseMipLevel = 0u; view_info.subresourceRange.baseArrayLayer = 0u; view_info.subresourceRange.levelCount = desc.mip_count; - view_info.subresourceRange.layerCount = ToArrayLayerCount(desc.type); + view_info.subresourceRange.layerCount = ToArrayLayerCount(desc); auto [view_result, view] = device.createImageViewUnique(view_info); if (view_result != vk::Result::eSuccess) { diff --git a/engine/src/flutter/impeller/renderer/backend/vulkan/swapchain/khr/khr_swapchain_vk.cc b/engine/src/flutter/impeller/renderer/backend/vulkan/swapchain/khr/khr_swapchain_vk.cc index 339a01b1018fb..286a9f4355cbc 100644 --- a/engine/src/flutter/impeller/renderer/backend/vulkan/swapchain/khr/khr_swapchain_vk.cc +++ b/engine/src/flutter/impeller/renderer/backend/vulkan/swapchain/khr/khr_swapchain_vk.cc @@ -57,9 +57,13 @@ std::unique_ptr KHRSwapchainVK::AcquireNextDrawable( TRACE_EVENT0("impeller", __FUNCTION__); - auto result = impl_->AcquireNextDrawable(); - if (!result.out_of_date && size_ == impl_->GetSize()) { - return std::move(result.surface); + // Do not call AcquireNextDrawable if the impl_ has a mismatched size. + // Acquiring an image without presenting it can cause leaks. + if (size_ == impl_->GetSize()) { + auto result = impl_->AcquireNextDrawable(); + if (!result.out_of_date) { + return std::move(result.surface); + } } // When the swapchain says its out-of-date, we attempt to read the underlying diff --git a/engine/src/flutter/impeller/renderer/backend/vulkan/test/pipeline_compile_queue_vulkan_unittests.cc b/engine/src/flutter/impeller/renderer/backend/vulkan/test/pipeline_compile_queue_vulkan_unittests.cc new file mode 100644 index 0000000000000..6ee373535fd39 --- /dev/null +++ b/engine/src/flutter/impeller/renderer/backend/vulkan/test/pipeline_compile_queue_vulkan_unittests.cc @@ -0,0 +1,181 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "impeller/renderer/backend/vulkan/pipeline_compile_queue_vulkan.h" + +#include +#include +#include +#include + +#include "flutter/fml/synchronization/count_down_latch.h" +#include "flutter/fml/task_runner.h" +#include "flutter/testing/testing.h" +#include "impeller/renderer/pipeline_descriptor.h" + +namespace impeller { +namespace testing { + +TEST(PipelineCompileQueueVulkanTest, CreateSucceedsWithValidTaskRunner) { + auto loop = fml::ConcurrentMessageLoop::Create(); + auto queue = PipelineCompileQueueVulkan::Create(loop->GetTaskRunner()); + EXPECT_NE(queue, nullptr); +} + +TEST(PipelineCompileQueueVulkanTest, PostJobDoesNothingWithNullClosure) { + auto loop = fml::ConcurrentMessageLoop::Create(); + auto queue = PipelineCompileQueueVulkan::Create(loop->GetTaskRunner()); + ASSERT_NE(queue, nullptr); + + queue->PostJob(nullptr); +} + +TEST(PipelineCompileQueueVulkanTest, OnJobAddedProcessesJobsInParallel) { + auto loop = fml::ConcurrentMessageLoop::Create(); + auto queue = PipelineCompileQueueVulkan::Create(loop->GetTaskRunner()); + ASSERT_NE(queue, nullptr); + + std::atomic concurrent_jobs{0}; + std::atomic max_concurrent{0}; + fml::CountDownLatch latch(3); + + PipelineDescriptor desc1; + desc1.SetSampleCount(SampleCount::kCount1); + desc1.SetCullMode(CullMode::kNone); + + PipelineDescriptor desc2; + desc2.SetSampleCount(SampleCount::kCount1); + desc2.SetCullMode(CullMode::kFrontFace); + + PipelineDescriptor desc3; + desc3.SetSampleCount(SampleCount::kCount1); + desc3.SetCullMode(CullMode::kBackFace); + + queue->PostJobForDescriptor(desc1, [&]() { + int current = ++concurrent_jobs; + int prev_max = max_concurrent.load(); + while (current > prev_max && + !max_concurrent.compare_exchange_weak(prev_max, current)) { + } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + concurrent_jobs--; + latch.CountDown(); + }); + + queue->PostJobForDescriptor(desc2, [&]() { + int current = ++concurrent_jobs; + int prev_max = max_concurrent.load(); + while (current > prev_max && + !max_concurrent.compare_exchange_weak(prev_max, current)) { + } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + concurrent_jobs--; + latch.CountDown(); + }); + + queue->PostJobForDescriptor(desc3, [&]() { + int current = ++concurrent_jobs; + int prev_max = max_concurrent.load(); + while (current > prev_max && + !max_concurrent.compare_exchange_weak(prev_max, current)) { + } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + concurrent_jobs--; + latch.CountDown(); + }); + + latch.Wait(); + + EXPECT_GE(max_concurrent.load(), 1); +} + +TEST(PipelineCompileQueueVulkanTest, + PostJobForDescriptorWithDuplicateRunsEagerly) { + auto loop = fml::ConcurrentMessageLoop::Create(); + auto queue = PipelineCompileQueueVulkan::Create(loop->GetTaskRunner()); + ASSERT_NE(queue, nullptr); + + std::atomic first_job_count{0}; + std::atomic second_job_count{0}; + fml::CountDownLatch latch(2); + + PipelineDescriptor desc; + + queue->PostJobForDescriptor(desc, [&]() { + first_job_count++; + latch.CountDown(); + }); + + queue->PostJobForDescriptor(desc, [&]() { + second_job_count++; + latch.CountDown(); + }); + + latch.Wait(); + + EXPECT_EQ(first_job_count, 1); + EXPECT_EQ(second_job_count, 1); +} + +TEST(PipelineCompileQueueVulkanTest, MultipleJobsCompleteSuccessfully) { + auto loop = fml::ConcurrentMessageLoop::Create(); + auto queue = PipelineCompileQueueVulkan::Create(loop->GetTaskRunner()); + ASSERT_NE(queue, nullptr); + + std::atomic completed_jobs{0}; + fml::CountDownLatch latch(5); + + PipelineDescriptor desc1; + desc1.SetSampleCount(SampleCount::kCount1); + desc1.SetCullMode(CullMode::kNone); + + PipelineDescriptor desc2; + desc2.SetSampleCount(SampleCount::kCount1); + desc2.SetCullMode(CullMode::kFrontFace); + + PipelineDescriptor desc3; + desc3.SetSampleCount(SampleCount::kCount1); + desc3.SetCullMode(CullMode::kBackFace); + + PipelineDescriptor desc4; + desc4.SetSampleCount(SampleCount::kCount4); + desc4.SetCullMode(CullMode::kNone); + + PipelineDescriptor desc5; + desc5.SetSampleCount(SampleCount::kCount4); + desc5.SetCullMode(CullMode::kFrontFace); + + // Post 5 jobs with distinct descriptors + queue->PostJobForDescriptor(desc1, [&]() { + completed_jobs++; + latch.CountDown(); + }); + + queue->PostJobForDescriptor(desc2, [&]() { + completed_jobs++; + latch.CountDown(); + }); + + queue->PostJobForDescriptor(desc3, [&]() { + completed_jobs++; + latch.CountDown(); + }); + + queue->PostJobForDescriptor(desc4, [&]() { + completed_jobs++; + latch.CountDown(); + }); + + queue->PostJobForDescriptor(desc5, [&]() { + completed_jobs++; + latch.CountDown(); + }); + + latch.Wait(); + + EXPECT_EQ(completed_jobs, 5); +} + +} // namespace testing +} // namespace impeller diff --git a/engine/src/flutter/impeller/renderer/backend/vulkan/test/swapchain_unittests.cc b/engine/src/flutter/impeller/renderer/backend/vulkan/test/swapchain_unittests.cc index 847ba0938a2f8..a346156c0c037 100644 --- a/engine/src/flutter/impeller/renderer/backend/vulkan/test/swapchain_unittests.cc +++ b/engine/src/flutter/impeller/renderer/backend/vulkan/test/swapchain_unittests.cc @@ -163,5 +163,42 @@ TEST(SwapchainTest, NoFenceWaitAfterAcquireNextImageFailure) { EXPECT_FALSE(wait_for_fences_called); } +TEST(SwapchainTest, SwapchainSizeChangeDoesNotAcquireDrawable) { + int acquire_call_count = 0; + auto const context = + MockVulkanContextBuilder() + .SetAcquireNextImageCallback([&](VkDevice, VkSwapchainKHR, uint64_t, + VkSemaphore, VkFence, + uint32_t* pImageIndex) -> VkResult { + *pImageIndex = 0; + acquire_call_count++; + return VK_SUCCESS; + }) + .Build(); + + auto surface = CreateSurface(*context); + ISize original_size(1, 1); + SetSwapchainImageSize(original_size); + auto swapchain = + KHRSwapchainVK::Create(context, std::move(surface), original_size, + /*enable_msaa=*/false); + auto image = swapchain->AcquireNextDrawable(); + ASSERT_NE(image, nullptr); + EXPECT_EQ(image->GetSize(), original_size); + + ISize new_size(100, 100); + SetSwapchainImageSize(new_size); + swapchain->UpdateSurfaceSize(new_size); + + acquire_call_count = 0; + image = swapchain->AcquireNextDrawable(); + ASSERT_NE(image, nullptr); + EXPECT_EQ(image->GetSize(), new_size); + + // Verify that the call to AcquireNextDrawable after the resize did not make + // any extra calls to the underlying Vulkan API. + EXPECT_EQ(acquire_call_count, 1); +} + } // namespace testing } // namespace impeller diff --git a/engine/src/flutter/impeller/renderer/backend/vulkan/texture_source_vk.cc b/engine/src/flutter/impeller/renderer/backend/vulkan/texture_source_vk.cc index b8dae55071a40..13f41ef991887 100644 --- a/engine/src/flutter/impeller/renderer/backend/vulkan/texture_source_vk.cc +++ b/engine/src/flutter/impeller/renderer/backend/vulkan/texture_source_vk.cc @@ -45,7 +45,7 @@ fml::Status TextureSourceVK::SetLayout(const BarrierVK& barrier) const { image_barrier.subresourceRange.levelCount = desc_.mip_count - barrier.base_mip_level; image_barrier.subresourceRange.baseArrayLayer = 0u; - image_barrier.subresourceRange.layerCount = ToArrayLayerCount(desc_.type); + image_barrier.subresourceRange.layerCount = ToArrayLayerCount(desc_); barrier.cmd_buffer.pipelineBarrier(barrier.src_stage, // src stage barrier.dst_stage, // dst stage diff --git a/engine/src/flutter/impeller/renderer/pipeline_compile_queue.cc b/engine/src/flutter/impeller/renderer/pipeline_compile_queue.cc index 124e11e177346..da10b9be100b8 100644 --- a/engine/src/flutter/impeller/renderer/pipeline_compile_queue.cc +++ b/engine/src/flutter/impeller/renderer/pipeline_compile_queue.cc @@ -9,16 +9,6 @@ namespace impeller { -std::shared_ptr PipelineCompileQueue::Create( - std::shared_ptr worker_task_runner) { - return std::shared_ptr( - new PipelineCompileQueue(std::move(worker_task_runner))); -} - -PipelineCompileQueue::PipelineCompileQueue( - std::shared_ptr worker_task_runner) - : worker_task_runner_(std::move(worker_task_runner)) {} - PipelineCompileQueue::~PipelineCompileQueue() { FinishAllJobs(); } @@ -29,31 +19,33 @@ bool PipelineCompileQueue::PostJobForDescriptor(const PipelineDescriptor& desc, return false; } - { - Lock lock(pending_jobs_mutex_); - auto insertion_result = pending_jobs_.insert(std::make_pair(desc, job)); - if (!insertion_result.second) { - // This bit is being extremely conservative. If insertion did not take - // place, someone gave the compile queue a job for the same description. - // This is highly unusual but technically not impossible. Just run the job - // eagerly. - FML_LOG(ERROR) << "Got multiple compile jobs for the same descriptor. " + if (!AddJob(desc, job)) { + // This bit is being extremely conservative. If insertion did not take + // place, someone gave the compile queue a job for the same description. + // This is highly unusual but technically not impossible. Just run the job + // eagerly. + FML_LOG(WARNING) << "Got multiple compile jobs for the same descriptor. " "Running eagerly."; - // Don't invoke the job here has there are we have currently acquired a - // mutex. - worker_task_runner_->PostTask(job); - return true; - } + PostJob(job); + return true; } - worker_task_runner_->PostTask([weak_queue = weak_from_this()]() { - if (auto queue = weak_queue.lock()) { - queue->DoOneJob(); - } - }); + OnJobAdded(); return true; } +bool PipelineCompileQueue::AddJob(const PipelineDescriptor& desc, + const fml::closure& job) { + Lock lock(pending_jobs_mutex_); + auto insertion_result = pending_jobs_.insert(std::make_pair(desc, job)); + return insertion_result.second; +} + +bool PipelineCompileQueue::HasPendingJobs() { + Lock lock(pending_jobs_mutex_); + return !pending_jobs_.empty(); +} + fml::closure PipelineCompileQueue::TakeNextJob() { Lock lock(pending_jobs_mutex_); if (pending_jobs_.empty()) { diff --git a/engine/src/flutter/impeller/renderer/pipeline_compile_queue.h b/engine/src/flutter/impeller/renderer/pipeline_compile_queue.h index d166679b3798f..97940df514c8b 100644 --- a/engine/src/flutter/impeller/renderer/pipeline_compile_queue.h +++ b/engine/src/flutter/impeller/renderer/pipeline_compile_queue.h @@ -5,12 +5,11 @@ #ifndef FLUTTER_IMPELLER_RENDERER_PIPELINE_COMPILE_QUEUE_H_ #define FLUTTER_IMPELLER_RENDERER_PIPELINE_COMPILE_QUEUE_H_ -#include - #include "flutter/fml/closure.h" #include "flutter/fml/concurrent_message_loop.h" #include "impeller/base/thread.h" #include "impeller/renderer/pipeline_descriptor.h" +#include "third_party/abseil-cpp/absl/container/linked_hash_map.h" namespace impeller { @@ -39,13 +38,12 @@ namespace impeller { /// entirely optional. The queue skipping mechanism all assume the /// optional availability of a compile queue. /// -class PipelineCompileQueue final +class PipelineCompileQueue : public std::enable_shared_from_this { public: - static std::shared_ptr Create( - std::shared_ptr worker_task_runner); + PipelineCompileQueue() = default; - ~PipelineCompileQueue(); + virtual ~PipelineCompileQueue(); PipelineCompileQueue(const PipelineCompileQueue&) = delete; @@ -72,26 +70,68 @@ class PipelineCompileQueue final /// void PerformJobEagerly(const PipelineDescriptor& desc); - private: - std::shared_ptr worker_task_runner_; - Mutex pending_jobs_mutex_; - size_t priorities_elevated_ = {}; - - std::unordered_map, - ComparableEqual> - pending_jobs_ IPLR_GUARDED_BY(pending_jobs_mutex_); + protected: + //---------------------------------------------------------------------------- + /// @brief Post a compilation job to the worker task runner. + /// + /// This is a pure virtual function that must be implemented by + /// subclasses. It is responsible for actually dispatching the + /// job closure to the appropriate task runner for execution. + /// + /// @param[in] job The compilation job closure to post + /// + virtual void PostJob(const fml::closure& job) = 0; - explicit PipelineCompileQueue( - std::shared_ptr worker_task_runner); + //---------------------------------------------------------------------------- + /// @brief Called by PostJobForDescriptor after a job has been + /// successfully added to the queue. Subclasses must implement + /// this to define their scheduling strategy. + /// + /// The default implementation for duplicate descriptors is to + /// run the job eagerly. Subclasses can override this behavior + /// by checking for duplicates before calling the base class. + /// + virtual void OnJobAdded() = 0; - fml::closure TakeJob(const PipelineDescriptor& desc); + //---------------------------------------------------------------------------- + /// @brief Execute one pending compilation job from the queue. + /// + /// This method retrieves and executes a single job from the + /// pending jobs queue. It is typically called by subclasses + /// when they are ready to process the next job in the queue. + /// + void DoOneJob(); - fml::closure TakeNextJob(); + //---------------------------------------------------------------------------- + /// @brief Add a compilation job to the pending queue for the specified + /// descriptor. + /// + /// @param[in] desc The pipeline descriptor that identifies the job + /// @param[in] job The compilation job closure to add + /// + /// @return True if the job was successfully added to the queue, false + /// if a job for this descriptor already exists. + /// + bool AddJob(const PipelineDescriptor& desc, const fml::closure& job); - void DoOneJob(); + //---------------------------------------------------------------------------- + /// @brief Check if there are any pending compilation jobs in the queue. + /// + /// @return True if there are pending jobs waiting to be processed, + /// false otherwise. + /// + bool HasPendingJobs(); + private: + Mutex pending_jobs_mutex_; + absl::linked_hash_map, + ComparableEqual> + pending_jobs_ IPLR_GUARDED_BY(pending_jobs_mutex_); + size_t priorities_elevated_ = {}; + fml::closure TakeJob(const PipelineDescriptor& desc); + fml::closure TakeNextJob(); void FinishAllJobs(); }; diff --git a/engine/src/flutter/impeller/renderer/pipeline_compile_queue_unittests.cc b/engine/src/flutter/impeller/renderer/pipeline_compile_queue_unittests.cc new file mode 100644 index 0000000000000..9d9e0611b8f7a --- /dev/null +++ b/engine/src/flutter/impeller/renderer/pipeline_compile_queue_unittests.cc @@ -0,0 +1,120 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "impeller/renderer/pipeline_compile_queue.h" + +#include + +#include "flutter/fml/closure.h" +#include "flutter/testing/testing.h" + +namespace impeller { +namespace testing { + +class TestPipelineCompileQueue : public PipelineCompileQueue { + public: + void PostJob(const fml::closure& job) override { + if (job) { + job(); + } + } + + void OnJobAdded() override {} + + bool AddJobForTest(const PipelineDescriptor& desc, const fml::closure& job) { + return AddJob(desc, job); + } + + bool HasPendingJobsForTest() { return HasPendingJobs(); } + + void DoOneJobForTest() { DoOneJob(); } +}; + +TEST(PipelineCompileQueueTest, AddJobReturnsTrueForNewDescriptor) { + TestPipelineCompileQueue queue; + PipelineDescriptor desc; + bool job_executed = false; + fml::closure job = [&job_executed]() { job_executed = true; }; + + bool result = queue.AddJobForTest(desc, job); + EXPECT_TRUE(result); +} + +TEST(PipelineCompileQueueTest, AddJobReturnsFalseForDuplicateDescriptor) { + TestPipelineCompileQueue queue; + PipelineDescriptor desc; + bool job1_executed = false; + bool job2_executed = false; + fml::closure job1 = [&job1_executed]() { job1_executed = true; }; + fml::closure job2 = [&job2_executed]() { job2_executed = true; }; + + bool result1 = queue.AddJobForTest(desc, job1); + bool result2 = queue.AddJobForTest(desc, job2); + + EXPECT_TRUE(result1); + EXPECT_FALSE(result2); +} + +TEST(PipelineCompileQueueTest, HasPendingJobsReturnsCorrectState) { + TestPipelineCompileQueue queue; + PipelineDescriptor desc; + fml::closure job = []() {}; + + EXPECT_FALSE(queue.HasPendingJobsForTest()); + + queue.AddJobForTest(desc, job); + EXPECT_TRUE(queue.HasPendingJobsForTest()); +} + +TEST(PipelineCompileQueueTest, PerformJobEagerlyExecutesJob) { + TestPipelineCompileQueue queue; + PipelineDescriptor desc; + bool job_executed = false; + fml::closure job = [&job_executed]() { job_executed = true; }; + + queue.AddJobForTest(desc, job); + queue.PerformJobEagerly(desc); + + EXPECT_TRUE(job_executed); + EXPECT_FALSE(queue.HasPendingJobsForTest()); +} + +TEST(PipelineCompileQueueTest, FinishAllJobsDrainsQueue) { + auto queue = std::make_shared(); + PipelineDescriptor desc; + bool job_executed = false; + fml::closure job = [&job_executed]() { job_executed = true; }; + + queue->AddJobForTest(desc, job); + EXPECT_TRUE(queue->HasPendingJobsForTest()); + + queue.reset(); + + EXPECT_TRUE(job_executed); +} + +TEST(PipelineCompileQueueTest, ExecutesJobsInInsertionOrder) { + constexpr size_t kJobCount = 10; + auto queue = std::make_shared(); + + std::vector job_order; + for (size_t i = 0; i < kJobCount; i++) { + PipelineDescriptor desc; + desc.SetLabel(std::to_string(i)); + ASSERT_TRUE(queue->AddJobForTest( + desc, [&job_order, index = i] { job_order.push_back(index); })); + } + + for (size_t i = 0; i < kJobCount; i++) { + queue->DoOneJobForTest(); + } + + EXPECT_EQ(job_order.size(), kJobCount); + for (size_t i = 0; i < kJobCount; i++) { + EXPECT_EQ(i, job_order[i]); + } +} + +} // namespace testing +} // namespace impeller diff --git a/engine/src/flutter/impeller/renderer/renderer_unittests.cc b/engine/src/flutter/impeller/renderer/renderer_unittests.cc index e0b024b6245a0..4082b2ae2aac7 100644 --- a/engine/src/flutter/impeller/renderer/renderer_unittests.cc +++ b/engine/src/flutter/impeller/renderer/renderer_unittests.cc @@ -1616,6 +1616,39 @@ TEST_P(RendererTest, BindingNullTexturesDoesNotCrash) { EXPECT_FALSE(FS::BindContents2(*pass, nullptr, sampler)); } +// Creating and uploading per-layer contents of a 2D array texture. The GLES +// path requires ES 3.0 and is covered separately in the GLES texture unit +// tests, so this skips the GLES backends to avoid an ES 2.0 failure. +TEST_P(RendererTest, CanCreateAndUpload2DArrayTexture) { + if (GetBackend() == PlaygroundBackend::kOpenGLES || + GetBackend() == PlaygroundBackend::kOpenGLESSDF) { + GTEST_SKIP() << "Covered by the GLES-specific texture array test."; + } + auto context = GetContext(); + ASSERT_TRUE(context); + + TextureDescriptor desc; + desc.storage_mode = StorageMode::kHostVisible; + desc.type = TextureType::kTexture2DArray; + desc.format = PixelFormat::kR8G8B8A8UNormInt; + desc.size = {2, 2}; + desc.array_layer_count = 4; + desc.mip_count = 1; + + auto texture = context->GetResourceAllocator()->CreateTexture(desc); + ASSERT_TRUE(texture); + EXPECT_EQ(static_cast(texture->GetTextureDescriptor().array_layer_count), + 4); + EXPECT_TRUE(texture->IsSliceValid(3)); + EXPECT_FALSE(texture->IsSliceValid(4)); + + std::vector layer(2u * 2u * 4u, 0xFF); + for (size_t slice = 0; slice < static_cast(desc.array_layer_count); + ++slice) { + EXPECT_TRUE(texture->SetContents(layer.data(), layer.size(), slice)); + } +} + // Clears a single cube map face by attaching it as a render target slice. // Rendering to cube faces is portable down to OpenGL ES 2.0, so this runs on // every backend. diff --git a/engine/src/flutter/lib/gpu/lib/src/buffer.dart b/engine/src/flutter/lib/gpu/lib/src/buffer.dart index df25c61528a6b..e8f1984e79b46 100644 --- a/engine/src/flutter/lib/gpu/lib/src/buffer.dart +++ b/engine/src/flutter/lib/gpu/lib/src/buffer.dart @@ -84,13 +84,14 @@ base class DeviceBuffer extends NativeFieldWrapperClass1 { bool _bindAsUniform( RenderPass renderPass, - UniformSlot slot, + Shader shader, + int uniformStructIndex, int offsetInBytes, int lengthInBytes, ) { - return renderPass._bindUniformDevice( - slot.shader, - slot.uniformName, + return renderPass._bindUniformDeviceIndexed( + shader, + uniformStructIndex, this, offsetInBytes, lengthInBytes, diff --git a/engine/src/flutter/lib/gpu/lib/src/render_pass.dart b/engine/src/flutter/lib/gpu/lib/src/render_pass.dart index a6f42d1d3da1d..040231ae6add8 100644 --- a/engine/src/flutter/lib/gpu/lib/src/render_pass.dart +++ b/engine/src/flutter/lib/gpu/lib/src/render_pass.dart @@ -468,9 +468,18 @@ base class RenderPass extends NativeFieldWrapperClass1 { } void bindUniform(UniformSlot slot, BufferView bufferView) { + // The slot's index is resolved once and cached, so steady-state binds + // pass an integer across the native boundary instead of the name. + int uniformStructIndex = slot._resolvedStructIndex; + if (uniformStructIndex < 0) { + throw Exception( + "Failed to bind uniform (no uniform struct named '${slot.uniformName}')", + ); + } bool success = bufferView.buffer._bindAsUniform( this, - slot, + slot.shader, + uniformStructIndex, bufferView.offsetInBytes, bufferView.lengthInBytes, ); @@ -510,9 +519,15 @@ base class RenderPass extends NativeFieldWrapperClass1 { ); } - bool success = _bindTexture( + int uniformTextureIndex = slot._resolvedTextureIndex; + if (uniformTextureIndex < 0) { + throw Exception( + "Failed to bind texture (no texture named '${slot.uniformName}')", + ); + } + bool success = _bindTextureIndexed( slot.shader, - slot.uniformName, + uniformTextureIndex, texture, sampler.minFilter.index, sampler.magFilter.index, @@ -801,11 +816,11 @@ base class RenderPass extends NativeFieldWrapperClass1 { ); @Native< - Bool Function(Pointer, Pointer, Handle, Pointer, Int, Int) - >(symbol: 'InternalFlutterGpu_RenderPass_BindUniformDevice') - external bool _bindUniformDevice( + Bool Function(Pointer, Pointer, Int, Pointer, Int, Int) + >(symbol: 'InternalFlutterGpu_RenderPass_BindUniformDeviceIndexed') + external bool _bindUniformDeviceIndexed( Shader shader, - String uniformName, + int uniformStructIndex, DeviceBuffer buffer, int offsetInBytes, int lengthInBytes, @@ -827,7 +842,7 @@ base class RenderPass extends NativeFieldWrapperClass1 { Bool Function( Pointer, Pointer, - Handle, + Int, Pointer, Int, Int, @@ -836,10 +851,10 @@ base class RenderPass extends NativeFieldWrapperClass1 { Int, Int, ) - >(symbol: 'InternalFlutterGpu_RenderPass_BindTexture') - external bool _bindTexture( + >(symbol: 'InternalFlutterGpu_RenderPass_BindTextureIndexed') + external bool _bindTextureIndexed( Shader shader, - String uniformName, + int uniformTextureIndex, Texture texture, int minFilter, int magFilter, diff --git a/engine/src/flutter/lib/gpu/lib/src/shader.dart b/engine/src/flutter/lib/gpu/lib/src/shader.dart index 001bfc34671cc..b568cd9b0d148 100644 --- a/engine/src/flutter/lib/gpu/lib/src/shader.dart +++ b/engine/src/flutter/lib/gpu/lib/src/shader.dart @@ -6,11 +6,49 @@ part of flutter_gpu; +/// Bumped whenever any shader library hot reloads. [UniformSlot] caches +/// reflection indices against this epoch, since a reload replaces the +/// shaders' reflection data in place and invalidates cached indices. +int _shaderReloadEpoch = 0; + +const int _kSlotIndexUnresolved = -2; + base class UniformSlot { UniformSlot._(this.shader, this.uniformName); final Shader shader; final String uniformName; + // Reflection indices for the name-free bind path, resolved through one + // native call on first use and cached until a shader hot reload. -1 + // means the shader has no struct/texture with this slot's name. + int _structIndex = _kSlotIndexUnresolved; + int _textureIndex = _kSlotIndexUnresolved; + int _epoch = _shaderReloadEpoch; + + void _syncEpoch() { + if (_epoch != _shaderReloadEpoch) { + _structIndex = _kSlotIndexUnresolved; + _textureIndex = _kSlotIndexUnresolved; + _epoch = _shaderReloadEpoch; + } + } + + int get _resolvedStructIndex { + _syncEpoch(); + if (_structIndex == _kSlotIndexUnresolved) { + _structIndex = shader._getUniformStructIndex(uniformName); + } + return _structIndex; + } + + int get _resolvedTextureIndex { + _syncEpoch(); + if (_textureIndex == _kSlotIndexUnresolved) { + _textureIndex = shader._getUniformTextureIndex(uniformName); + } + return _textureIndex; + } + /// The reflected total size of a shader's uniform struct by name. /// /// Returns [null] if the shader does not contain a uniform struct with the @@ -35,8 +73,12 @@ base class Shader extends NativeFieldWrapperClass1 { // [Shader] handles are instantiated when interacting with a [ShaderLibrary]. Shader._(); + // Memoized so per-draw lookups return the same slot instance, whose + // cached reflection indices make repeat binds name-free. + final Map _uniformSlots = {}; + UniformSlot getUniformSlot(String uniformName) { - return UniformSlot._(this, uniformName); + return _uniformSlots[uniformName] ??= UniformSlot._(this, uniformName); } @Native, Handle)>( @@ -52,6 +94,16 @@ base class Shader extends NativeFieldWrapperClass1 { String memberName, ); + @Native, Handle)>( + symbol: 'InternalFlutterGpu_Shader_GetUniformStructIndex', + ) + external int _getUniformStructIndex(String uniformStructName); + + @Native, Handle)>( + symbol: 'InternalFlutterGpu_Shader_GetUniformTextureIndex', + ) + external int _getUniformTextureIndex(String uniformTextureName); + /// Test-only. Whether this shader is currently marked dirty (will be /// evicted and re-registered with the impeller shader library on next /// pipeline build). Used by tests to assert that reload dedupe keeps diff --git a/engine/src/flutter/lib/gpu/lib/src/shader_library.dart b/engine/src/flutter/lib/gpu/lib/src/shader_library.dart index 22f1a1a64f798..37d5a8480798d 100644 --- a/engine/src/flutter/lib/gpu/lib/src/shader_library.dart +++ b/engine/src/flutter/lib/gpu/lib/src/shader_library.dart @@ -110,14 +110,22 @@ base class ShaderLibrary extends NativeFieldWrapperClass1 { if (error != null) { throw Exception("Failed to reinitialize ShaderLibrary: ${error}"); } + // The reload replaced the shaders' reflection data in place, so cached + // uniform slot indices must re-resolve. + _shaderReloadEpoch++; } /// Test-only. Reloads this library from `assetName`'s bytes while keeping /// this library's identity and registry key. Production hot reload always /// re-fetches the original asset path via [reinitialize]; this hook lets /// tests simulate an edited bundle by swapping in a different fixture. - String? debugReinitializeFromAsset(String assetName) => - _reinitializeWithAsset(assetName); + String? debugReinitializeFromAsset(String assetName) { + final String? error = _reinitializeWithAsset(assetName); + if (error == null) { + _shaderReloadEpoch++; + } + return error; + } /// Reparses [bytes] into this library in place, preserving its identity so /// any [Shader]s already handed out keep working (they are mutated and @@ -127,8 +135,13 @@ base class ShaderLibrary extends NativeFieldWrapperClass1 { /// /// Returns null on success, or an error message if [bytes] could not be /// parsed (the live shaders are left unchanged in that case). - String? reinitializeFromBytes(ByteData bytes) => - _reinitializeWithBytes(bytes); + String? reinitializeFromBytes(ByteData bytes) { + final String? error = _reinitializeWithBytes(bytes); + if (error == null) { + _shaderReloadEpoch++; + } + return error; + } @Native( symbol: 'InternalFlutterGpu_ShaderLibrary_InitializeWithAsset', diff --git a/engine/src/flutter/lib/gpu/render_pass.cc b/engine/src/flutter/lib/gpu/render_pass.cc index 3f2ec0152d49f..cbe5bbc12dd6f 100644 --- a/engine/src/flutter/lib/gpu/render_pass.cc +++ b/engine/src/flutter/lib/gpu/render_pass.cc @@ -405,18 +405,13 @@ void InternalFlutterGpu_RenderPass_BindIndexBufferDevice( length_in_bytes, index_type); } -static bool BindUniform( +static bool BindUniformStruct( flutter::gpu::RenderPass* wrapper, flutter::gpu::Shader* shader, - Dart_Handle uniform_name_handle, + const flutter::gpu::Shader::UniformBinding* uniform_struct, const std::shared_ptr& buffer, int offset_in_bytes, int length_in_bytes) { - auto uniform_name = tonic::StdStringFromDart(uniform_name_handle); - const flutter::gpu::Shader::UniformBinding* uniform_struct = - shader->GetUniformStruct(uniform_name); - // TODO(bdero): Return an error string stating that no uniform struct with - // this name exists and throw an exception. if (!uniform_struct) { return false; } @@ -458,15 +453,28 @@ bool InternalFlutterGpu_RenderPass_BindUniformDevice( flutter::gpu::DeviceBuffer* device_buffer, int offset_in_bytes, int length_in_bytes) { - return BindUniform(wrapper, shader, uniform_name_handle, - device_buffer->GetBuffer(), offset_in_bytes, - length_in_bytes); + auto uniform_name = tonic::StdStringFromDart(uniform_name_handle); + return BindUniformStruct( + wrapper, shader, shader->GetUniformStruct(uniform_name), + device_buffer->GetBuffer(), offset_in_bytes, length_in_bytes); } -bool InternalFlutterGpu_RenderPass_BindTexture( +bool InternalFlutterGpu_RenderPass_BindUniformDeviceIndexed( flutter::gpu::RenderPass* wrapper, flutter::gpu::Shader* shader, - Dart_Handle uniform_name_handle, + int uniform_struct_index, + flutter::gpu::DeviceBuffer* device_buffer, + int offset_in_bytes, + int length_in_bytes) { + return BindUniformStruct( + wrapper, shader, shader->GetUniformStructAt(uniform_struct_index), + device_buffer->GetBuffer(), offset_in_bytes, length_in_bytes); +} + +static bool BindTextureBinding( + flutter::gpu::RenderPass* wrapper, + flutter::gpu::Shader* shader, + const flutter::gpu::Shader::TextureBinding* texture_binding, flutter::gpu::Texture* texture, int min_filter, int mag_filter, @@ -474,11 +482,6 @@ bool InternalFlutterGpu_RenderPass_BindTexture( int width_address_mode, int height_address_mode, int max_anisotropy) { - auto uniform_name = tonic::StdStringFromDart(uniform_name_handle); - const flutter::gpu::Shader::TextureBinding* texture_binding = - shader->GetUniformTexture(uniform_name); - // TODO(bdero): Return an error string stating that no uniform texture with - // this name exists and throw an exception. if (!texture_binding) { return false; } @@ -520,6 +523,41 @@ bool InternalFlutterGpu_RenderPass_BindTexture( return true; } +bool InternalFlutterGpu_RenderPass_BindTexture( + flutter::gpu::RenderPass* wrapper, + flutter::gpu::Shader* shader, + Dart_Handle uniform_name_handle, + flutter::gpu::Texture* texture, + int min_filter, + int mag_filter, + int mip_filter, + int width_address_mode, + int height_address_mode, + int max_anisotropy) { + auto uniform_name = tonic::StdStringFromDart(uniform_name_handle); + return BindTextureBinding( + wrapper, shader, shader->GetUniformTexture(uniform_name), texture, + min_filter, mag_filter, mip_filter, width_address_mode, + height_address_mode, max_anisotropy); +} + +bool InternalFlutterGpu_RenderPass_BindTextureIndexed( + flutter::gpu::RenderPass* wrapper, + flutter::gpu::Shader* shader, + int uniform_texture_index, + flutter::gpu::Texture* texture, + int min_filter, + int mag_filter, + int mip_filter, + int width_address_mode, + int height_address_mode, + int max_anisotropy) { + return BindTextureBinding( + wrapper, shader, shader->GetUniformTextureAt(uniform_texture_index), + texture, min_filter, mag_filter, mip_filter, width_address_mode, + height_address_mode, max_anisotropy); +} + void InternalFlutterGpu_RenderPass_ClearBindings( flutter::gpu::RenderPass* wrapper) { wrapper->ClearBindings(); diff --git a/engine/src/flutter/lib/gpu/render_pass.h b/engine/src/flutter/lib/gpu/render_pass.h index 2a40293a0b48a..ae72c0b5e6b6c 100644 --- a/engine/src/flutter/lib/gpu/render_pass.h +++ b/engine/src/flutter/lib/gpu/render_pass.h @@ -194,6 +194,15 @@ extern bool InternalFlutterGpu_RenderPass_BindUniformDevice( int offset_in_bytes, int length_in_bytes); +FLUTTER_GPU_EXPORT +extern bool InternalFlutterGpu_RenderPass_BindUniformDeviceIndexed( + flutter::gpu::RenderPass* wrapper, + flutter::gpu::Shader* shader, + int uniform_struct_index, + flutter::gpu::DeviceBuffer* device_buffer, + int offset_in_bytes, + int length_in_bytes); + FLUTTER_GPU_EXPORT extern bool InternalFlutterGpu_RenderPass_BindTexture( flutter::gpu::RenderPass* wrapper, @@ -207,6 +216,19 @@ extern bool InternalFlutterGpu_RenderPass_BindTexture( int height_address_mode, int max_anisotropy); +FLUTTER_GPU_EXPORT +extern bool InternalFlutterGpu_RenderPass_BindTextureIndexed( + flutter::gpu::RenderPass* wrapper, + flutter::gpu::Shader* shader, + int uniform_texture_index, + flutter::gpu::Texture* texture, + int min_filter, + int mag_filter, + int mip_filter, + int width_address_mode, + int height_address_mode, + int max_anisotropy); + FLUTTER_GPU_EXPORT extern void InternalFlutterGpu_RenderPass_ClearBindings( flutter::gpu::RenderPass* wrapper); diff --git a/engine/src/flutter/lib/gpu/shader.cc b/engine/src/flutter/lib/gpu/shader.cc index 31c9470404009..c2ff9954f501a 100644 --- a/engine/src/flutter/lib/gpu/shader.cc +++ b/engine/src/flutter/lib/gpu/shader.cc @@ -58,6 +58,7 @@ fml::RefPtr Shader::Make( shader->uniform_structs_ = std::move(uniform_structs); shader->uniform_textures_ = std::move(uniform_textures); shader->descriptor_set_layouts_ = std::move(descriptor_set_layouts); + shader->RebuildBindingOrder(); return shader; } @@ -110,6 +111,7 @@ void Shader::ResetFrom(Shader& other) { uniform_structs_ = std::move(other.uniform_structs_); uniform_textures_ = std::move(other.uniform_textures_); descriptor_set_layouts_ = std::move(other.descriptor_set_layouts_); + RebuildBindingOrder(); if (code_changed) { is_dirty_ = true; } @@ -191,6 +193,60 @@ const Shader::TextureBinding* Shader::GetUniformTexture( return &uniform->second; } +int Shader::GetUniformStructIndex(const std::string& name) const { + const UniformBinding* binding = GetUniformStruct(name); + if (binding == nullptr) { + return -1; + } + for (size_t i = 0; i < uniform_struct_order_.size(); i++) { + if (uniform_struct_order_[i] == binding) { + return static_cast(i); + } + } + return -1; +} + +const Shader::UniformBinding* Shader::GetUniformStructAt(int index) const { + if (index < 0 || static_cast(index) >= uniform_struct_order_.size()) { + return nullptr; + } + return uniform_struct_order_[index]; +} + +int Shader::GetUniformTextureIndex(const std::string& name) const { + const TextureBinding* binding = GetUniformTexture(name); + if (binding == nullptr) { + return -1; + } + for (size_t i = 0; i < uniform_texture_order_.size(); i++) { + if (uniform_texture_order_[i] == binding) { + return static_cast(i); + } + } + return -1; +} + +const Shader::TextureBinding* Shader::GetUniformTextureAt(int index) const { + if (index < 0 || + static_cast(index) >= uniform_texture_order_.size()) { + return nullptr; + } + return uniform_texture_order_[index]; +} + +void Shader::RebuildBindingOrder() { + uniform_struct_order_.clear(); + uniform_struct_order_.reserve(uniform_structs_.size()); + for (const auto& entry : uniform_structs_) { + uniform_struct_order_.push_back(&entry.second); + } + uniform_texture_order_.clear(); + uniform_texture_order_.reserve(uniform_textures_.size()); + for (const auto& entry : uniform_textures_) { + uniform_texture_order_.push_back(&entry.second); + } +} + } // namespace gpu } // namespace flutter @@ -210,6 +266,20 @@ int InternalFlutterGpu_Shader_GetUniformStructSize( return uniform->size_in_bytes; } +int InternalFlutterGpu_Shader_GetUniformStructIndex( + flutter::gpu::Shader* wrapper, + Dart_Handle struct_name_handle) { + auto name = tonic::StdStringFromDart(struct_name_handle); + return wrapper->GetUniformStructIndex(name); +} + +int InternalFlutterGpu_Shader_GetUniformTextureIndex( + flutter::gpu::Shader* wrapper, + Dart_Handle texture_name_handle) { + auto name = tonic::StdStringFromDart(texture_name_handle); + return wrapper->GetUniformTextureIndex(name); +} + int InternalFlutterGpu_Shader_GetUniformMemberOffset( flutter::gpu::Shader* wrapper, Dart_Handle struct_name_handle, diff --git a/engine/src/flutter/lib/gpu/shader.h b/engine/src/flutter/lib/gpu/shader.h index feae2821a8cf8..b9429bc5f61ba 100644 --- a/engine/src/flutter/lib/gpu/shader.h +++ b/engine/src/flutter/lib/gpu/shader.h @@ -90,6 +90,22 @@ class Shader : public RefCountedDartWrappable { const Shader::TextureBinding* GetUniformTexture( const std::string& name) const; + /// The position of the named uniform struct in this shader's stable + /// binding order, or -1. Indices stay valid until the shader's payload + /// is replaced by a reload (`ResetFrom`); callers cache them to bind + /// without passing the name across the FFI boundary on every draw. + int GetUniformStructIndex(const std::string& name) const; + + /// The uniform struct at `index` in the stable binding order, or nullptr + /// when the index is out of range. + const Shader::UniformBinding* GetUniformStructAt(int index) const; + + /// The texture counterpart to `GetUniformStructIndex`. + int GetUniformTextureIndex(const std::string& name) const; + + /// The texture counterpart to `GetUniformStructAt`. + const Shader::TextureBinding* GetUniformTextureAt(int index) const; + private: Shader(); @@ -105,9 +121,16 @@ class Shader : public RefCountedDartWrappable { std::vector layouts_; std::unordered_map uniform_structs_; std::unordered_map uniform_textures_; + // The maps' entries in a stable order for index-based lookup. Entry + // pointers stay valid for the maps' lifetime (node-based containers); + // rebuilt whenever the maps are replaced (`Make`, `ResetFrom`). + std::vector uniform_struct_order_; + std::vector uniform_texture_order_; std::vector descriptor_set_layouts_; bool is_dirty_ = true; + void RebuildBindingOrder(); + // Returns the scoped name to use when registering or looking up this // shader's function in a shared impeller::ShaderLibrary. std::string GetScopedName() const; @@ -135,6 +158,16 @@ extern int InternalFlutterGpu_Shader_GetUniformMemberOffset( Dart_Handle struct_name_handle, Dart_Handle member_name_handle); +FLUTTER_GPU_EXPORT +extern int InternalFlutterGpu_Shader_GetUniformStructIndex( + flutter::gpu::Shader* wrapper, + Dart_Handle struct_name_handle); + +FLUTTER_GPU_EXPORT +extern int InternalFlutterGpu_Shader_GetUniformTextureIndex( + flutter::gpu::Shader* wrapper, + Dart_Handle texture_name_handle); + // Test-only: exposes the per-shader dirty bit so tests can assert that // reload deduplication keeps unchanged shaders clean. FLUTTER_GPU_EXPORT diff --git a/engine/src/flutter/lib/ui/fixtures/shaders/general_shaders/BUILD.gn b/engine/src/flutter/lib/ui/fixtures/shaders/general_shaders/BUILD.gn index 8b37a74565d27..8b20c28fc2b0e 100644 --- a/engine/src/flutter/lib/ui/fixtures/shaders/general_shaders/BUILD.gn +++ b/engine/src/flutter/lib/ui/fixtures/shaders/general_shaders/BUILD.gn @@ -13,6 +13,7 @@ if (enable_unittests) { "circle_sdf.frag", "double_sampler_swapped.frag", "double_sampler.frag", + "filter_shader_fractional_texel.frag", "filter_shader.frag", "functions.frag", "missing_size.frag", diff --git a/engine/src/flutter/lib/ui/fixtures/shaders/general_shaders/filter_shader_fractional_texel.frag b/engine/src/flutter/lib/ui/fixtures/shaders/general_shaders/filter_shader_fractional_texel.frag new file mode 100644 index 0000000000000..30a158d8c72ec --- /dev/null +++ b/engine/src/flutter/lib/ui/fixtures/shaders/general_shaders/filter_shader_fractional_texel.frag @@ -0,0 +1,15 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include + +uniform vec2 u_size; +uniform sampler2D u_texture; + +out vec4 frag_color; + +void main() { + vec2 uv = (FlutterFragCoord().xy + vec2(0.25, 0.0)) / u_size; + frag_color = texture(u_texture, uv); +} diff --git a/engine/src/flutter/lib/ui/painting.dart b/engine/src/flutter/lib/ui/painting.dart index 1268cba684c01..e1cada240c761 100644 --- a/engine/src/flutter/lib/ui/painting.dart +++ b/engine/src/flutter/lib/ui/painting.dart @@ -4429,6 +4429,9 @@ abstract class ImageFilter { /// also be at least one sampler2D uniform, the first of which will be set by /// the engine to contain the filter input. /// + /// The optional [filterQuality] argument sets the quality level used to sample + /// the filter input. By default, it is set to [FilterQuality.none]. + /// /// When Impeller uses the OpenGL(ES) backend, the y-axis direction is /// reversed. Custom fragment shaders must invert the y-axis on /// GLES or they will render upside-down. @@ -4458,7 +4461,10 @@ abstract class ImageFilter { /// } /// /// ``` - factory ImageFilter.shader(FragmentShader shader) { + factory ImageFilter.shader( + FragmentShader shader, { + FilterQuality filterQuality = FilterQuality.none, + }) { if (!_impellerEnabled) { throw UnsupportedError('ImageFilter.shader only supported with Impeller rendering engine.'); } @@ -4477,7 +4483,7 @@ abstract class ImageFilter { } throw StateError(buffer.toString()); } - return _FragmentShaderImageFilter(shader); + return _FragmentShaderImageFilter(shader, filterQuality); } /// Whether [ImageFilter.shader] is supported on the current backend. @@ -4673,9 +4679,10 @@ class _ComposeImageFilter implements ImageFilter { } class _FragmentShaderImageFilter implements ImageFilter { - _FragmentShaderImageFilter(this.shader); + _FragmentShaderImageFilter(this.shader, this.filterQuality); final FragmentShader shader; + final FilterQuality filterQuality; late final _ImageFilter nativeFilter = _ImageFilter.shader(this); @@ -4686,7 +4693,7 @@ class _FragmentShaderImageFilter implements ImageFilter { String get debugShortDescription => 'shader'; @override - String toString() => 'ImageFilter.shader(Shader#${shader.hashCode})'; + String toString() => 'ImageFilter.shader(Shader#${shader.hashCode}, $filterQuality)'; @override bool operator ==(Object other) { @@ -4695,6 +4702,7 @@ class _FragmentShaderImageFilter implements ImageFilter { } return other is _FragmentShaderImageFilter && other.shader == shader && + other.filterQuality == filterQuality && _equals(nativeFilter, other.nativeFilter); } @@ -4702,7 +4710,7 @@ class _FragmentShaderImageFilter implements ImageFilter { external static bool _equals(_ImageFilter a, _ImageFilter b); @override - int get hashCode => shader.hashCode; + int get hashCode => Object.hash(shader, filterQuality); } /// An [ImageFilter] that is backed by a native DlImageFilter. @@ -4770,7 +4778,7 @@ base class _ImageFilter extends NativeFieldWrapperClass1 { _ImageFilter.shader(_FragmentShaderImageFilter filter) : creator = filter { _constructor(); - _initShader(filter.shader); + _initShader(filter.shader, filter.filterQuality.index); } @Native(symbol: 'ImageFilter::Create') @@ -4813,8 +4821,8 @@ base class _ImageFilter extends NativeFieldWrapperClass1 { ) external void _initComposed(_ImageFilter outerFilter, _ImageFilter innerFilter); - @Native, Pointer)>(symbol: 'ImageFilter::initShader') - external void _initShader(FragmentShader shader); + @Native, Pointer, Int32)>(symbol: 'ImageFilter::initShader') + external void _initShader(FragmentShader shader, int filterQuality); /// The original Dart object that created the native wrapper, which retains /// the values used for the filter. diff --git a/engine/src/flutter/lib/ui/painting/fragment_program.cc b/engine/src/flutter/lib/ui/painting/fragment_program.cc index 74f4a33c66ae8..a9b8c454f4d64 100644 --- a/engine/src/flutter/lib/ui/painting/fragment_program.cc +++ b/engine/src/flutter/lib/ui/painting/fragment_program.cc @@ -271,9 +271,10 @@ std::shared_ptr FragmentProgram::MakeDlColorSource( std::shared_ptr FragmentProgram::MakeDlImageFilter( std::shared_ptr> float_uniforms, - const std::vector>& children) { - return DlImageFilter::MakeRuntimeEffect(runtime_effect_, children, - std::move(float_uniforms)); + const std::vector>& children, + DlImageSampling input_sampling) { + return DlImageFilter::MakeRuntimeEffect( + runtime_effect_, children, std::move(float_uniforms), input_sampling); } void FragmentProgram::Create(Dart_Handle wrapper) { diff --git a/engine/src/flutter/lib/ui/painting/fragment_program.h b/engine/src/flutter/lib/ui/painting/fragment_program.h index 65eb460658c51..def8c4787f802 100644 --- a/engine/src/flutter/lib/ui/painting/fragment_program.h +++ b/engine/src/flutter/lib/ui/painting/fragment_program.h @@ -41,7 +41,8 @@ class FragmentProgram : public RefCountedDartWrappable { std::shared_ptr MakeDlImageFilter( std::shared_ptr> float_uniforms, - const std::vector>& children); + const std::vector>& children, + DlImageSampling input_sampling = DlImageSampling::kNearestNeighbor); private: FragmentProgram(); diff --git a/engine/src/flutter/lib/ui/painting/fragment_shader.cc b/engine/src/flutter/lib/ui/painting/fragment_shader.cc index 7ce2f25a88c9e..bf308846c63fd 100644 --- a/engine/src/flutter/lib/ui/painting/fragment_shader.cc +++ b/engine/src/flutter/lib/ui/painting/fragment_shader.cc @@ -94,7 +94,8 @@ void ReusableFragmentShader::SetImageSampler(Dart_Handle index_handle, uniform_floats[float_count_ + 2 * index + 1] = image->height(); } -std::shared_ptr ReusableFragmentShader::as_image_filter() const { +std::shared_ptr ReusableFragmentShader::as_image_filter( + DlImageSampling input_sampling) const { FML_CHECK(program_); // The lifetime of this object is longer than a frame, and the uniforms can be @@ -104,7 +105,8 @@ std::shared_ptr ReusableFragmentShader::as_image_filter() const { uniform_data->resize(uniform_data_->size()); memcpy(uniform_data->data(), uniform_data_->bytes(), uniform_data->size()); - return program_->MakeDlImageFilter(std::move(uniform_data), samplers_); + return program_->MakeDlImageFilter(std::move(uniform_data), samplers_, + input_sampling); } std::shared_ptr ReusableFragmentShader::shader( diff --git a/engine/src/flutter/lib/ui/painting/fragment_shader.h b/engine/src/flutter/lib/ui/painting/fragment_shader.h index 2246dc5093e8c..60efb487f8b8c 100644 --- a/engine/src/flutter/lib/ui/painting/fragment_shader.h +++ b/engine/src/flutter/lib/ui/painting/fragment_shader.h @@ -47,7 +47,8 @@ class ReusableFragmentShader : public Shader { // |Shader| std::shared_ptr shader(DlImageSampling) override; - std::shared_ptr as_image_filter() const; + std::shared_ptr as_image_filter( + DlImageSampling input_sampling) const; private: ReusableFragmentShader(fml::RefPtr program, diff --git a/engine/src/flutter/lib/ui/painting/image_filter.cc b/engine/src/flutter/lib/ui/painting/image_filter.cc index 41366bbe38c09..3fb577980e1f5 100644 --- a/engine/src/flutter/lib/ui/painting/image_filter.cc +++ b/engine/src/flutter/lib/ui/painting/image_filter.cc @@ -131,9 +131,10 @@ void ImageFilter::initComposeFilter(ImageFilter* outer, ImageFilter* inner) { inner->filter(DlTileMode::kClamp)); } -void ImageFilter::initShader(ReusableFragmentShader* shader) { +void ImageFilter::initShader(ReusableFragmentShader* shader, + int filterQualityIndex) { FML_DCHECK(shader); - filter_ = shader->as_image_filter(); + filter_ = shader->as_image_filter(SamplingFromIndex(filterQualityIndex)); } bool ImageFilter::equals(Dart_Handle a_handle, Dart_Handle b_handle) { diff --git a/engine/src/flutter/lib/ui/painting/image_filter.h b/engine/src/flutter/lib/ui/painting/image_filter.h index 18a71c02e7bc1..47449193119f2 100644 --- a/engine/src/flutter/lib/ui/painting/image_filter.h +++ b/engine/src/flutter/lib/ui/painting/image_filter.h @@ -42,7 +42,7 @@ class ImageFilter : public RefCountedDartWrappable { void initMatrix(const tonic::Float64List& matrix4, int filter_quality_index); void initColorFilter(ColorFilter* colorFilter); void initComposeFilter(ImageFilter* outer, ImageFilter* inner); - void initShader(ReusableFragmentShader* shader); + void initShader(ReusableFragmentShader* shader, int filter_quality_index); static bool equals(Dart_Handle a_handle, Dart_Handle b_handle); const std::shared_ptr filter(DlTileMode mode) const; diff --git a/engine/src/flutter/lib/web_ui/flutter_js/src/entrypoint_loader.js b/engine/src/flutter/lib/web_ui/flutter_js/src/entrypoint_loader.js index 7a74451f246b0..1c131c596beb0 100644 --- a/engine/src/flutter/lib/web_ui/flutter_js/src/entrypoint_loader.js +++ b/engine/src/flutter/lib/web_ui/flutter_js/src/entrypoint_loader.js @@ -185,10 +185,7 @@ export class FlutterEntrypointLoader { const defaultLoadDeferredModules = (moduleNames, handleModule) => Promise.all( moduleNames.map((moduleName) => - handleModule( - moduleName, - fetch(resolveUrlWithSegments(entrypointBaseUrl, moduleName)) - ) + fetch(resolveUrlWithSegments(entrypointBaseUrl, moduleName)).then((response) => handleModule(moduleName, response)) ) ); const dartApp = await compiledDartApp.instantiate(await importsPromise, { diff --git a/engine/src/flutter/lib/web_ui/lib/painting.dart b/engine/src/flutter/lib/web_ui/lib/painting.dart index 65b56d2758d6d..94ddea19142fe 100644 --- a/engine/src/flutter/lib/web_ui/lib/painting.dart +++ b/engine/src/flutter/lib/web_ui/lib/painting.dart @@ -690,8 +690,12 @@ class ImageFilter { factory ImageFilter.compose({required ImageFilter outer, required ImageFilter inner}) => engine.renderer.composeImageFilters(outer: outer, inner: inner); - // ignore: avoid_unused_constructor_parameters - factory ImageFilter.shader(FragmentShader shader) { + factory ImageFilter.shader( + // ignore: avoid_unused_constructor_parameters + FragmentShader shader, { + // ignore: avoid_unused_constructor_parameters + FilterQuality filterQuality = FilterQuality.none, + }) { throw UnsupportedError('ImageFilter.shader only supported with Impeller rendering engine.'); } @@ -741,9 +745,9 @@ Future instantiateImageCodecFromBuffer( int? targetWidth, int? targetHeight, bool allowUpscaling = true, -}) { +}) async { try { - return engine.renderer.instantiateImageCodec( + return await engine.renderer.instantiateImageCodec( buffer._list!, targetWidth: targetWidth, targetHeight: targetHeight, 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/lib/src/engine/semantics/text_field.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart index 7a8a78bb5d331..97ece97a2a7a5 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'package:ui/ui.dart' as ui; +import 'package:ui/ui_web/src/ui_web.dart' as ui_web; import '../dom.dart'; import '../platform_dispatcher.dart'; @@ -47,6 +48,13 @@ class SemanticsTextEditingStrategy extends DefaultTextEditingStrategy { /// Current input configuration supplied by the "flutter/textinput" channel. InputConfiguration? inputConfig; + /// Whether an autofill form has been woken up for the active field. + /// + /// Tracked locally because the base strategy's `_appendedToForm` is private + /// to its library, and [SemanticsTextEditingStrategy] fully overrides + /// [disable] (it never calls `super.disable()`). + bool _formIsActive = false; + /// The semantics implementation does not operate on DOM nodes, but only /// remembers the config and callbacks. This is because the DOM nodes are /// supplied in the semantics update and enabled by [activate]. @@ -114,6 +122,24 @@ class SemanticsTextEditingStrategy extends DefaultTextEditingStrategy { } subscriptions.clear(); lastEditingState = null; + + // The focused field is linked to the autofill form by the `form` + // attribute. On blur, detach it and leave a synthetic placeholder holding + // its value, then keep the form dormant in the DOM so the autofill context + // can still be submitted (credential save via + // `TextInput.finishAutofillContext`) and the group stays complete when + // another field is focused. + if (_formIsActive && inputConfiguration.autofillGroup != null) { + final EngineAutofillForm group = inputConfiguration.autofillGroup!; + if (inputConfiguration.autofill != null) { + group.demoteFocusedToSynthetic(activeDomElement, inputConfiguration.autofill!); + } + if (group.formElement != null) { + group.goDormant(); + } + _formIsActive = false; + } + EnginePlatformDispatcher.instance.viewManager.safeBlur(activeDomElement); domElement = null; activeTextField = null; @@ -146,8 +172,25 @@ class SemanticsTextEditingStrategy extends DefaultTextEditingStrategy { OnActionCallback? onAction, }) { isEnabled = true; - inputConfiguration = inputConfig; - applyConfiguration(inputConfig); + final EngineAutofillForm? autofillGroup = inputConfig.autofillGroup; + inputConfiguration = autofillGroup == null + ? inputConfig + : inputConfig.copyWith( + autofillGroup: autofillGroup.copyWith(associateFocusedElementByAttribute: true), + ); + applyConfiguration(inputConfiguration); + + // Build the autofill form here, before [addEventHandlers] runs (it runs + // later in the same [enable] call). [addEventHandlers] subscribes to the + // `input` events of the synthetic group fields, so those fields must exist + // by then or non-focused fields would never propagate autofilled values. + // + // Note [placeElement]/[placeForm] are never reached via the normal + // placement path in semantics mode ([initializeElementPlacement] is a + // no-op), so the form must be set up explicitly here. + if (hasAutofillGroup) { + placeForm(); + } } @override @@ -165,7 +208,27 @@ class SemanticsTextEditingStrategy extends DefaultTextEditingStrategy { } @override - void placeForm() {} + void placeForm() { + // Safari autofills grouped credential fields by heuristic without needing a + // form. The attribute-linked form regresses that: a non-focused field's real + // input is left outside the form and stops being filled + // (flutter/flutter#180652). Skip the form on Safari and let its native + // heuristic fill the whole group. `_formIsActive` stays false, so [disable] + // skips the demote/dormant cleanup too. + // + // Other WebKit browsers (Chrome, Firefox on iOS) do not fill by heuristic + // and need the form path, so they are not skipped here. + if (ui_web.browser.isSafari) { + return; + } + + // The focused element is the real semantics-owned ``. It must not be + // moved into the form (that regressed a11y tab traversal, see + // flutter/flutter#180652). Link it to the form via the `form` attribute + // instead. See [EngineAutofillForm.wakeUp]. + inputConfiguration.autofillGroup!.wakeUp(activeDomElement, inputConfiguration.autofill!); + _formIsActive = true; + } @override void updateElementPlacement(EditableTextGeometry textGeometry) { @@ -364,6 +427,17 @@ class SemanticTextField extends SemanticRole { (editableElement as DomElementWithDisabledProperty).disabled = !semanticsObject.isEnabled; } + /// Whether an autofill group owns the autofill-related attributes of this + /// field. + /// + /// When the field participates in an autofill group, [AutofillInfo.applyToDomElement] + /// sets the element's `name` (and `id`/`autocomplete`) to the autofill hint. + /// A plain semantic input never has a `name`, so a non-empty `name` is a + /// reliable, order-independent signal that [_updateInputType] must not + /// overwrite `autocomplete`, otherwise grouped autofill silently breaks on + /// the next semantics update (flutter/flutter#180652). + bool get _isAutofillOwned => editableElement.getAttribute('name')?.isNotEmpty ?? false; + void _updateInputType() { if (semanticsObject.flags.isMultiline) { // text area can't be annotated with input type @@ -379,7 +453,9 @@ class SemanticTextField extends SemanticRole { // proper selection/cursor operations. input.removeAttribute('inputmode'); input.removeAttribute('autocapitalize'); - input.autocomplete = 'off'; + if (!_isAutofillOwned) { + input.autocomplete = 'off'; + } input.type = 'text'; switch (semanticsObject.inputType) { @@ -392,7 +468,9 @@ class SemanticTextField extends SemanticRole { case ui.SemanticsInputType.email: input.setAttribute('inputmode', 'email'); input.setAttribute('autocapitalize', 'none'); - input.autocomplete = 'email'; + if (!_isAutofillOwned) { + input.autocomplete = 'email'; + } default: } } diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/text_editing.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/text_editing.dart index f737daf67c728..e907a3e98469f 100644 --- a/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/text_editing.dart +++ b/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/text_editing.dart @@ -192,6 +192,7 @@ class EngineAutofillForm { required this.items, required this.formIdentifier, required this.focusedElementId, + this.associateFocusedElementByAttribute = false, }); DomHTMLFormElement? formElement; @@ -218,10 +219,40 @@ class EngineAutofillForm { final String focusedElementId; + /// Whether the real focused text field lives outside [formElement] and is + /// associated with it via the HTML `form` attribute. + /// + /// This is used by semantics mode, where moving the real text field into the + /// form would break accessibility traversal. + final bool associateFocusedElementByAttribute; + bool get _isSafariStrategy => textEditing.strategy is SafariDesktopTextEditingStrategy || textEditing.strategy is IOSTextEditingStrategy; + /// Whether the current browser engine is WebKit (Safari and all iOS + /// browsers). + /// + /// WebKit refuses to autofill an input that is zero-sized, so synthetic + /// autofill fields must keep a real size on WebKit, while other engines + /// tolerate zero-sized fields. An engine check is used rather than the active + /// text editing strategy because in semantics mode the active strategy is + /// [SemanticsTextEditingStrategy], not the Safari/iOS strategy, so the + /// strategy type alone would miss WebKit there. + bool get _isWebKit => ui_web.browser.browserEngine == ui_web.BrowserEngine.webkit; + + /// Stable DOM id for the form, used so a focused element that lives outside + /// the form, in semantics mode, can be associated with it via the HTML `form` + /// attribute. + /// + /// Derived from [formIdentifier] so it stays the same when the form goes + /// dormant and is woken again by a new instance, and so that distinct + /// identifiers map to distinct ids. The latter matters when multiple autofill + /// forms coexist in one document, for example multi-view, multiple autofill + /// groups, or a dormant form left in the DOM, where a focused element must + /// never be associated with the wrong form. + String get formDomId => 'flt-af-$formIdentifier'; + /// Creates an [EngineAutofillForm] from the JSON representation of a Flutter /// framework `TextInputConfiguration` object. /// @@ -289,6 +320,17 @@ class EngineAutofillForm { ); } + EngineAutofillForm copyWith({bool? associateFocusedElementByAttribute}) { + return EngineAutofillForm( + viewId: viewId, + items: items, + formIdentifier: formIdentifier, + focusedElementId: focusedElementId, + associateFocusedElementByAttribute: + associateFocusedElementByAttribute ?? this.associateFocusedElementByAttribute, + ); + } + static String _getFormIdentifier(Map items) { final ids = []; for (final FieldItem item in items.values) { @@ -301,7 +343,16 @@ class EngineAutofillForm { /// Wakes up the form with the given focused element. /// - /// The [focusedElement] is inserted into the form, replacing the old focused element. + /// The [focusedElement] is inserted into the form, replacing the old focused + /// element. + /// + /// When [associateFocusedElementByAttribute] is true (semantics mode), the focused + /// element is a real `` owned by the semantics tree and must stay in + /// its `` node. Moving it into the form regressed a11y tab + /// traversal (see flutter/flutter#180652). + /// Instead it is linked to the form via the HTML `form` attribute, and any + /// synthetic placeholder previously created for that field is removed so the + /// field is represented in the form exactly once. void wakeUp(DomHTMLElement focusedElement, AutofillInfo focusedAutofill) { // Since we're disabling pointer events on the form to fix Safari autofill, // we need to explicitly set pointer events on the active input element in @@ -311,7 +362,13 @@ class EngineAutofillForm { focusedElement.style.pointerEvents = 'all'; } - final EngineAutofillForm? existingForm = dormantForms[formIdentifier]; + EngineAutofillForm? existingForm = dormantForms[formIdentifier]; + if (existingForm != null && + existingForm.associateFocusedElementByAttribute != associateFocusedElementByAttribute) { + existingForm.formElement?.remove(); + dormantForms.remove(formIdentifier); + existingForm = null; + } final firstWakeUp = formElement == null; @@ -336,11 +393,24 @@ class EngineAutofillForm { } } - // There's potentially a new focused element that needs to be inserted into the existing form. - // - // Do not cause DOM disturbance unless necessary. Doing superfluous DOM operations may seem - // harmless, but it actually causes focus changes that could break things. - if (!formElement!.contains(focusedElement)) { + if (associateFocusedElementByAttribute) { + // Promote the focused field to its real (semantics-owned) element: drop + // any synthetic placeholder so the field is not submitted twice, then + // link the real element to the form by attribute. + final DomElement? synthetic = elements[focusedAutofill.uniqueIdentifier]; + if (synthetic != null && synthetic != focusedElement) { + synthetic.remove(); + } + elements.remove(focusedAutofill.uniqueIdentifier); + focusedElement.setAttribute('form', formDomId); + } else if (!formElement!.contains(focusedElement)) { + // There's potentially a new focused element that needs to be inserted + // into the existing form. + // + // Do not cause DOM disturbance unless necessary. Doing superfluous DOM + // operations may seem harmless, but it actually causes focus changes that + // could break things. + // // Find the matching element and replace it with the new focused element. final DomElement oldFocusedElement = elements[focusedAutofill.uniqueIdentifier]!; elements[focusedAutofill.uniqueIdentifier] = focusedElement; @@ -350,6 +420,40 @@ class EngineAutofillForm { _updateFieldValues(); } + /// Demotes a field that is losing focus in semantics mode back to a synthetic + /// in-form placeholder. + /// + /// In semantics mode the focused field is represented by its real element via + /// the `form` attribute (see [wakeUp]). When it blurs, the real element must + /// be detached from the form and replaced by a synthetic element carrying its + /// last value, so the field still participates in form submission (credential + /// save via `TextInput.finishAutofillContext`) and stays grouped when another + /// field in the group is focused. + void demoteFocusedToSynthetic(DomHTMLElement realElement, AutofillInfo autofill) { + realElement.removeAttribute('form'); + final String id = autofill.uniqueIdentifier; + final FieldItem? field = items[id]; + if (field == null || formElement == null) { + return; + } + if (elements[id] != null && elements[id] != realElement) { + // A synthetic placeholder already represents this field. + return; + } + final DomHTMLElement synthetic = field.inputType.createDomElement(); + field.autofillInfo.applyToDomElement(synthetic); + // Preserve the value the user (or autofill) just put in the real element so + // the field still submits correctly for credential save. + EditingState.fromDomElement(realElement).applyTextToDomElement(synthetic); + _styleAutofillElements( + synthetic, + shouldHideElement: !_isWebKit, + shouldDisablePointerEvents: _isWebKit, + ); + elements[id] = synthetic; + formElement!.append(synthetic); + } + /// Makes the form dormant. /// /// A dormant form stays in the DOM and does not interact with the framework until it's woken up. @@ -376,16 +480,25 @@ class EngineAutofillForm { formElement.noValidate = true; formElement.method = 'post'; formElement.action = '#'; + formElement.id = formDomId; formElement.addEventListener('submit', preventDefaultListener); // We need to explicitly disable pointer events on the form in Safari Desktop and iOS, // so that we don't have pointer event collisions if users hover over or click // into the invisible autofill elements within the form. - _styleAutofillElements(formElement, shouldDisablePointerEvents: _isSafariStrategy); + _styleAutofillElements(formElement, shouldDisablePointerEvents: _isWebKit); for (final FieldItem field in items.values) { final DomHTMLElement htmlElement; if (field.autofillInfo.uniqueIdentifier == focusedAutofill.uniqueIdentifier) { + if (associateFocusedElementByAttribute) { + // The focused element is a real semantics-owned element that stays + // in its `` node and is linked via the `form` + // attribute by [wakeUp]. Do not create or append a synthetic + // placeholder for it here, otherwise the field would be submitted + // twice. + continue; + } // Do not create the focused element here since it is created already. Use the provided one. htmlElement = focusedElement; } else { @@ -400,8 +513,8 @@ class EngineAutofillForm { // sized and placed on the DOM, we also have to disable pointer events. _styleAutofillElements( htmlElement, - shouldHideElement: !_isSafariStrategy, - shouldDisablePointerEvents: _isSafariStrategy, + shouldHideElement: !_isWebKit, + shouldDisablePointerEvents: _isWebKit, ); } @@ -1146,6 +1259,22 @@ class InputConfiguration { enableInteractiveSelection = flutterInputConfiguration.tryBool('enableInteractiveSelection') ?? true; + InputConfiguration copyWith({EngineAutofillForm? autofillGroup}) { + return InputConfiguration( + viewId: viewId, + inputType: inputType, + inputAction: inputAction, + obscureText: obscureText, + readOnly: readOnly, + autocorrect: autocorrect, + textCapitalization: textCapitalization, + autofill: autofill, + autofillGroup: autofillGroup ?? this.autofillGroup, + enableDeltaModel: enableDeltaModel, + enableInteractiveSelection: enableInteractiveSelection, + ); + } + /// The ID of the view that contains the text field. final int viewId; 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/lib/web_ui/test/engine/semantics/text_field_test.dart b/engine/src/flutter/lib/web_ui/test/engine/semantics/text_field_test.dart index 20ea90ccad1c8..d0993e7587ebe 100644 --- a/engine/src/flutter/lib/web_ui/test/engine/semantics/text_field_test.dart +++ b/engine/src/flutter/lib/web_ui/test/engine/semantics/text_field_test.dart @@ -11,6 +11,7 @@ import 'package:ui/src/engine.dart' hide window; import 'package:ui/ui.dart' as ui; import 'package:ui/ui_web/src/ui_web.dart' as ui_web; +import '../../common/spy.dart'; import '../../common/test_initialization.dart'; import 'semantics_tester.dart'; @@ -613,6 +614,344 @@ void testMain() { expect(textField.editableElement.getAttribute('aria-description'), isNull); }); }); + + // Group autofill in semantics mode. See https://github.com/flutter/flutter/issues/180652 + group('$SemanticsTextEditingStrategy autofill group', () { + late HybridTextEditing testTextEditing; + late SemanticsTextEditingStrategy strategy; + + setUp(() { + testTextEditing = HybridTextEditing(); + SemanticsTextEditingStrategy.ensureInitialized(testTextEditing); + strategy = SemanticsTextEditingStrategy.instance; + testTextEditing.debugTextEditingStrategyOverride = strategy; + testTextEditing.configuration = singlelineConfig; + semantics() + ..debugOverrideTimestampFunction(() => _testTime) + ..semanticsEnabled = true; + }); + + tearDown(() { + if (strategy.isEnabled) { + strategy.disable(); + } + cleanForms(); + semantics().semanticsEnabled = false; + domDocument.activeElement?.blur(); + }); + + // Builds a focused-username + password autofill group and drives the + // semantics path so the focused field is activated. + ({EngineAutofillForm form, SemanticTextField textField}) activateGroup() { + final List> fields = _autofillFields( + ['username', 'password'], + ['field1', 'field2'], + ); + final focusedMap = fields.first['autofill']! as Map; + final EngineAutofillForm form = EngineAutofillForm.fromFrameworkMessage( + kImplicitViewId, + focusedMap, + fields, + )!; + final config = InputConfiguration( + viewId: kImplicitViewId, + autofill: AutofillInfo.fromFrameworkMessage(focusedMap), + autofillGroup: form, + ); + strategy.enable(config, onChange: (_, _) {}, onAction: (_) {}); + final SemanticsObject semanticsObject = createTextFieldSemantics(value: '', isFocused: true); + return ( + form: strategy.inputConfiguration.autofillGroup!, + textField: semanticsObject.semanticRole! as SemanticTextField, + ); + } + + test('builds the form and links the focused field by attribute', () { + final (form: EngineAutofillForm form, textField: SemanticTextField textField) = + activateGroup(); + final DomHTMLFormElement formElement = form.formElement!; + + // Form is inserted into the text-editing host with the stable id used + // for the form= association. + expect(form.associateFocusedElementByAttribute, isTrue); + expect(flutterView.dom.textEditingHost.contains(formElement), isTrue); + expect(formElement.id, form.formDomId); + expect(formElement.getElementsByClassName('submitBtn'), hasLength(1)); + + // The non-focused member is a synthetic placeholder inside the form. + final DomHTMLElement password = form.elements['field2']!; + expect(formElement.contains(password), isTrue); + expect((password as DomHTMLInputElement).name, 'current-password'); + + // The focused member is NOT synthesized into the form (would be + // submitted twice), it is linked by attribute instead. + expect(form.elements['field1'], isNull); + expect(textField.editableElement.getAttribute('form'), form.formDomId); + + // Regression guard for the 2021 tab-traversal regression + // (flutter/engine#25797): the editing element must stay in the + // semantics host and must NOT be moved into the form / text-editing + // host. + expect(flutterView.dom.semanticsHost.contains(textField.editableElement), isTrue); + expect(formElement.contains(textField.editableElement), isFalse); + + // Autofill hint is applied and not clobbered back to 'off' by + // _updateInputType during the same semantics update. + expect((textField.editableElement as DomHTMLInputElement).autocomplete, 'username'); + }); + + test('autocomplete survives a later semantics update', () { + final (form: EngineAutofillForm form, textField: SemanticTextField textField) = + activateGroup(); + expect((textField.editableElement as DomHTMLInputElement).autocomplete, 'username'); + + // A second semantics update re-runs _updateInputType; it must not stomp + // the autofill hint while the field is the active group member. + createTextFieldSemantics(value: '', isFocused: true); + expect((textField.editableElement as DomHTMLInputElement).autocomplete, 'username'); + expect(textField.editableElement.getAttribute('form'), form.formDomId); + }); + + test('autofill on a synthetic sibling propagates to the framework', () { + final spy = PlatformMessagesSpy()..setUp(); + try { + final (form: EngineAutofillForm form, textField: _) = activateGroup(); + final password = form.elements['field2']! as DomHTMLInputElement; + + // Simulate the browser autofilling the (non-focused) password field. + password.value = 'p4ssw0rd'; + password.dispatchEvent(createDomEvent('Event', 'input')); + + final Iterable tagged = spy.messages.where( + (m) => + m.channel == 'flutter/textinput' && + m.methodName == 'TextInputClient.updateEditingStateWithTag', + ); + expect(tagged, isNotEmpty); + final args = tagged.last.methodArguments as List; + expect(args[1], isA>()); + expect((args[1] as Map).containsKey('field2'), isTrue); + } finally { + spy.tearDown(); + } + }); + + test('demotes the focused field to a synthetic placeholder on blur', () { + final (form: EngineAutofillForm form, textField: SemanticTextField textField) = + activateGroup(); + expect(textField.editableElement.getAttribute('form'), form.formDomId); + + strategy.disable(); + + // The real element is detached from the form... + expect(textField.editableElement.getAttribute('form'), isNull); + // ...and replaced by a synthetic placeholder so the field still submits + // for credential save, and the form is kept dormant in the DOM. + final DomHTMLElement? placeholder = form.elements['field1']; + expect(placeholder, isNotNull); + expect(form.formElement!.contains(placeholder), isTrue); + expect(dormantForms[form.formIdentifier], form); + }); + + // Focus A, autofill A, focus B, autofill a different credential, focus A + // again, then assert each field is represented in the form exactly once + // with its latest value and the focused field is linked by attribute (not + // duplicated). This is the property that makes TextInput.finishAutofillContext + // submit correct values. Exercises wakeUp dormant-reuse, demote, and promote + // together. See https://github.com/flutter/flutter/issues/180652 + test('promote/demote keeps each field represented once across A->B->A', () { + // Each focus change arrives as a fresh InputConfiguration whose `autofill` + // is the focused field and whose group shares the same formIdentifier, so + // it reuses the dormant form. `values` mirrors how the framework re-sends + // the config with each field's current editing value after an autofilled + // value has propagated back. Non-focused synthetic values come from this + // editing state via _updateFieldValues, not from DOM scraping, so this is + // the faithful way to simulate autofill. + InputConfiguration configFor(int focusedIndex, List values) { + final List> f = _autofillFields( + ['username', 'password'], + ['field1', 'field2'], + values: values, + ); + final focusedMap = f[focusedIndex]['autofill']! as Map; + return InputConfiguration( + viewId: kImplicitViewId, + autofill: AutofillInfo.fromFrameworkMessage(focusedMap), + autofillGroup: EngineAutofillForm.fromFrameworkMessage(kImplicitViewId, focusedMap, f), + ); + } + + final tester = SemanticsTester(owner()); + void focusNode(int nodeId) { + tester.updateNode( + id: 0, + children: [ + tester.updateNode( + id: 1, + flags: ui.SemanticsFlags( + isEnabled: ui.Tristate.isTrue, + isTextField: true, + isFocused: nodeId == 1 ? ui.Tristate.isTrue : ui.Tristate.isFalse, + ), + value: '', + rect: const ui.Rect.fromLTRB(0, 0, 50, 10), + ), + tester.updateNode( + id: 2, + flags: ui.SemanticsFlags( + isEnabled: ui.Tristate.isTrue, + isTextField: true, + isFocused: nodeId == 2 ? ui.Tristate.isTrue : ui.Tristate.isFalse, + ), + value: '', + rect: const ui.Rect.fromLTRB(0, 20, 50, 10), + ), + ], + ); + tester.apply(); + } + + // Node 1 == field1 (username/A), node 2 == field2 (password/B). + // Focus A. Nothing autofilled yet. + strategy.enable(configFor(0, ['', '']), onChange: (_, _) {}, onAction: (_) {}); + focusNode(1); + final EngineAutofillForm formA = strategy.inputConfiguration.autofillGroup!; + // Browser autofills the focused field A. Its live value is what save + // submits for the focused, form-associated element. + (tester.getTextField(1).editableElement as DomHTMLInputElement).value = 'userA'; + + // Focus B. The framework now knows A's value and re-sends the config. + strategy.enable(configFor(1, ['userA', '']), onChange: (_, _) {}, onAction: (_) {}); + focusNode(2); + (tester.getTextField(2).editableElement as DomHTMLInputElement).value = 'passB'; + + // Focus A again. The framework now knows both values. + strategy.enable( + configFor(0, ['userA', 'passB']), + onChange: (_, _) {}, + onAction: (_) {}, + ); + focusNode(1); + + final EngineAutofillForm group = strategy.inputConfiguration.autofillGroup!; + final DomHTMLFormElement formElement = group.formElement!; + + // Same DOM form reused across every wake/dormant cycle. + expect(formElement, formA.formElement); + + // A is focused: real element linked by attribute, never moved into or + // duplicated in the form. + expect(tester.getTextField(1).editableElement.getAttribute('form'), group.formDomId); + expect(formElement.contains(tester.getTextField(1).editableElement), isFalse); + expect((tester.getTextField(1).editableElement as DomHTMLInputElement).value, 'userA'); + + // B is not focused: exactly one synthetic carrying its latest value, no + // stale/duplicate synthetic for either field, B's real element released. + final List synthetics = formElement + .querySelectorAll('input') + .cast() + .where((DomHTMLInputElement e) => e.type != 'submit') + .toList(); + expect(synthetics, hasLength(1)); + expect(synthetics.single.name, 'current-password'); + expect(synthetics.single.value, 'passB'); + expect(group.elements.length, 1); + expect(group.elements['field2'], synthetics.single); + expect(group.elements.containsKey('field1'), isFalse); + expect(tester.getTextField(2).editableElement.getAttribute('form'), isNull); + }); + // The attribute-linked form is only built on non-Safari browsers. Safari + // fills grouped fields by its own heuristic, so the form is intentionally + // skipped there (see [SemanticsTextEditingStrategy.initializeTextEditing]). + // The Safari path is covered by the group below. + }, skip: ui_web.browser.isSafari); + + // On Safari the autofill form is not built; the native heuristic fills the + // group instead. See https://github.com/flutter/flutter/issues/180652 + group('$SemanticsTextEditingStrategy autofill group on Safari', () { + late HybridTextEditing testTextEditing; + late SemanticsTextEditingStrategy strategy; + + setUp(() { + testTextEditing = HybridTextEditing(); + SemanticsTextEditingStrategy.ensureInitialized(testTextEditing); + strategy = SemanticsTextEditingStrategy.instance; + testTextEditing.debugTextEditingStrategyOverride = strategy; + testTextEditing.configuration = singlelineConfig; + semantics() + ..debugOverrideTimestampFunction(() => _testTime) + ..semanticsEnabled = true; + }); + + tearDown(() { + if (strategy.isEnabled) { + strategy.disable(); + } + cleanForms(); + semantics().semanticsEnabled = false; + domDocument.activeElement?.blur(); + }); + + test('does not build the form and does not link the focused field', () { + final List> fields = _autofillFields( + ['username', 'password'], + ['field1', 'field2'], + ); + final focusedMap = fields.first['autofill']! as Map; + final EngineAutofillForm form = EngineAutofillForm.fromFrameworkMessage( + kImplicitViewId, + focusedMap, + fields, + )!; + final config = InputConfiguration( + viewId: kImplicitViewId, + autofill: AutofillInfo.fromFrameworkMessage(focusedMap), + autofillGroup: form, + ); + strategy.enable(config, onChange: (_, _) {}, onAction: (_) {}); + final SemanticsObject semanticsObject = createTextFieldSemantics(value: '', isFocused: true); + final textField = semanticsObject.semanticRole! as SemanticTextField; + + // No form is created and the focused field is not linked by attribute. + expect(form.formElement, isNull); + expect(textField.editableElement.getAttribute('form'), isNull); + }, skip: !ui_web.browser.isSafari); + }); +} + +/// Builds the `fields` list of a `TextInputConfiguration` autofill group, the +/// same shape the framework sends over the `flutter/textinput` channel. +List> _autofillFields( + List hints, + List uniqueIds, { + List? values, +}) { + assert(hints.length == uniqueIds.length); + assert(values == null || values.length == hints.length); + return >[ + for (var i = 0; i < hints.length; i++) + { + 'inputType': { + 'name': 'TextInputType.text', + 'signed': null, + 'decimal': null, + }, + 'textCapitalization': 'TextCapitalization.none', + 'autofill': { + 'uniqueIdentifier': uniqueIds[i], + 'hints': [hints[i]], + 'editingValue': { + 'text': values?[i] ?? '', + 'selectionBase': 0, + 'selectionExtent': 0, + 'selectionAffinity': 'TextAffinity.downstream', + 'selectionIsDirectional': false, + 'composingBase': -1, + 'composingExtent': -1, + }, + }, + }, + ]; } SemanticsObject createTextFieldSemantics({ diff --git a/engine/src/flutter/runtime/dart_vm.cc b/engine/src/flutter/runtime/dart_vm.cc index 9545914254b89..42df4beadfb2a 100644 --- a/engine/src/flutter/runtime/dart_vm.cc +++ b/engine/src/flutter/runtime/dart_vm.cc @@ -43,16 +43,6 @@ static const char* kDartAllConfigsArgs[] = { static const char* kDartPrecompilationArgs[] = {"--precompilation"}; -static const char* kSerialGCArgs[] = { - // clang-format off - "--concurrent_mark=false", - "--concurrent_sweep=false", - "--compactor_tasks=1", - "--scavenger_tasks=0", - "--marker_tasks=0", - // clang-format on -}; - [[maybe_unused]] static const char* kDartWriteProtectCodeArgs[] = { "--no_write_protect_code", @@ -367,13 +357,6 @@ DartVM::DartVM(const std::shared_ptr& vm_data, PushBackAll(&args, kDartAssertArgs, std::size(kDartAssertArgs)); } - // On low power devices with lesser number of cores, using concurrent - // marking or sweeping causes contention for the UI thread leading to - // Jank, this option can be used to turn off all concurrent GC activities. - if (settings_.enable_serial_gc) { - PushBackAll(&args, kSerialGCArgs, std::size(kSerialGCArgs)); - } - if (settings_.start_paused) { PushBackAll(&args, kDartStartPausedArgs, std::size(kDartStartPausedArgs)); } diff --git a/engine/src/flutter/shell/common/platform_view.h b/engine/src/flutter/shell/common/platform_view.h index 95384c3f79366..1c744176364fd 100644 --- a/engine/src/flutter/shell/common/platform_view.h +++ b/engine/src/flutter/shell/common/platform_view.h @@ -386,6 +386,16 @@ class PlatformView { /// @return The settings. /// virtual const Settings& OnPlatformViewGetSettings() const = 0; + + //-------------------------------------------------------------------------- + /// @brief Returns a task runner that executes tasks on the IO thread + /// and stops running tasks after the shell shuts down the IO + /// thread. + /// + /// @return The task runner. + /// + virtual std::shared_ptr + OnPlatformViewGetShutdownSafeIOTaskRunner() const = 0; }; //---------------------------------------------------------------------------- diff --git a/engine/src/flutter/shell/common/rasterizer.cc b/engine/src/flutter/shell/common/rasterizer.cc index 6af8bfc91d5a7..b003f827df24b 100644 --- a/engine/src/flutter/shell/common/rasterizer.cc +++ b/engine/src/flutter/shell/common/rasterizer.cc @@ -129,6 +129,8 @@ void Rasterizer::Teardown() { } #endif // !SLIMPELLER } + context_switch.reset(); + surface_->ClearRenderContext(); surface_.reset(); } diff --git a/engine/src/flutter/shell/common/rasterizer_unittests.cc b/engine/src/flutter/shell/common/rasterizer_unittests.cc index bab565efe08d1..ca1b776f91252 100644 --- a/engine/src/flutter/shell/common/rasterizer_unittests.cc +++ b/engine/src/flutter/shell/common/rasterizer_unittests.cc @@ -1215,6 +1215,40 @@ TEST(RasterizerTest, TeardownFreesResourceCache) { EXPECT_EQ(context->getResourceCachePurgeableBytes(), 0ul); } +TEST(RasterizerTest, TeardownClearsRenderContext) { + std::string test_name = + ::testing::UnitTest::GetInstance()->current_test_info()->name(); + ThreadHost thread_host("io.flutter.test." + test_name + ".", + ThreadHost::Type::kPlatform | + ThreadHost::Type::kRaster | ThreadHost::Type::kIo | + ThreadHost::Type::kUi); + TaskRunners task_runners("test", thread_host.platform_thread->GetTaskRunner(), + thread_host.raster_thread->GetTaskRunner(), + thread_host.ui_thread->GetTaskRunner(), + thread_host.io_thread->GetTaskRunner()); + NiceMock delegate; + Settings settings; + ON_CALL(delegate, GetSettings()).WillByDefault(ReturnRef(settings)); + ON_CALL(delegate, GetTaskRunners()).WillByDefault(ReturnRef(task_runners)); + auto rasterizer = std::make_unique(delegate); + auto surface = std::make_unique>(); + bool render_context_is_current = false; + EXPECT_CALL(*surface, MakeRenderContextCurrent()).WillRepeatedly([&]() { + render_context_is_current = true; + return std::make_unique(true); + }); + EXPECT_CALL(*surface, ClearRenderContext()).WillRepeatedly([&]() { + render_context_is_current = false; + return true; + }); + + rasterizer->Setup(std::move(surface)); + EXPECT_TRUE(render_context_is_current); + + rasterizer->Teardown(); + EXPECT_FALSE(render_context_is_current); +} + TEST(RasterizerTest, TeardownNoSurface) { std::string test_name = ::testing::UnitTest::GetInstance()->current_test_info()->name(); diff --git a/engine/src/flutter/shell/common/shell.cc b/engine/src/flutter/shell/common/shell.cc index 526d2a7984125..a4414c6cb0953 100644 --- a/engine/src/flutter/shell/common/shell.cc +++ b/engine/src/flutter/shell/common/shell.cc @@ -25,6 +25,7 @@ #include "flutter/fml/make_copyable.h" #include "flutter/fml/message_loop.h" #include "flutter/fml/paths.h" +#include "flutter/fml/task_runner_util.h" #include "flutter/fml/trace_event.h" #include "flutter/runtime/dart_vm.h" #include "flutter/shell/common/base64.h" @@ -545,6 +546,15 @@ Shell::Shell(DartVMRef vm, resource_cache_limit_calculator->AddResourceCacheLimitItem( weak_factory_.GetWeakPtr()); + std::shared_future> weak_io_manager_future( + weak_io_manager_promise_.get_future()); + shutdown_safe_io_task_runner_ = + std::make_shared( + task_runners_.GetIOTaskRunner(), + [weak_io_manager_future = std::move(weak_io_manager_future)] { + return static_cast(weak_io_manager_future.get()); + }); + // Generate a WeakPtrFactory for use with the raster thread. This does not // need to wait on a latch because it can only ever be used from the raster // thread from this class, so we have ordering guarantees. @@ -873,6 +883,7 @@ bool Shell::Setup(std::unique_ptr platform_view, engine_ = std::move(engine); rasterizer_ = std::move(rasterizer); io_manager_ = io_manager; + weak_io_manager_promise_.set_value(io_manager_->GetWeakPtr()); // Set the external view embedder for the rasterizer. auto view_embedder = platform_view_->CreateExternalViewEmbedder(); @@ -951,6 +962,10 @@ fml::WeakPtr Shell::GetIOManager() { return io_manager_->GetWeakPtr(); } +std::shared_ptr Shell::GetShutdownSafeIOTaskRunner() { + return shutdown_safe_io_task_runner_; +} + DartVM* Shell::GetDartVM() { return &vm_; } @@ -1367,6 +1382,12 @@ const Settings& Shell::OnPlatformViewGetSettings() const { return settings_; } +// |PlatformView::Delegate| +std::shared_ptr +Shell::OnPlatformViewGetShutdownSafeIOTaskRunner() const { + return shutdown_safe_io_task_runner_; +} + // |Animator::Delegate| void Shell::OnAnimatorBeginFrame(fml::TimePoint frame_target_time, uint64_t frame_number) { @@ -2381,16 +2402,28 @@ fml::Status Shell::WaitForFirstFrame(fml::TimeDelta timeout) { std::unique_lock lock(waiting_for_first_frame_mutex_); bool success = waiting_for_first_frame_condition_.wait_until( - lock, duration, [&waiting_for_first_frame = waiting_for_first_frame_] { - return !waiting_for_first_frame.load(); + lock, duration, + [&waiting_for_first_frame = waiting_for_first_frame_, + &cancelled = wait_for_first_frame_cancelled_] { + return !waiting_for_first_frame.load() || cancelled; }); - if (success) { + if (wait_for_first_frame_cancelled_) { + return fml::Status(fml::StatusCode::kAborted, "Shell is shutting down."); + } else if (success) { return fml::Status(); } else { return fml::Status(fml::StatusCode::kDeadlineExceeded, "timeout"); } } +void Shell::CancelWaitForFirstFrame() { + { + std::scoped_lock lock(waiting_for_first_frame_mutex_); + wait_for_first_frame_cancelled_ = true; + } + waiting_for_first_frame_condition_.notify_all(); +} + bool Shell::ReloadSystemFonts() { FML_DCHECK(is_set_up_); FML_DCHECK(task_runners_.GetPlatformTaskRunner()->RunsTasksOnCurrentThread()); diff --git a/engine/src/flutter/shell/common/shell.h b/engine/src/flutter/shell/common/shell.h index 9953a560f5d69..bc845af795516 100644 --- a/engine/src/flutter/shell/common/shell.h +++ b/engine/src/flutter/shell/common/shell.h @@ -280,6 +280,19 @@ class Shell final : public PlatformView::Delegate, /// fml::WeakPtr GetIOManager(); + //---------------------------------------------------------------------------- + /// @brief The IO thread can be used for background tasks, including + /// tasks that perform graphics operations using the resource + /// context. But the IO thread will lose the resource context + /// during shutdown of the Shell. Tasks that require the IO + /// manager or the resource context must not run after that + /// phase of shutdown. + /// + /// @return A BasicTaskRunner that posts tasks to the IO thread but stops + /// running tasks after the Shell shuts down the IO manager. + /// + std::shared_ptr GetShutdownSafeIOTaskRunner(); + // Embedders should call this under low memory conditions to free up // internal caches used. // @@ -339,6 +352,21 @@ class Shell final : public PlatformView::Delegate, /// fml::Status WaitForFirstFrame(fml::TimeDelta timeout); + //---------------------------------------------------------------------------- + /// @brief Unblocks any call to WaitForFirstFrame(), causing it to + /// immediately return 'kAborted' instead of blocking for the + /// full timeout. + /// + /// Embedders that pass a reference to the Shell to a thread they + /// do not otherwise synchronize with the shell's destruction + /// must call this, and wait for that thread to finish with the + /// shell, before destroying it. This method only prevents + /// WaitForFirstFrame() from blocking; it does not by itself + /// make it safe to destroy the Shell out from under a caller + /// that has not yet returned from WaitForFirstFrame(). + /// + void CancelWaitForFirstFrame(); + //---------------------------------------------------------------------------- /// @brief Used by embedders to reload the system fonts in /// FontCollection. @@ -480,6 +508,9 @@ class Shell final : public PlatformView::Delegate, fml::WeakPtr weak_platform_view_; // to be shared across threads + std::promise> weak_io_manager_promise_; + std::shared_ptr shutdown_safe_io_task_runner_; + std::unordered_map, ServiceProtocolHandler> // task-runner/function @@ -491,7 +522,19 @@ class Shell final : public PlatformView::Delegate, uint64_t next_pointer_flow_id_ = 0; bool first_frame_rasterized_ = false; + + // True if a first frame has not yet been rendered. + // + // This is read and written lock-free on the raster thread, and read under + // waiting_for_first_frame_mutex_ in WaitForFirstFrame. std::atomic waiting_for_first_frame_ = true; + + // True when WaitForFirstFrame has been cancelled because the shell is + // shutting down and waiting threads should be unblocked. + // + // Guarded by waiting_for_first_frame_mutex_. + bool wait_for_first_frame_cancelled_ = false; + std::mutex waiting_for_first_frame_mutex_; std::condition_variable waiting_for_first_frame_condition_; @@ -640,6 +683,10 @@ class Shell final : public PlatformView::Delegate, // |PlatformView::Delegate| const Settings& OnPlatformViewGetSettings() const override; + // |PlatformView::Delegate| + std::shared_ptr + OnPlatformViewGetShutdownSafeIOTaskRunner() const override; + // |PlatformView::Delegate| void LoadDartDeferredLibrary( intptr_t loading_unit_id, diff --git a/engine/src/flutter/shell/common/shell_test_platform_view_gl.cc b/engine/src/flutter/shell/common/shell_test_platform_view_gl.cc index 5ee5e7b18a935..20383e34e6b5c 100644 --- a/engine/src/flutter/shell/common/shell_test_platform_view_gl.cc +++ b/engine/src/flutter/shell/common/shell_test_platform_view_gl.cc @@ -8,6 +8,7 @@ #include +#include "flutter/fml/task_runner_util.h" #include "flutter/shell/gpu/gpu_surface_gl_skia.h" #include "impeller/entity/gles/entity_shaders_gles.h" @@ -61,7 +62,9 @@ ShellTestPlatformViewGL::ShellTestPlatformViewGL( return; } impeller_context_ = impeller::ContextGLES::Create( - impeller::Flags{}, std::move(gl), ShaderLibraryMappings(), true); + impeller::Flags{}, std::move(gl), ShaderLibraryMappings(), true, + std::make_shared( + task_runners.GetIOTaskRunner())); } } diff --git a/engine/src/flutter/shell/common/shell_unittests.cc b/engine/src/flutter/shell/common/shell_unittests.cc index 60ff1ecbeb213..110b4ccbe9640 100644 --- a/engine/src/flutter/shell/common/shell_unittests.cc +++ b/engine/src/flutter/shell/common/shell_unittests.cc @@ -186,6 +186,11 @@ class MockPlatformViewDelegate : public PlatformView::Delegate { (), (const, override)); + MOCK_METHOD(std::shared_ptr, + OnPlatformViewGetShutdownSafeIOTaskRunner, + (), + (const, override)); + MOCK_METHOD(void, LoadDartDeferredLibrary, (intptr_t loading_unit_id, @@ -1723,6 +1728,68 @@ TEST_F(ShellTest, WaitForFirstFrameTimeout) { DestroyShell(std::move(shell)); } +// Ensure CancelWaitForFirstFrame() correctly causes all tasks blocked on +// WaitForFirstFrame() to return kAborted. +// +// See: b/521830222 +TEST_F(ShellTest, CancelWaitForFirstFrameAllowsSafeShellDestruction) { + auto settings = CreateSettingsForFixture(); + std::unique_ptr shell = CreateShell(settings); + + PlatformViewNotifyCreated(shell.get()); + + auto configuration = RunConfiguration::InferFromSettings(settings); + configuration.SetEntrypoint("emptyMain"); + RunEngine(shell.get(), std::move(configuration)); + // No PumpOneFrame: waiting_for_first_frame_ stays true, so + // WaitForFirstFrame would otherwise park on the condvar for the full + // timeout below. + + fml::AutoResetWaitableEvent bg_has_ref; + fml::AutoResetWaitableEvent proceed_with_wait; + + // Background thread holds a raw Shell* obtained while the shell was still + // live -- exactly what `strongSelf.shell` (-> `*_shell`) hands the GCD + // block in -[FlutterEngine waitForFirstFrame:callback:]. + Shell* raw_shell = shell.get(); + fml::Status background_result; + std::thread background( + [raw_shell, &bg_has_ref, &proceed_with_wait, &background_result] { + bg_has_ref.Signal(); + proceed_with_wait.Wait(); + // A well-behaved caller must not still be here once the owner has + // finished destroying the Shell. CancelWaitForFirstFrame() below makes + // sure this call returns promptly instead of blocking for 30 seconds. + background_result = + raw_shell->WaitForFirstFrame(fml::TimeDelta::FromSeconds(30)); + }); + + bg_has_ref.Wait(); + proceed_with_wait.Signal(); + + // Give the background thread a chance to actually call WaitForFirstFrame() + // before it is cancelled below. If it hasn't gotten there yet, cancellation + // is still observed safely (and just as fast) the moment it does. + std::this_thread::yield(); + + fml::TimePoint cancel_start = fml::TimePoint::Now(); + // Models -[FlutterEngine destroyContext]: cancel any in-flight waiter, + // then join it, before freeing the Shell. + raw_shell->CancelWaitForFirstFrame(); + background.join(); + fml::TimeDelta elapsed = fml::TimePoint::Now() - cancel_start; + + // The whole point of CancelWaitForFirstFrame() is to avoid blocking the + // owner for anywhere near the caller's requested timeout. + EXPECT_LT(elapsed.ToSecondsF(), 5.0); + ASSERT_FALSE(background_result.ok()); + ASSERT_EQ(background_result.code(), fml::StatusCode::kAborted); + + // Only safe to destroy now that the background thread has been joined, + // i.e. is guaranteed to no longer be touching the Shell. + DestroyShell(std::move(shell)); +} + TEST_F(ShellTest, WaitForFirstFrameMultiple) { auto settings = CreateSettingsForFixture(); std::unique_ptr shell = CreateShell(settings); diff --git a/engine/src/flutter/shell/common/switch_defs.h b/engine/src/flutter/shell/common/switch_defs.h index 696b43a1dae59..d95789073cb23 100644 --- a/engine/src/flutter/shell/common/switch_defs.h +++ b/engine/src/flutter/shell/common/switch_defs.h @@ -219,12 +219,6 @@ DEF_SWITCH(DisableDartAsserts, "disabled. This flag may be specified if the user wishes to run " "with assertions disabled in the debug product mode (i.e. with JIT " "or DBC).") -DEF_SWITCH(EnableSerialGC, - "enable-serial-gc", - "On low power devices with low core counts, running concurrent " - "GC tasks on threads can cause them to contend with the UI thread " - "which could potentially lead to jank. This option turns off all " - "concurrent GC activities") DEF_SWITCH(DisallowInsecureConnections, "disallow-insecure-connections", "By default, dart:io allows all socket connections. If this switch " diff --git a/engine/src/flutter/shell/common/switches.cc b/engine/src/flutter/shell/common/switches.cc index bffc7b3f9182b..9948072de2fcd 100644 --- a/engine/src/flutter/shell/common/switches.cc +++ b/engine/src/flutter/shell/common/switches.cc @@ -311,9 +311,6 @@ Settings SettingsFromCommandLine(const fml::CommandLine& command_line, settings.trace_startup = command_line.HasOption(FlagForSwitch(Switch::TraceStartup)); - settings.enable_serial_gc = - command_line.HasOption(FlagForSwitch(Switch::EnableSerialGC)); - #if !FLUTTER_RELEASE settings.trace_skia = true; diff --git a/engine/src/flutter/shell/common/switches_unittests.cc b/engine/src/flutter/shell/common/switches_unittests.cc index 8042c3a59bb03..9c89905f2dd2b 100644 --- a/engine/src/flutter/shell/common/switches_unittests.cc +++ b/engine/src/flutter/shell/common/switches_unittests.cc @@ -161,6 +161,19 @@ TEST(SwitchesTest, RequireMergedPlatformUIThread) { "This platform does not support the " "merged-platform-ui-thread=disabled flag"); } + +// Ensure mergeAfterLaunch is passed correctly. +// +// This is a supported threading model even on some platforms (e.g. Android) +// that enforce a merged platform/UI thread. For embedders where this isn't a +// supported behavior, it can be blocked in the embedder itself. +TEST(SwitchesTest, RequireMergedPlatformUIThreadAllowsMergeAfterLaunch) { + fml::CommandLine command_line = fml::CommandLineFromInitializerList( + {"command", "--merged-platform-ui-thread=mergeAfterLaunch"}); + Settings settings = SettingsFromCommandLine(command_line, true); + EXPECT_EQ(settings.merged_platform_ui_thread, + Settings::MergedPlatformUIThread::kMergeAfterLaunch); +} #endif // !OS_FUCHSIA } // namespace testing diff --git a/engine/src/flutter/shell/platform/android/android_context_dynamic_impeller.cc b/engine/src/flutter/shell/platform/android/android_context_dynamic_impeller.cc index c65f926ea86a5..94f38b04f04bb 100644 --- a/engine/src/flutter/shell/platform/android/android_context_dynamic_impeller.cc +++ b/engine/src/flutter/shell/platform/android/android_context_dynamic_impeller.cc @@ -140,9 +140,11 @@ GetActualRenderingAPIForImpeller( } // namespace AndroidContextDynamicImpeller::AndroidContextDynamicImpeller( - const AndroidContext::ContextSettings& settings) + const AndroidContext::ContextSettings& settings, + std::shared_ptr io_task_runner) : AndroidContext(AndroidRenderingAPI::kImpellerVulkan), - settings_(settings) {} + settings_(settings), + io_task_runner_(std::move(io_task_runner)) {} AndroidContextDynamicImpeller::~AndroidContextDynamicImpeller() = default; @@ -186,7 +188,7 @@ void AndroidContextDynamicImpeller::SetupImpellerContext() { if (!vk_context_) { gl_context_ = std::make_shared( std::make_unique(), - settings_.enable_gpu_tracing); + settings_.enable_gpu_tracing, io_task_runner_); } } diff --git a/engine/src/flutter/shell/platform/android/android_context_dynamic_impeller.h b/engine/src/flutter/shell/platform/android/android_context_dynamic_impeller.h index 4de037bfa77ba..17aa12fa074af 100644 --- a/engine/src/flutter/shell/platform/android/android_context_dynamic_impeller.h +++ b/engine/src/flutter/shell/platform/android/android_context_dynamic_impeller.h @@ -23,7 +23,8 @@ namespace flutter { class AndroidContextDynamicImpeller : public AndroidContext { public: explicit AndroidContextDynamicImpeller( - const AndroidContext::ContextSettings& settings); + const AndroidContext::ContextSettings& settings, + std::shared_ptr io_task_runner); ~AndroidContextDynamicImpeller(); @@ -51,6 +52,7 @@ class AndroidContextDynamicImpeller : public AndroidContext { const AndroidContext::ContextSettings settings_; std::shared_ptr gl_context_; std::shared_ptr vk_context_; + std::shared_ptr io_task_runner_; FML_DISALLOW_COPY_AND_ASSIGN(AndroidContextDynamicImpeller); }; diff --git a/engine/src/flutter/shell/platform/android/android_context_gl_impeller.cc b/engine/src/flutter/shell/platform/android/android_context_gl_impeller.cc index 79c0ec66bd25b..128fe13042bab 100644 --- a/engine/src/flutter/shell/platform/android/android_context_gl_impeller.cc +++ b/engine/src/flutter/shell/platform/android/android_context_gl_impeller.cc @@ -51,7 +51,8 @@ class AndroidContextGLImpeller::ReactorWorker final static std::shared_ptr CreateImpellerContext( const std::shared_ptr& worker, - bool enable_gpu_tracing) { + bool enable_gpu_tracing, + std::shared_ptr io_task_runner) { auto proc_table = std::make_unique( impeller::egl::CreateProcAddressResolver()); @@ -85,11 +86,11 @@ static std::shared_ptr CreateImpellerContext( auto context = impeller::ContextGLES::Create( impeller::Flags{}, std::move(proc_table), is_gles3 ? gles3_shader_mappings : gles2_shader_mappings, - enable_gpu_tracing); + enable_gpu_tracing, std::move(io_task_runner)); #else - auto context = - impeller::ContextGLES::Create(impeller::Flags{}, std::move(proc_table), - gles2_shader_mappings, enable_gpu_tracing); + auto context = impeller::ContextGLES::Create( + impeller::Flags{}, std::move(proc_table), gles2_shader_mappings, + enable_gpu_tracing, std::move(io_task_runner)); #endif // !SLIMPELLER if (!context) { @@ -107,10 +108,12 @@ static std::shared_ptr CreateImpellerContext( AndroidContextGLImpeller::AndroidContextGLImpeller( std::unique_ptr display, - bool enable_gpu_tracing) + bool enable_gpu_tracing, + std::shared_ptr io_task_runner) : AndroidContext(AndroidRenderingAPI::kImpellerOpenGLES), reactor_worker_(std::shared_ptr(new ReactorWorker())), - display_(std::move(display)) { + display_(std::move(display)), + io_task_runner_(std::move(io_task_runner)) { if (!display_ || !display_->IsValid()) { FML_LOG(ERROR) << "Could not create context with invalid EGL display."; return; @@ -174,8 +177,8 @@ AndroidContextGLImpeller::AndroidContextGLImpeller( return; } - auto impeller_context = - CreateImpellerContext(reactor_worker_, enable_gpu_tracing); + auto impeller_context = CreateImpellerContext( + reactor_worker_, enable_gpu_tracing, io_task_runner_); if (!impeller_context) { FML_LOG(ERROR) << "Could not create Impeller context."; diff --git a/engine/src/flutter/shell/platform/android/android_context_gl_impeller.h b/engine/src/flutter/shell/platform/android/android_context_gl_impeller.h index e6d68d8444e38..9983a69752e5c 100644 --- a/engine/src/flutter/shell/platform/android/android_context_gl_impeller.h +++ b/engine/src/flutter/shell/platform/android/android_context_gl_impeller.h @@ -6,6 +6,7 @@ #define FLUTTER_SHELL_PLATFORM_ANDROID_ANDROID_CONTEXT_GL_IMPELLER_H_ #include "flutter/fml/macros.h" +#include "flutter/fml/task_runner.h" #include "flutter/impeller/toolkit/egl/display.h" #include "flutter/shell/platform/android/context/android_context.h" @@ -13,8 +14,10 @@ namespace flutter { class AndroidContextGLImpeller : public AndroidContext { public: - AndroidContextGLImpeller(std::unique_ptr display, - bool enable_gpu_tracing); + AndroidContextGLImpeller( + std::unique_ptr display, + bool enable_gpu_tracing, + std::shared_ptr io_task_runner); ~AndroidContextGLImpeller(); @@ -42,6 +45,7 @@ class AndroidContextGLImpeller : public AndroidContext { std::unique_ptr onscreen_context_; std::unique_ptr offscreen_context_; bool is_valid_ = false; + std::shared_ptr io_task_runner_; FML_DISALLOW_COPY_AND_ASSIGN(AndroidContextGLImpeller); }; diff --git a/engine/src/flutter/shell/platform/android/android_context_gl_impeller_unittests.cc b/engine/src/flutter/shell/platform/android/android_context_gl_impeller_unittests.cc index b74c068de093a..ab43170d8331b 100644 --- a/engine/src/flutter/shell/platform/android/android_context_gl_impeller_unittests.cc +++ b/engine/src/flutter/shell/platform/android/android_context_gl_impeller_unittests.cc @@ -84,8 +84,8 @@ TEST_F(AndroidContextGLImpellerTest, MSAAFirstAttempt) { .WillOnce(Return(ByMove(std::move(second_result)))); ON_CALL(*display, ChooseConfig(_)) .WillByDefault(Return(ByMove(std::unique_ptr()))); - auto context = - std::make_unique(std::move(display), true); + auto context = std::make_unique(std::move(display), + true, nullptr); ASSERT_TRUE(context); } @@ -131,8 +131,8 @@ TEST_F(AndroidContextGLImpellerTest, FallbackForEmulator) { .WillOnce(Return(ByMove(std::move(fourth_result)))); ON_CALL(*display, ChooseConfig(_)) .WillByDefault(Return(ByMove(std::unique_ptr()))); - auto context = - std::make_unique(std::move(display), true); + auto context = std::make_unique(std::move(display), + true, nullptr); ASSERT_TRUE(context); } diff --git a/engine/src/flutter/shell/platform/android/build.gradle b/engine/src/flutter/shell/platform/android/build.gradle index 9a2fe44cf0dbe..a49169463efbd 100644 --- a/engine/src/flutter/shell/platform/android/build.gradle +++ b/engine/src/flutter/shell/platform/android/build.gradle @@ -10,7 +10,7 @@ buildscript { dependencies { // Consult the Android team before bumping. This is only used for IDE support, so // it does not need to be bumped as part of most repo-wide upgrades. - classpath "com.android.tools.build:gradle:8.9.1" + classpath "com.android.tools.build:gradle:9.1.0" } } diff --git a/engine/src/flutter/shell/platform/android/external_view_embedder/external_view_embedder_2.cc b/engine/src/flutter/shell/platform/android/external_view_embedder/external_view_embedder_2.cc index a9bb173cc8366..cadb90f1459a6 100644 --- a/engine/src/flutter/shell/platform/android/external_view_embedder/external_view_embedder_2.cc +++ b/engine/src/flutter/shell/platform/android/external_view_embedder/external_view_embedder_2.cc @@ -177,7 +177,6 @@ void AndroidExternalViewEmbedder2::SubmitFlutterView( } else { HideOverlayLayerIfNeeded(); } - jni_facade->swapTransaction(); for (int64_t view_id : composition_order) { DlRect view_rect = GetViewRect(view_id, view_params); @@ -200,6 +199,7 @@ void AndroidExternalViewEmbedder2::SubmitFlutterView( jni_facade->hidePlatformView2(view_id); } + jni_facade->swapTransaction(); jni_facade_->onEndFrame2(); })); diff --git a/engine/src/flutter/shell/platform/android/external_view_embedder/external_view_embedder_unittests.cc b/engine/src/flutter/shell/platform/android/external_view_embedder/external_view_embedder_unittests.cc index fd867b27a864e..dc15f144864cf 100644 --- a/engine/src/flutter/shell/platform/android/external_view_embedder/external_view_embedder_unittests.cc +++ b/engine/src/flutter/shell/platform/android/external_view_embedder/external_view_embedder_unittests.cc @@ -7,6 +7,7 @@ #include #include "flutter/shell/platform/android/external_view_embedder/external_view_embedder.h" +#include "flutter/shell/platform/android/external_view_embedder/external_view_embedder_2.h" #include "flutter/flow/embedded_views.h" #include "flutter/flow/surface.h" @@ -1112,5 +1113,80 @@ TEST(AndroidExternalViewEmbedder, TeardownDoesNotCallJNIMethod) { embedder->Teardown(); } +TEST(AndroidExternalViewEmbedder2, + SwapsTransactionsAfterDisplayingPlatformViews) { + auto jni_mock = std::make_shared(); + auto android_context = + std::make_shared(AndroidRenderingAPI::kSoftware); + ThreadHost thread_host("io.flutter.test." + GetCurrentTestName() + ".", + ThreadHost::Type::kPlatform | ThreadHost::Type::kIo | + ThreadHost::Type::kUi | ThreadHost::Type::kRaster); + TaskRunners task_runners( + "test", + thread_host.platform_thread->GetTaskRunner(), // platform + thread_host.raster_thread->GetTaskRunner(), // raster + thread_host.ui_thread->GetTaskRunner(), // ui + thread_host.io_thread->GetTaskRunner() // io + ); + auto surface_factory = std::make_shared([]() { + auto android_surface = std::make_unique(); + EXPECT_CALL(*android_surface, IsValid()).WillRepeatedly(Return(true)); + EXPECT_CALL(*android_surface, SetNativeWindow(_, _)) + .WillRepeatedly(Return(true)); + EXPECT_CALL(*android_surface, CreateGPUSurface(_)) + .WillRepeatedly(Return(ByMove(std::make_unique()))); + return android_surface; + }); + + fml::RefPtr window = + fml::MakeRefCounted(nullptr); + EXPECT_CALL(*jni_mock, createOverlaySurface2()) + .WillRepeatedly(Return( + ByMove(std::make_unique( + 0, window)))); + + auto embedder = std::make_unique( + *android_context, jni_mock, surface_factory, task_runners); + + const DlISize frame_size(100, 100); + const int64_t view_id = 42; + MutatorsStack mutators; + DlMatrix matrix = DlMatrix::MakeTranslation({0, 0}); + + embedder->PrepareFlutterView(frame_size, 1.0); + embedder->PrerollCompositeEmbeddedView( + view_id, + std::make_unique(matrix, DlSize(50, 50), mutators)); + embedder->CompositeEmbeddedView(view_id); + + { + ::testing::InSequence sequence; + + EXPECT_CALL(*jni_mock, onDisplayPlatformView2(view_id, 0, 0, 50, 50, 50, 50, + mutators)); + EXPECT_CALL(*jni_mock, swapTransaction()); + EXPECT_CALL(*jni_mock, onEndFrame2()); + } + + SurfaceFrame::FramebufferInfo framebuffer_info; + auto surface_frame = std::make_unique( + SkSurfaces::Null(100, 100), framebuffer_info, + [](const SurfaceFrame& surface_frame, DlCanvas* canvas) { return true; }, + [](const SurfaceFrame& surface_frame) { return true; }, + /*frame_size=*/frame_size); + + fml::AutoResetWaitableEvent latch; + fml::TaskRunner::RunNowOrPostTask(task_runners.GetRasterTaskRunner(), [&]() { + embedder->SubmitFlutterView(kImplicitViewId, nullptr, nullptr, + std::move(surface_frame)); + fml::TaskRunner::RunNowOrPostTask(task_runners.GetPlatformTaskRunner(), + [&latch]() { latch.Signal(); }); + }); + latch.Wait(); + + embedder->Teardown(); + embedder.reset(); +} + } // namespace testing } // namespace flutter diff --git a/engine/src/flutter/shell/platform/android/platform_view_android.cc b/engine/src/flutter/shell/platform/android/platform_view_android.cc index dfafb6788a7b8..15915dd6b2252 100644 --- a/engine/src/flutter/shell/platform/android/platform_view_android.cc +++ b/engine/src/flutter/shell/platform/android/platform_view_android.cc @@ -106,7 +106,8 @@ static std::shared_ptr CreateAndroidContext( const flutter::TaskRunners& task_runners, AndroidRenderingAPI android_rendering_api, bool enable_opengl_gpu_tracing, - const AndroidContext::ContextSettings& settings) { + const AndroidContext::ContextSettings& settings, + std::shared_ptr io_task_runner) { switch (android_rendering_api) { #if !SLIMPELLER case AndroidRenderingAPI::kSoftware: @@ -121,11 +122,12 @@ static std::shared_ptr CreateAndroidContext( return std::make_unique(settings); case AndroidRenderingAPI::kImpellerOpenGLES: return std::make_unique( - std::make_unique(), - enable_opengl_gpu_tracing); + std::make_unique(), enable_opengl_gpu_tracing, + std::move(io_task_runner)); case AndroidRenderingAPI::kImpellerAutoselect: // Determine if we're using GL or Vulkan. - return std::make_unique(settings); + return std::make_unique( + settings, std::move(io_task_runner)); } FML_UNREACHABLE(); } @@ -143,7 +145,8 @@ PlatformViewAndroid::PlatformViewAndroid( task_runners, rendering_api, delegate.OnPlatformViewGetSettings().enable_opengl_gpu_tracing, - CreateContextSettings(delegate.OnPlatformViewGetSettings()))) {} + CreateContextSettings(delegate.OnPlatformViewGetSettings()), + delegate.OnPlatformViewGetShutdownSafeIOTaskRunner())) {} PlatformViewAndroid::PlatformViewAndroid( PlatformView::Delegate& delegate, diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/android/FlutterFragmentActivityTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/android/FlutterFragmentActivityTest.java index 91f5187d16c83..5d310974d916a 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/android/FlutterFragmentActivityTest.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/android/FlutterFragmentActivityTest.java @@ -70,7 +70,7 @@ public void tearDown() { @Test public void createFlutterFragment_defaultRenderModeSurface() { final FlutterFragmentActivity activity = new FakeFlutterFragmentActivity(); - assertEquals(activity.createFlutterFragment().getRenderMode(), RenderMode.surface); + assertEquals(RenderMode.surface, activity.createFlutterFragment().getRenderMode()); } @Test @@ -82,7 +82,7 @@ protected BackgroundMode getBackgroundMode() { return BackgroundMode.transparent; } }; - assertEquals(activity.createFlutterFragment().getRenderMode(), RenderMode.texture); + assertEquals(RenderMode.texture, activity.createFlutterFragment().getRenderMode()); } @Test @@ -94,7 +94,7 @@ protected RenderMode getRenderMode() { return RenderMode.texture; } }; - assertEquals(activity.createFlutterFragment().getRenderMode(), RenderMode.texture); + assertEquals(RenderMode.texture, activity.createFlutterFragment().getRenderMode()); } @Test @@ -107,7 +107,7 @@ public String getDartEntrypointLibraryUri() { } }; assertEquals( - activity.createFlutterFragment().getDartEntrypointLibraryUri(), "package:foo/bar.dart"); + "package:foo/bar.dart", activity.createFlutterFragment().getDartEntrypointLibraryUri()); } @Test diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/android/KeyChannelResponderTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/android/KeyChannelResponderTest.java index 8626d0e78db46..7fad24c620494 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/android/KeyChannelResponderTest.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/android/KeyChannelResponderTest.java @@ -53,6 +53,6 @@ public void primaryResponderTest() { (canHandleEvent) -> { completionCallbackInvocationCounter[0]++; }); - assertEquals(completionCallbackInvocationCounter[0], 1); + assertEquals(1, completionCallbackInvocationCounter[0]); } } diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/FlutterEngineTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/FlutterEngineTest.java index f0adf4465b1bf..19a35a973fb11 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/FlutterEngineTest.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/FlutterEngineTest.java @@ -2,6 +2,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.mockito.Mockito.any; import static org.mockito.Mockito.anyInt; @@ -140,10 +141,10 @@ public void itCanBeRetrievedByHandle() { assertEquals(flutterEngine1, FlutterEngine.engineForId(1)); assertEquals(flutterEngine2, FlutterEngine.engineForId(2)); flutterEngine1.destroy(); - assertEquals(null, FlutterEngine.engineForId(1)); + assertNull(FlutterEngine.engineForId(1)); assertEquals(flutterEngine2, FlutterEngine.engineForId(2)); flutterEngine2.destroy(); - assertEquals(null, FlutterEngine.engineForId(2)); + assertNull(FlutterEngine.engineForId(2)); } // Helps show the root cause of MissingPluginException type errors like diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/dart/DartExecutorTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/dart/DartExecutorTest.java index bac8a61a49413..216d48cac0cf2 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/dart/DartExecutorTest.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/dart/DartExecutorTest.java @@ -83,7 +83,7 @@ public void itHasReasonableDefaultsWhenFlutterLoaderIsInitialized() { FlutterInjector.setInstance( new FlutterInjector.Builder().setFlutterLoader(mockFlutterLoader).build()); DartEntrypoint entrypoint = DartEntrypoint.createDefault(); - assertEquals(entrypoint.pathToBundle, "my/custom/path"); - assertEquals(entrypoint.dartEntrypointFunctionName, "main"); + assertEquals("my/custom/path", entrypoint.pathToBundle); + assertEquals("main", entrypoint.dartEntrypointFunctionName); } } diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/dart/DartMessengerTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/dart/DartMessengerTest.java index c5ed3e7395e6f..d9b334c57f872 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/dart/DartMessengerTest.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/dart/DartMessengerTest.java @@ -342,7 +342,7 @@ public void testSerialTaskQueue() throws InterruptedException { latch.await(); assertEquals(count, ints.size()); for (int i = 0; i < count - 1; ++i) { - assertEquals((int) ints.get(i), (int) (ints.get(i + 1)) - 1); + assertEquals((int) ints.get(i), ints.get(i + 1) - 1); } } } diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/deferredcomponents/PlayStoreDeferredComponentManagerTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/deferredcomponents/PlayStoreDeferredComponentManagerTest.java index 20c64ea281d7b..bc983db113f7b 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/deferredcomponents/PlayStoreDeferredComponentManagerTest.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/deferredcomponents/PlayStoreDeferredComponentManagerTest.java @@ -110,18 +110,18 @@ public void downloadCallsJNIFunctions() throws NameNotFoundException { TestPlayStoreDeferredComponentManager playStoreManager = new TestPlayStoreDeferredComponentManager(spyContext, jni); jni.setDeferredComponentManager(playStoreManager); - assertEquals(jni.loadingUnitId, 0); + assertEquals(0, jni.loadingUnitId); playStoreManager.installDeferredComponent(123, "TestModuleName"); - assertEquals(jni.loadDartDeferredLibraryCalled, 1); - assertEquals(jni.updateAssetManagerCalled, 1); - assertEquals(jni.deferredComponentInstallFailureCalled, 0); + assertEquals(1, jni.loadDartDeferredLibraryCalled); + assertEquals(1, jni.updateAssetManagerCalled); + assertEquals(0, jni.deferredComponentInstallFailureCalled); - assertEquals(jni.searchPaths[0], soTestFilename); + assertEquals(soTestFilename, jni.searchPaths[0]); assertTrue(jni.searchPaths[1].endsWith(soTestPath)); - assertEquals(jni.searchPaths.length, 2); - assertEquals(jni.loadingUnitId, 123); - assertEquals(jni.assetBundlePath, "flutter_assets"); + assertEquals(2, jni.searchPaths.length); + assertEquals(123, jni.loadingUnitId); + assertEquals("flutter_assets", jni.assetBundlePath); } @Test @@ -141,18 +141,18 @@ public void downloadCallsJNIFunctionsWithFilenameFromManifest() throws NameNotFo TestPlayStoreDeferredComponentManager playStoreManager = new TestPlayStoreDeferredComponentManager(spyContext, jni); jni.setDeferredComponentManager(playStoreManager); - assertEquals(jni.loadingUnitId, 0); + assertEquals(0, jni.loadingUnitId); playStoreManager.installDeferredComponent(123, "TestModuleName"); - assertEquals(jni.loadDartDeferredLibraryCalled, 1); - assertEquals(jni.updateAssetManagerCalled, 1); - assertEquals(jni.deferredComponentInstallFailureCalled, 0); + assertEquals(1, jni.loadDartDeferredLibraryCalled); + assertEquals(1, jni.updateAssetManagerCalled); + assertEquals(0, jni.deferredComponentInstallFailureCalled); - assertEquals(jni.searchPaths[0], soTestFilename); + assertEquals(soTestFilename, jni.searchPaths[0]); assertTrue(jni.searchPaths[1].endsWith(soTestPath)); - assertEquals(jni.searchPaths.length, 2); - assertEquals(jni.loadingUnitId, 123); - assertEquals(jni.assetBundlePath, "custom_assets"); + assertEquals(2, jni.searchPaths.length); + assertEquals(123, jni.loadingUnitId); + assertEquals("custom_assets", jni.assetBundlePath); } @Test @@ -173,18 +173,18 @@ public void downloadCallsJNIFunctionsWithSharedLibraryNameFromManifest() TestPlayStoreDeferredComponentManager playStoreManager = new TestPlayStoreDeferredComponentManager(spyContext, jni); jni.setDeferredComponentManager(playStoreManager); - assertEquals(jni.loadingUnitId, 0); + assertEquals(0, jni.loadingUnitId); playStoreManager.installDeferredComponent(123, "TestModuleName"); - assertEquals(jni.loadDartDeferredLibraryCalled, 1); - assertEquals(jni.updateAssetManagerCalled, 1); - assertEquals(jni.deferredComponentInstallFailureCalled, 0); + assertEquals(1, jni.loadDartDeferredLibraryCalled); + assertEquals(1, jni.updateAssetManagerCalled); + assertEquals(0, jni.deferredComponentInstallFailureCalled); - assertEquals(jni.searchPaths[0], soTestFilename); + assertEquals(soTestFilename, jni.searchPaths[0]); assertTrue(jni.searchPaths[1].endsWith(soTestPath)); - assertEquals(jni.searchPaths.length, 2); - assertEquals(jni.loadingUnitId, 123); - assertEquals(jni.assetBundlePath, "custom_assets"); + assertEquals(2, jni.searchPaths.length); + assertEquals(123, jni.loadingUnitId); + assertEquals("custom_assets", jni.assetBundlePath); } @Test @@ -205,17 +205,17 @@ public void manifestMappingHandlesBaseModuleEmptyString() throws NameNotFoundExc PlayStoreDeferredComponentManager playStoreManager = new PlayStoreDeferredComponentManager(spyContext, jni); jni.setDeferredComponentManager(playStoreManager); - assertEquals(jni.loadingUnitId, 0); + assertEquals(0, jni.loadingUnitId); playStoreManager.installDeferredComponent(3, null); - assertEquals(jni.loadDartDeferredLibraryCalled, 1); - assertEquals(jni.updateAssetManagerCalled, 0); // no assets to load for base - assertEquals(jni.deferredComponentInstallFailureCalled, 0); + assertEquals(1, jni.loadDartDeferredLibraryCalled); + assertEquals(0, jni.updateAssetManagerCalled); // no assets to load for base + assertEquals(0, jni.deferredComponentInstallFailureCalled); - assertEquals(jni.searchPaths[0], soTestFilename); + assertEquals(soTestFilename, jni.searchPaths[0]); assertTrue(jni.searchPaths[1].endsWith(soTestPath)); - assertEquals(jni.searchPaths.length, 2); - assertEquals(jni.loadingUnitId, 3); + assertEquals(2, jni.searchPaths.length); + assertEquals(3, jni.loadingUnitId); } @Test @@ -229,17 +229,17 @@ public void searchPathsAddsApks() throws NameNotFoundException { new TestPlayStoreDeferredComponentManager(spyContext, jni); jni.setDeferredComponentManager(playStoreManager); - assertEquals(jni.loadingUnitId, 0); + assertEquals(0, jni.loadingUnitId); playStoreManager.installDeferredComponent(123, "TestModuleName"); - assertEquals(jni.loadDartDeferredLibraryCalled, 1); - assertEquals(jni.updateAssetManagerCalled, 1); - assertEquals(jni.deferredComponentInstallFailureCalled, 0); + assertEquals(1, jni.loadDartDeferredLibraryCalled); + assertEquals(1, jni.updateAssetManagerCalled); + assertEquals(0, jni.deferredComponentInstallFailureCalled); - assertEquals(jni.searchPaths[0], "libapp.so-123.part.so"); + assertEquals("libapp.so-123.part.so", jni.searchPaths[0]); assertTrue(jni.searchPaths[1].endsWith(apkTestPath + "!lib/armeabi-v7a/libapp.so-123.part.so")); - assertEquals(jni.searchPaths.length, 2); - assertEquals(jni.loadingUnitId, 123); + assertEquals(2, jni.searchPaths.length); + assertEquals(123, jni.loadingUnitId); } @Test @@ -253,17 +253,17 @@ public void searchPathsSearchesSplitConfig() throws NameNotFoundException { new TestPlayStoreDeferredComponentManager(spyContext, jni); jni.setDeferredComponentManager(playStoreManager); - assertEquals(jni.loadingUnitId, 0); + assertEquals(0, jni.loadingUnitId); playStoreManager.installDeferredComponent(123, "TestModuleName"); - assertEquals(jni.loadDartDeferredLibraryCalled, 1); - assertEquals(jni.updateAssetManagerCalled, 1); - assertEquals(jni.deferredComponentInstallFailureCalled, 0); + assertEquals(1, jni.loadDartDeferredLibraryCalled); + assertEquals(1, jni.updateAssetManagerCalled); + assertEquals(0, jni.deferredComponentInstallFailureCalled); - assertEquals(jni.searchPaths[0], "libapp.so-123.part.so"); + assertEquals("libapp.so-123.part.so", jni.searchPaths[0]); assertTrue(jni.searchPaths[1].endsWith(apkTestPath + "!lib/armeabi-v7a/libapp.so-123.part.so")); - assertEquals(jni.searchPaths.length, 2); - assertEquals(jni.loadingUnitId, 123); + assertEquals(2, jni.searchPaths.length); + assertEquals(123, jni.loadingUnitId); } @Test @@ -277,16 +277,16 @@ public void invalidSearchPathsAreIgnored() throws NameNotFoundException { new TestPlayStoreDeferredComponentManager(spyContext, jni); jni.setDeferredComponentManager(playStoreManager); - assertEquals(jni.loadingUnitId, 0); + assertEquals(0, jni.loadingUnitId); playStoreManager.installDeferredComponent(123, "TestModuleName"); - assertEquals(jni.loadDartDeferredLibraryCalled, 1); - assertEquals(jni.updateAssetManagerCalled, 1); - assertEquals(jni.deferredComponentInstallFailureCalled, 0); + assertEquals(1, jni.loadDartDeferredLibraryCalled); + assertEquals(1, jni.updateAssetManagerCalled); + assertEquals(0, jni.deferredComponentInstallFailureCalled); - assertEquals(jni.searchPaths[0], "libapp.so-123.part.so"); - assertEquals(jni.searchPaths.length, 1); - assertEquals(jni.loadingUnitId, 123); + assertEquals("libapp.so-123.part.so", jni.searchPaths[0]); + assertEquals(1, jni.searchPaths.length); + assertEquals(123, jni.loadingUnitId); } @Test @@ -301,12 +301,12 @@ public void assetManagerUpdateInvoked() throws NameNotFoundException { new TestPlayStoreDeferredComponentManager(spyContext, jni); jni.setDeferredComponentManager(playStoreManager); - assertEquals(jni.loadingUnitId, 0); + assertEquals(0, jni.loadingUnitId); playStoreManager.installDeferredComponent(123, "TestModuleName"); - assertEquals(jni.loadDartDeferredLibraryCalled, 1); - assertEquals(jni.updateAssetManagerCalled, 1); - assertEquals(jni.deferredComponentInstallFailureCalled, 0); + assertEquals(1, jni.loadDartDeferredLibraryCalled); + assertEquals(1, jni.updateAssetManagerCalled); + assertEquals(0, jni.deferredComponentInstallFailureCalled); assertEquals(jni.assetManager, assetManager); } @@ -318,7 +318,7 @@ public void stateGetterReturnsUnknowByDefault() throws NameNotFoundException { doReturn(null).when(spyContext).getAssets(); TestPlayStoreDeferredComponentManager playStoreManager = new TestPlayStoreDeferredComponentManager(spyContext, jni); - assertEquals(playStoreManager.getDeferredComponentInstallState(-1, "invalidName"), "unknown"); + assertEquals("unknown", playStoreManager.getDeferredComponentInstallState(-1, "invalidName")); } @Test diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/renderer/FlutterRendererTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/renderer/FlutterRendererTest.java index 9be4c08bd0228..fabceff7c3e79 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/renderer/FlutterRendererTest.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/renderer/FlutterRendererTest.java @@ -780,8 +780,8 @@ public void ImageReaderSurfaceProducerClampsWidthAndHeightTo1() { TextureRegistry.SurfaceProducer producer = flutterRenderer.createSurfaceProducer(); // Default values. - assertEquals(producer.getWidth(), 1); - assertEquals(producer.getHeight(), 1); + assertEquals(1, producer.getWidth()); + assertEquals(1, producer.getHeight()); // Try setting width and height to 0. producer.setSize(0, 0); @@ -790,8 +790,8 @@ public void ImageReaderSurfaceProducerClampsWidthAndHeightTo1() { assertNotNull(producer.getSurface()); // Expect clamp to 1. - assertEquals(producer.getWidth(), 1); - assertEquals(producer.getHeight(), 1); + assertEquals(1, producer.getWidth()); + assertEquals(1, producer.getHeight()); } @Test @@ -806,7 +806,7 @@ public void SurfaceTextureSurfaceProducerCreatesAConnectedTexture() { flutterRenderer.startRenderingToSurface(fakeSurface, false); // Verify behavior under test. - assertEquals(producer.id(), 0); + assertEquals(0, producer.id()); verify(fakeFlutterJNI, times(1)).registerTexture(eq(producer.id()), any()); } finally { FlutterRenderer.debugForceSurfaceProducerGlTextures = false; diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/renderer/SurfaceTextureSurfaceProducerTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/renderer/SurfaceTextureSurfaceProducerTest.java index 6dc4e997c5538..365cb901ddc0f 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/renderer/SurfaceTextureSurfaceProducerTest.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/renderer/SurfaceTextureSurfaceProducerTest.java @@ -59,7 +59,7 @@ public void createsSurfaceTextureOfGivenSizeAndResizesWhenRequested() { canvas.drawARGB(255, 255, 0, 0); surface.unlockCanvasAndPost(canvas); shadowOf(Looper.getMainLooper()).idle(); - assertEquals(frames.get(), 1); + assertEquals(1, frames.get()); // Resize and redraw. producer.setSize(400, 800); @@ -67,7 +67,7 @@ public void createsSurfaceTextureOfGivenSizeAndResizesWhenRequested() { canvas.drawARGB(255, 255, 0, 0); surface.unlockCanvasAndPost(canvas); shadowOf(Looper.getMainLooper()).idle(); - assertEquals(frames.get(), 2); + assertEquals(2, frames.get()); // Done. fakeJNI.detachFromNativeAndReleaseResources(); diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/renderer/SurfaceTextureWrapperTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/renderer/SurfaceTextureWrapperTest.java index 859b024b888b6..5897bdbf31fed 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/renderer/SurfaceTextureWrapperTest.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/renderer/SurfaceTextureWrapperTest.java @@ -4,7 +4,6 @@ package io.flutter.embedding.engine.renderer; -import static junit.framework.TestCase.*; import static org.mockito.Mockito.*; import android.graphics.SurfaceTexture; diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/systemchannels/PlatformChannelTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/systemchannels/PlatformChannelTest.java index e5a5d6c3ed2a5..3c6834179fe8b 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/systemchannels/PlatformChannelTest.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/systemchannels/PlatformChannelTest.java @@ -66,7 +66,7 @@ public void platformChannel_shareInvokeMessage() { MethodChannel.Result mockResult = mock(MethodChannel.Result.class); fakePlatformChannel.parsingMethodCallHandler.onMethodCall(methodCall, mockResult); - assertEquals(valueCapture.getValue(), expectedContent); + assertEquals(expectedContent, valueCapture.getValue()); verify(mockResult).success(null); } } diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/systemchannels/RestorationChannelTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/systemchannels/RestorationChannelTest.java index 7aefeb9a79fb8..015af071ccc82 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/systemchannels/RestorationChannelTest.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/embedding/engine/systemchannels/RestorationChannelTest.java @@ -6,6 +6,7 @@ import static io.flutter.Build.API_LEVELS; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; import static org.mockito.Mockito.any; import static org.mockito.Mockito.eq; import static org.mockito.Mockito.mock; @@ -86,7 +87,7 @@ public void itPushesNewData() throws JSONException { verify(result).success(expected); restorationChannel.setRestorationData(data); - assertEquals(restorationChannel.getRestorationData(), null); + assertNull(restorationChannel.getRestorationData()); ArgumentCaptor resultCapture = ArgumentCaptor.forClass(MethodChannel.Result.class); diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/common/StandardMessageCodecTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/common/StandardMessageCodecTest.java index 3e000e3f6073e..051e9a38cf46c 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/common/StandardMessageCodecTest.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/common/StandardMessageCodecTest.java @@ -120,7 +120,7 @@ public void itEncodesCharSequences() { message.flip(); String value = (String) codec.decodeMessage(message); - assertEquals(value, "hello world"); + assertEquals("hello world", value); } private static class NotEncodable { diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/common/StandardMethodCodecTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/common/StandardMethodCodecTest.java index dfad648be6fbe..65369cb03e73b 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/common/StandardMethodCodecTest.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/common/StandardMethodCodecTest.java @@ -103,12 +103,12 @@ public void encodeErrorEnvelopeWithStacktraceTest() { final Object message = StandardMessageCodec.INSTANCE.readValue(buffer); final Object details = StandardMessageCodec.INSTANCE.readValue(buffer); final Object stacktrace = StandardMessageCodec.INSTANCE.readValue(buffer); - assertEquals("code", (String) code); - assertEquals("foo", (String) message); + assertEquals("code", code); + assertEquals("foo", message); String stack = (String) details; assertTrue( stack.contains( "at io.flutter.plugin.common.StandardMethodCodecTest.encodeErrorEnvelopeWithStacktraceTest(StandardMethodCodecTest.java:")); - assertEquals("error stacktrace", (String) stacktrace); + assertEquals("error stacktrace", stacktrace); } } diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java index 0238d9eeb3cbb..d0d8838174d67 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/editing/InputConnectionAdaptorTest.java @@ -643,53 +643,53 @@ public void testSendKeyEvent_leftKeyMovesCaretLeftComplexEmoji() { // Normal Character didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 74); + assertEquals(74, Selection.getSelectionStart(editable)); // Non-Spacing Mark didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 73); + assertEquals(73, Selection.getSelectionStart(editable)); didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 72); + assertEquals(72, Selection.getSelectionStart(editable)); // Keycap didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 69); + assertEquals(69, Selection.getSelectionStart(editable)); // Keycap with invalid base adaptor.setSelection(68, 68); didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 66); + assertEquals(66, Selection.getSelectionStart(editable)); adaptor.setSelection(67, 67); didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 66); + assertEquals(66, Selection.getSelectionStart(editable)); // Zero Width Joiner didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 55); + assertEquals(55, Selection.getSelectionStart(editable)); // Zero Width Joiner with invalid base didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 53); + assertEquals(53, Selection.getSelectionStart(editable)); didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 52); + assertEquals(52, Selection.getSelectionStart(editable)); didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 51); + assertEquals(51, Selection.getSelectionStart(editable)); // ----- Start Emoji Tag Sequence with invalid base testing ---- // Delete base tag adaptor.setSelection(39, 39); didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 37); + assertEquals(37, Selection.getSelectionStart(editable)); // Delete the sequence adaptor.setSelection(49, 49); @@ -697,80 +697,80 @@ public void testSendKeyEvent_leftKeyMovesCaretLeftComplexEmoji() { didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); } - assertEquals(Selection.getSelectionStart(editable), 37); + assertEquals(37, Selection.getSelectionStart(editable)); // ----- End Emoji Tag Sequence with invalid base testing ---- // Emoji Tag Sequence didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 23); + assertEquals(23, Selection.getSelectionStart(editable)); // Variation Selector with invalid base adaptor.setSelection(22, 22); didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 21); + assertEquals(21, Selection.getSelectionStart(editable)); adaptor.setSelection(22, 22); didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 21); + assertEquals(21, Selection.getSelectionStart(editable)); // Variation Selector didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 19); + assertEquals(19, Selection.getSelectionStart(editable)); // Emoji Modifier didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 16); + assertEquals(16, Selection.getSelectionStart(editable)); // Emoji Modifier with invalid base adaptor.setSelection(14, 14); didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 13); + assertEquals(13, Selection.getSelectionStart(editable)); adaptor.setSelection(14, 14); didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 13); + assertEquals(13, Selection.getSelectionStart(editable)); // Line Feed adaptor.setSelection(12, 12); didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 11); + assertEquals(11, Selection.getSelectionStart(editable)); // Carriage Return adaptor.setSelection(12, 12); didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 11); + assertEquals(11, Selection.getSelectionStart(editable)); // Carriage Return and Line Feed didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 9); + assertEquals(9, Selection.getSelectionStart(editable)); // Regional Indicator Symbol odd didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 7); + assertEquals(7, Selection.getSelectionStart(editable)); // Regional Indicator Symbol even didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 3); + assertEquals(3, Selection.getSelectionStart(editable)); // Simple Emoji didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 1); + assertEquals(1, Selection.getSelectionStart(editable)); // First CodePoint didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 0); + assertEquals(0, Selection.getSelectionStart(editable)); } @Test @@ -834,26 +834,26 @@ public void testSendKeyEvent_rightKeyMovesCaretRightComplexRegion() { // The cursor moves over two region indicators at a time. didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 4); + assertEquals(4, Selection.getSelectionStart(editable)); didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 8); + assertEquals(8, Selection.getSelectionStart(editable)); didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 12); + assertEquals(12, Selection.getSelectionStart(editable)); // When there is only one region indicator left with no pair, the cursor // moves over that single region indicator. didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 14); + assertEquals(14, Selection.getSelectionStart(editable)); // If the cursor is placed in the middle of a region indicator pair, it // moves over only the second half of the pair. adaptor.setSelection(6, 6); didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 8); + assertEquals(8, Selection.getSelectionStart(editable)); } @Test @@ -868,65 +868,65 @@ public void testSendKeyEvent_rightKeyMovesCaretRightComplexEmoji() { // First CodePoint didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 1); + assertEquals(1, Selection.getSelectionStart(editable)); // Simple Emoji didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 3); + assertEquals(3, Selection.getSelectionStart(editable)); // Regional Indicator Symbol even didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 7); + assertEquals(7, Selection.getSelectionStart(editable)); // Regional Indicator Symbol odd didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 9); + assertEquals(9, Selection.getSelectionStart(editable)); // Carriage Return didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 10); + assertEquals(10, Selection.getSelectionStart(editable)); // Line Feed and Carriage Return didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 12); + assertEquals(12, Selection.getSelectionStart(editable)); // Line Feed didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 13); + assertEquals(13, Selection.getSelectionStart(editable)); // Modified Emoji didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 16); + assertEquals(16, Selection.getSelectionStart(editable)); // Emoji Modifier adaptor.setSelection(14, 14); didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 16); + assertEquals(16, Selection.getSelectionStart(editable)); // Emoji Modifier with invalid base adaptor.setSelection(18, 18); didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 19); + assertEquals(19, Selection.getSelectionStart(editable)); // Variation Selector didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 21); + assertEquals(21, Selection.getSelectionStart(editable)); // Variation Selector with invalid base adaptor.setSelection(22, 22); didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 23); + assertEquals(23, Selection.getSelectionStart(editable)); // Emoji Tag Sequence for (int i = 0; i < 7; i++) { @@ -934,7 +934,7 @@ public void testSendKeyEvent_rightKeyMovesCaretRightComplexEmoji() { assertTrue(didConsume); assertEquals(Selection.getSelectionStart(editable), 25 + 2 * i); } - assertEquals(Selection.getSelectionStart(editable), 37); + assertEquals(37, Selection.getSelectionStart(editable)); // ----- Start Emoji Tag Sequence with invalid base testing ---- // Pass the sequence @@ -944,51 +944,51 @@ public void testSendKeyEvent_rightKeyMovesCaretRightComplexEmoji() { assertTrue(didConsume); assertEquals(Selection.getSelectionStart(editable), 41 + 2 * i); } - assertEquals(Selection.getSelectionStart(editable), 51); + assertEquals(51, Selection.getSelectionStart(editable)); // ----- End Emoji Tag Sequence with invalid base testing ---- // Zero Width Joiner with invalid base didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 52); + assertEquals(52, Selection.getSelectionStart(editable)); didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 53); + assertEquals(53, Selection.getSelectionStart(editable)); didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 55); + assertEquals(55, Selection.getSelectionStart(editable)); // Zero Width Joiner didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 66); + assertEquals(66, Selection.getSelectionStart(editable)); // Keycap with invalid base adaptor.setSelection(67, 67); didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 68); + assertEquals(68, Selection.getSelectionStart(editable)); didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 69); + assertEquals(69, Selection.getSelectionStart(editable)); // Keycap didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 72); + assertEquals(72, Selection.getSelectionStart(editable)); // Non-Spacing Mark didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 73); + assertEquals(73, Selection.getSelectionStart(editable)); didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 74); + assertEquals(74, Selection.getSelectionStart(editable)); // Normal Character didConsume = adaptor.handleKeyEvent(downKeyDown); assertTrue(didConsume); - assertEquals(Selection.getSelectionStart(editable), 75); + assertEquals(75, Selection.getSelectionStart(editable)); } @Test @@ -1061,26 +1061,26 @@ public void testSendKeyEvent_MovementKeysAreNopWhenNoSelection() { KeyEvent keyEvent = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_DOWN); boolean didConsume = adaptor.handleKeyEvent(keyEvent); assertFalse(didConsume); - assertEquals(Selection.getSelectionStart(editable), -1); - assertEquals(Selection.getSelectionEnd(editable), -1); + assertEquals(-1, Selection.getSelectionStart(editable)); + assertEquals(-1, Selection.getSelectionEnd(editable)); keyEvent = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_UP); didConsume = adaptor.handleKeyEvent(keyEvent); assertFalse(didConsume); - assertEquals(Selection.getSelectionStart(editable), -1); - assertEquals(Selection.getSelectionEnd(editable), -1); + assertEquals(-1, Selection.getSelectionStart(editable)); + assertEquals(-1, Selection.getSelectionEnd(editable)); keyEvent = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_LEFT); didConsume = adaptor.handleKeyEvent(keyEvent); assertFalse(didConsume); - assertEquals(Selection.getSelectionStart(editable), -1); - assertEquals(Selection.getSelectionEnd(editable), -1); + assertEquals(-1, Selection.getSelectionStart(editable)); + assertEquals(-1, Selection.getSelectionEnd(editable)); keyEvent = new KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DPAD_RIGHT); didConsume = adaptor.handleKeyEvent(keyEvent); assertFalse(didConsume); - assertEquals(Selection.getSelectionStart(editable), -1); - assertEquals(Selection.getSelectionEnd(editable), -1); + assertEquals(-1, Selection.getSelectionStart(editable)); + assertEquals(-1, Selection.getSelectionEnd(editable)); } @Test @@ -1092,9 +1092,9 @@ public void testMethod_getExtractedText() { ExtractedText extractedText = adaptor.getExtractedText(null, 0); - assertEquals(extractedText.text, SAMPLE_TEXT); - assertEquals(extractedText.selectionStart, selStart); - assertEquals(extractedText.selectionEnd, selStart); + assertEquals(SAMPLE_TEXT, extractedText.text); + assertEquals(selStart, extractedText.selectionStart); + assertEquals(selStart, extractedText.selectionEnd); } @Test diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/mouse/MouseCursorPluginTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/mouse/MouseCursorPluginTest.java index 9624c04cde494..767f9f5e2d5b8 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/mouse/MouseCursorPluginTest.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/mouse/MouseCursorPluginTest.java @@ -58,7 +58,7 @@ public void mouseCursorPlugin_SetsSystemCursorOnRequest() throws JSONException { methodResult); verify(testView, times(1)).getSystemPointerIcon(PointerIcon.TYPE_TEXT); verify(testView, times(1)).setPointerIcon(any(PointerIcon.class)); - assertEquals(methodResult.result, Boolean.TRUE); + assertEquals(Boolean.TRUE, methodResult.result); }); } } diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/platform/PlatformPluginTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/platform/PlatformPluginTest.java index 34c5ec9f001d4..ea8e0b6b98e16 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/platform/PlatformPluginTest.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/platform/PlatformPluginTest.java @@ -854,9 +854,9 @@ public void startChoosenActivityWhenSharingText() { // The intent action created by the plugin and passed to Intent.createChooser should be // 'Intent.ACTION_SEND'. Intent sendToIntent = intentCaptor.getValue(); - assertEquals(sendToIntent.getAction(), Intent.ACTION_SEND); - assertEquals(sendToIntent.getType(), "text/plain"); - assertEquals(sendToIntent.getStringExtra(Intent.EXTRA_TEXT), expectedContent); + assertEquals(Intent.ACTION_SEND, sendToIntent.getAction()); + assertEquals("text/plain", sendToIntent.getType()); + assertEquals(expectedContent, sendToIntent.getStringExtra(Intent.EXTRA_TEXT)); } @Config(sdk = API_LEVELS.API_29) diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/platform/PlatformViewWrapperTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/platform/PlatformViewWrapperTest.java index 11c3094f982e7..095e5fcef8cab 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/platform/PlatformViewWrapperTest.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/platform/PlatformViewWrapperTest.java @@ -4,10 +4,8 @@ package io.flutter.plugin.platform; -import static android.view.View.OnFocusChangeListener; import static io.flutter.Build.API_LEVELS; import static org.junit.Assert.*; -import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; import static org.mockito.Mockito.spy; diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/platform/PlatformViewsController2Test.java b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/platform/PlatformViewsController2Test.java index 0b532a37dc261..7b946bce66753 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/platform/PlatformViewsController2Test.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/platform/PlatformViewsController2Test.java @@ -366,7 +366,7 @@ public void createPlatformViewMessage_throwsIfViewIsNull() { // Simulate create call from the framework. createPlatformView(jni, PlatformViewsController2, platformViewId, "testType"); - assertEquals(ShadowFlutterJNI.getResponses().size(), 1); + assertEquals(1, ShadowFlutterJNI.getResponses().size()); assertFalse(PlatformViewsController2.initializePlatformViewIfNeeded(platformViewId)); } @@ -392,7 +392,7 @@ public void createHybridPlatformViewMessage_throwsIfViewIsNull() { // Simulate create call from the framework. createPlatformView(jni, PlatformViewsController2, platformViewId, "testType"); - assertEquals(ShadowFlutterJNI.getResponses().size(), 1); + assertEquals(1, ShadowFlutterJNI.getResponses().size()); assertFalse(PlatformViewsController2.initializePlatformViewIfNeeded(platformViewId)); } @@ -421,7 +421,7 @@ public void setPlatformViewDirection_throwIfPlatformViewNotFound() { // Simulate create call from the framework. createPlatformView(jni, PlatformViewsController2, platformViewId, "testType"); - assertEquals(ShadowFlutterJNI.getResponses().size(), 1); + assertEquals(1, ShadowFlutterJNI.getResponses().size()); // Simulate set direction call from the framework. setLayoutDirection(jni, PlatformViewsController2, platformViewId, 1); @@ -430,7 +430,7 @@ public void setPlatformViewDirection_throwIfPlatformViewNotFound() { // The limit value of reply message will be equal to 2 if the layout direction is set // successfully, otherwise it will be much more than 2 due to the reply message contains // an error message wrapped with exception detail information. - assertEquals(ShadowFlutterJNI.getResponses().get(0).limit(), 2); + assertEquals(2, ShadowFlutterJNI.getResponses().get(0).limit()); } @Test diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/platform/PlatformViewsControllerTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/platform/PlatformViewsControllerTest.java index 528216dea2b2a..bdde95c1baec0 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/platform/PlatformViewsControllerTest.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/platform/PlatformViewsControllerTest.java @@ -256,8 +256,8 @@ public void itCancelsOldPresentationOnResize() { fakeVdController1.resize(10, 10, null); - assertEquals(fakeVdController1.presentation != presentation, true); - assertEquals(presentation.isShowing(), false); + assertNotSame(fakeVdController1.presentation, presentation); + assertFalse(presentation.isShowing()); } @Test @@ -1020,8 +1020,8 @@ public void createPlatformViewMessage_setsAndroidViewSize() { verify(androidView, times(2)).setLayoutParams(layoutParamsCaptor.capture()); List capturedLayoutParams = layoutParamsCaptor.getAllValues(); - assertEquals(capturedLayoutParams.get(0).width, 1); - assertEquals(capturedLayoutParams.get(0).height, 1); + assertEquals(1, capturedLayoutParams.get(0).width); + assertEquals(1, capturedLayoutParams.get(0).height); } @Test @@ -1073,7 +1073,7 @@ public void createPlatformViewMessage_throwsIfViewIsNull() { // Simulate create call from the framework. createPlatformView( jni, platformViewsController, platformViewId, "testType", /* hybrid=*/ false); - assertEquals(ShadowFlutterJNI.getResponses().size(), 1); + assertEquals(1, ShadowFlutterJNI.getResponses().size()); assertFalse(platformViewsController.initializePlatformViewIfNeeded(platformViewId)); } @@ -1098,7 +1098,7 @@ public void createHybridPlatformViewMessage_throwsIfViewIsNull() { // Simulate create call from the framework. createPlatformView(jni, platformViewsController, platformViewId, "testType", /* hybrid=*/ true); - assertEquals(ShadowFlutterJNI.getResponses().size(), 1); + assertEquals(1, ShadowFlutterJNI.getResponses().size()); assertFalse(platformViewsController.initializePlatformViewIfNeeded(platformViewId)); } @@ -1192,7 +1192,7 @@ public void createPlatformViewMessage_throwsIfViewHasParent() { // Simulate create call from the framework. createPlatformView( jni, platformViewsController, platformViewId, "testType", /* hybrid=*/ false); - assertEquals(ShadowFlutterJNI.getResponses().size(), 1); + assertEquals(1, ShadowFlutterJNI.getResponses().size()); assertThrows( IllegalStateException.class, @@ -1222,7 +1222,7 @@ public void createHybridPlatformViewMessage_throwsIfViewHasParent() { // Simulate create call from the framework. createPlatformView(jni, platformViewsController, platformViewId, "testType", /* hybrid=*/ true); - assertEquals(ShadowFlutterJNI.getResponses().size(), 1); + assertEquals(1, ShadowFlutterJNI.getResponses().size()); assertThrows( IllegalStateException.class, @@ -1253,7 +1253,7 @@ public void setPlatformViewDirection_throwIfPlatformViewNotFound() { // Simulate create call from the framework. createPlatformView(jni, platformViewsController, platformViewId, "testType", /* hybrid=*/ true); - assertEquals(ShadowFlutterJNI.getResponses().size(), 1); + assertEquals(1, ShadowFlutterJNI.getResponses().size()); // Simulate set direction call from the framework. setLayoutDirection(jni, platformViewsController, platformViewId, 1); @@ -1262,7 +1262,7 @@ public void setPlatformViewDirection_throwIfPlatformViewNotFound() { // The limit value of reply message will be equal to 2 if the layout direction is set // successfully, otherwise it will be much more than 2 due to the reply message contains // an error message wrapped with exception detail information. - assertEquals(ShadowFlutterJNI.getResponses().get(0).limit(), 2); + assertEquals(2, ShadowFlutterJNI.getResponses().get(0).limit()); } @Test @@ -1299,8 +1299,8 @@ public void resizeAndroidView() { ArgumentCaptor.forClass(FrameLayout.LayoutParams.class); verify(androidView, times(1)).setLayoutParams(layoutParamsCaptor.capture()); - assertEquals(layoutParamsCaptor.getValue().width, 10); - assertEquals(layoutParamsCaptor.getValue().height, 20); + assertEquals(10, layoutParamsCaptor.getValue().width); + assertEquals(20, layoutParamsCaptor.getValue().height); } @Test @@ -1512,7 +1512,7 @@ public void onEndFrame_removesPlatformViewParent() { // Simulate create call from the framework. createPlatformView(jni, platformViewsController, platformViewId, "testType", /* hybrid=*/ true); assertTrue(platformViewsController.initializePlatformViewIfNeeded(platformViewId)); - assertEquals(flutterView.getChildCount(), 2); + assertEquals(2, flutterView.getChildCount()); // Simulate first frame from the framework. jni.onFirstFrame(); @@ -1521,7 +1521,7 @@ public void onEndFrame_removesPlatformViewParent() { // Simulate dispose call from the framework. disposePlatformView(jni, platformViewsController, platformViewId); - assertEquals(flutterView.getChildCount(), 1); + assertEquals(1, flutterView.getChildCount()); } @Test @@ -1740,7 +1740,7 @@ public void convertPlatformViewRenderSurfaceAsDefault() { /* viewHeight=*/ 10, /* mutatorsStack=*/ new FlutterMutatorsStack()); - assertEquals(flutterView.getChildCount(), 3); + assertEquals(3, flutterView.getChildCount()); final View view = flutterView.getChildAt(1); assertTrue(view instanceof FlutterImageView); @@ -1790,8 +1790,8 @@ public void dontConverRenderSurfaceWhenFlagIsTrue() { /* viewHeight=*/ 10, /* mutatorsStack=*/ new FlutterMutatorsStack()); - assertEquals(flutterView.getChildCount(), 2); - assertTrue(!(flutterView.getChildAt(0) instanceof PlatformOverlayView)); + assertEquals(2, flutterView.getChildCount()); + assertFalse(flutterView.getChildAt(0) instanceof PlatformOverlayView); assertTrue(flutterView.getChildAt(1) instanceof FlutterMutatorView); // Simulate dispose call from the framework. @@ -1868,18 +1868,18 @@ public void revertImageViewAndRemoveImageView() { platformViewsController.onDisplayOverlaySurface(platformViewId, 0, 0, 10, 10); // This will contain three views: Background ImageView、PlatformView、Overlay ImageView - assertEquals(flutterView.getChildCount(), 3); + assertEquals(3, flutterView.getChildCount()); FlutterImageView imageView = flutterView.getCurrentImageSurface(); // Make sure the ImageView is inside the current FlutterView. - assertTrue(imageView != null); + assertNotNull(imageView); assertTrue(flutterView.indexOfChild(imageView) != -1); // Make sure the overlayView is inside the current FlutterView assertTrue(platformViewsController.getOverlayLayerViews().size() != 0); PlatformOverlayView overlayView = platformViewsController.getOverlayLayerViews().get(0); - assertTrue(overlayView != null); + assertNotNull(overlayView); assertTrue(flutterView.indexOfChild(overlayView) != -1); // Simulate in a new frame, there's no PlatformView, which is called @@ -1891,13 +1891,13 @@ public void revertImageViewAndRemoveImageView() { // Invoke all registered `FlutterUiDisplayListener` callback jni.onFirstFrame(); - assertEquals(null, flutterView.getCurrentImageSurface()); + assertNull(flutterView.getCurrentImageSurface()); // Make sure the background ImageVIew is not in the FlutterView - assertTrue(flutterView.indexOfChild(imageView) == -1); + assertEquals(-1, flutterView.indexOfChild(imageView)); // Make sure the overlay ImageVIew is not in the FlutterView - assertTrue(flutterView.indexOfChild(overlayView) == -1); + assertEquals(-1, flutterView.indexOfChild(overlayView)); } private static ByteBuffer encodeMethodCall(MethodCall call) { diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/platform/SingleViewPresentationTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/platform/SingleViewPresentationTest.java index dcb53d07a671f..d92631dd0b98b 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/platform/SingleViewPresentationTest.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/platform/SingleViewPresentationTest.java @@ -4,7 +4,6 @@ package io.flutter.plugin.platform; -import static io.flutter.Build.API_LEVELS; import static org.junit.Assert.assertEquals; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.spy; diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/platform/SurfaceTexturePlatformViewRenderTargetTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/platform/SurfaceTexturePlatformViewRenderTargetTest.java index 1921c935b7a8c..5ebda32591386 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/platform/SurfaceTexturePlatformViewRenderTargetTest.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/platform/SurfaceTexturePlatformViewRenderTargetTest.java @@ -5,8 +5,6 @@ package io.flutter.plugin.platform; import static io.flutter.Build.API_LEVELS; -import static org.junit.Assert.*; -import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; import android.annotation.TargetApi; diff --git a/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/text/ProcessTextPluginTest.java b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/text/ProcessTextPluginTest.java index 8891e72cdbfcf..3976f9710891c 100644 --- a/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/text/ProcessTextPluginTest.java +++ b/engine/src/flutter/shell/platform/android/test/io/flutter/plugin/text/ProcessTextPluginTest.java @@ -139,7 +139,7 @@ public void performProcessTextActionWithNoReturnedValue() { ArgumentCaptor intentCaptor = ArgumentCaptor.forClass(Intent.class); verify(mockActivity, times(1)).startActivityForResult(intentCaptor.capture(), anyInt()); Intent intent = intentCaptor.getValue(); - assertEquals(intent.getStringExtra(Intent.EXTRA_PROCESS_TEXT), textToBeProcessed); + assertEquals(textToBeProcessed, intent.getStringExtra(Intent.EXTRA_PROCESS_TEXT)); // Simulate an Android activity answer which does not return a value. Intent resultIntent = new Intent(); @@ -188,7 +188,7 @@ public void performProcessTextActionWithReturnedValue() { ArgumentCaptor intentCaptor = ArgumentCaptor.forClass(Intent.class); verify(mockActivity, times(1)).startActivityForResult(intentCaptor.capture(), anyInt()); Intent intent = intentCaptor.getValue(); - assertEquals(intent.getStringExtra(Intent.EXTRA_PROCESS_TEXT), textToBeProcessed); + assertEquals(textToBeProcessed, intent.getStringExtra(Intent.EXTRA_PROCESS_TEXT)); // Simulate an Android activity answer which returns a transformed text. String processedText = "Flutter!!!"; @@ -239,7 +239,7 @@ public void doNotCrashOnNonRelatedActivityResult() { ArgumentCaptor intentCaptor = ArgumentCaptor.forClass(Intent.class); verify(mockActivity, times(1)).startActivityForResult(intentCaptor.capture(), anyInt()); Intent intent = intentCaptor.getValue(); - assertEquals(intent.getStringExtra(Intent.EXTRA_PROCESS_TEXT), textToBeProcessed); + assertEquals(textToBeProcessed, intent.getStringExtra(Intent.EXTRA_PROCESS_TEXT)); // Result to a request not sent by this plugin should be ignored. final int externalRequestCode = 42; diff --git a/engine/src/flutter/shell/platform/android/test_runner/build.gradle b/engine/src/flutter/shell/platform/android/test_runner/build.gradle index b4f73c0c866db..5edbbb20ed989 100644 --- a/engine/src/flutter/shell/platform/android/test_runner/build.gradle +++ b/engine/src/flutter/shell/platform/android/test_runner/build.gradle @@ -4,7 +4,7 @@ buildscript { mavenCentral() } dependencies { - classpath "com.android.tools.build:gradle:8.9.1" + classpath "com.android.tools.build:gradle:9.1.0" } } diff --git a/engine/src/flutter/shell/platform/darwin/common/BUILD.gn b/engine/src/flutter/shell/platform/darwin/common/BUILD.gn index eca2e28ecfd74..fb3805429953b 100644 --- a/engine/src/flutter/shell/platform/darwin/common/BUILD.gn +++ b/engine/src/flutter/shell/platform/darwin/common/BUILD.gn @@ -244,8 +244,8 @@ executable("framework_common_swift_unittests") { ":swift_testing_config", ] sources = [ - "framework/Source/LoggerTest.swift", - "framework/Source/TracingTest.swift", + "framework/Source/LoggerTests.swift", + "framework/Source/TracingTests.swift", ] deps = [ ":framework_common", diff --git a/engine/src/flutter/shell/platform/darwin/common/framework/Source/LoggerTest.swift b/engine/src/flutter/shell/platform/darwin/common/framework/Source/LoggerTests.swift similarity index 98% rename from engine/src/flutter/shell/platform/darwin/common/framework/Source/LoggerTest.swift rename to engine/src/flutter/shell/platform/darwin/common/framework/Source/LoggerTests.swift index 00ae98d81ff84..9872233df543d 100644 --- a/engine/src/flutter/shell/platform/darwin/common/framework/Source/LoggerTest.swift +++ b/engine/src/flutter/shell/platform/darwin/common/framework/Source/LoggerTests.swift @@ -8,7 +8,7 @@ import Testing import InternalFlutterSwiftCommon import test_utils_swift -@Suite struct LoggerTest { +@Suite struct LoggerTests { @Test func testInitialization() { let writer = StringOutputWriter() diff --git a/engine/src/flutter/shell/platform/darwin/common/framework/Source/TracingTest.swift b/engine/src/flutter/shell/platform/darwin/common/framework/Source/TracingTests.swift similarity index 95% rename from engine/src/flutter/shell/platform/darwin/common/framework/Source/TracingTest.swift rename to engine/src/flutter/shell/platform/darwin/common/framework/Source/TracingTests.swift index 62503120db0a1..7efb53c35bafb 100644 --- a/engine/src/flutter/shell/platform/darwin/common/framework/Source/TracingTest.swift +++ b/engine/src/flutter/shell/platform/darwin/common/framework/Source/TracingTests.swift @@ -6,7 +6,7 @@ import Foundation import InternalFlutterSwiftCommon import Testing -@Suite struct TracingTest { +@Suite struct TracingTests { @Test func testTracePlatformVsyncDoesNotCrash() { Tracing.tracePlatformVsync( @@ -41,7 +41,7 @@ import Testing @Test func testTraceScopeTokenDoesNotCrash() { let scope = Tracing.beginScope("TestScope") - defer { scope.end() } + scope.end() } } diff --git a/engine/src/flutter/shell/platform/darwin/ios/BUILD.gn b/engine/src/flutter/shell/platform/darwin/ios/BUILD.gn index acae883b057d1..52f3054d9d8af 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/BUILD.gn +++ b/engine/src/flutter/shell/platform/darwin/ios/BUILD.gn @@ -255,13 +255,13 @@ if (enable_ios_unittests) { bridge_header = "FlutterTests-Bridging-Header.h" sources = [ "framework/Source/AccessibilityFeaturesTests.swift", - "framework/Source/ConnectionCollectionTest.swift", - "framework/Source/DisplayLinkManagerTest.swift", + "framework/Source/ConnectionCollectionTests.swift", + "framework/Source/DisplayLinkManagerTests.swift", "framework/Source/FakeUIPressProxy.swift", - "framework/Source/LaunchEngineTest.swift", + "framework/Source/LaunchEngineTests.swift", "framework/Source/SplashScreenManagerTests.swift", "framework/Source/TaskRunnerTests.swift", - "framework/Source/VSyncClientTest.swift", + "framework/Source/VSyncClientTests.swift", ] frameworks = [ "Testing.framework" ] deps = [ diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/AccessibilityFeaturesTests.swift b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/AccessibilityFeaturesTests.swift index f564433a98f59..d0ae629702ad7 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/AccessibilityFeaturesTests.swift +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/AccessibilityFeaturesTests.swift @@ -78,8 +78,8 @@ struct AccessibilityFeaturesTests { #expect(AccessibilityFeatureFlag.deterministicCursor.rawValue == 1 << 10) } - @Test @MainActor - func flagsBitmaskIsCorrect() { + @MainActor + @Test func flagsBitmaskIsCorrect() { let features = MockAccessibilityFeatures() #expect(features.flags == 0) diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/ConnectionCollectionTest.swift b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/ConnectionCollectionTests.swift similarity index 97% rename from engine/src/flutter/shell/platform/darwin/ios/framework/Source/ConnectionCollectionTest.swift rename to engine/src/flutter/shell/platform/darwin/ios/framework/Source/ConnectionCollectionTests.swift index cc7984c9bad86..0f400a195b7ee 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/ConnectionCollectionTest.swift +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/ConnectionCollectionTests.swift @@ -5,7 +5,7 @@ import InternalFlutterSwift import Testing -struct ConnectionCollectionTest { +struct ConnectionCollectionTests { @Test func acquireAndRelease() { let connections = ConnectionCollection() let connectionID = connections.acquireConnection(forChannel: "foo") diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/DisplayLinkManager.swift b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/DisplayLinkManager.swift index afafd5e63ea83..15d8e484e35ee 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/DisplayLinkManager.swift +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/DisplayLinkManager.swift @@ -36,7 +36,9 @@ public final class DisplayLinkManager: NSObject, @unchecked Sendable { /// The shared DisplayLinkManager. /// /// The first access performs a one-time read of `UIScreen.main`, and must happen on the main - /// thread; this is enforced by an assertion in `init()`. + /// thread; `@MainActor` isolation enforces this for Swift callers at compile time. Objective-C + /// callers remain responsible for calling from the main thread themselves. + @MainActor @objc public static let shared = DisplayLinkManager() @@ -90,6 +92,7 @@ public final class DisplayLinkManager: NSObject, @unchecked Sendable { /// /// Queries the system plist and main screen properties on the main thread, then starts observing /// for changes that can affect the cached refresh rate. + @MainActor private override init() { assert( Thread.isMainThread, diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/DisplayLinkManagerTest.swift b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/DisplayLinkManagerTests.swift similarity index 55% rename from engine/src/flutter/shell/platform/darwin/ios/framework/Source/DisplayLinkManagerTest.swift rename to engine/src/flutter/shell/platform/darwin/ios/framework/Source/DisplayLinkManagerTests.swift index ad12b260b56df..633a730efcd47 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/DisplayLinkManagerTest.swift +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/DisplayLinkManagerTests.swift @@ -2,59 +2,61 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import XCTest +import Testing @testable import InternalFlutterSwift -class DisplayLinkManagerTest: XCTestCase { +@MainActor +@Suite struct DisplayLinkManagerTests { - func testDisplayLinkManagerCanBeInstantiatedWithMockValues() { + @Test func displayLinkManagerCanBeInstantiatedWithMockValues() { let manager = DisplayLinkManager(maxRefreshRateEnabled: true, refreshRate: 120.0) - XCTAssertTrue(manager.maxRefreshRateEnabledOnIPhone) - XCTAssertEqual(manager.displayRefreshRate, 120.0) + #expect(manager.maxRefreshRateEnabledOnIPhone) + #expect(manager.displayRefreshRate == 120.0) } - func testDisplayLinkManagerCanBeInstantiatedWithAlternateMockValues() { + @Test func displayLinkManagerCanBeInstantiatedWithAlternateMockValues() { let manager = DisplayLinkManager(maxRefreshRateEnabled: false, refreshRate: 60.0) - XCTAssertFalse(manager.maxRefreshRateEnabledOnIPhone) - XCTAssertEqual(manager.displayRefreshRate, 60.0) + #expect(!manager.maxRefreshRateEnabledOnIPhone) + #expect(manager.displayRefreshRate == 60.0) } - func testSharedInstanceReturnsAValidValue() { + @Test func sharedInstanceReturnsAValidValue() { // Verify that the production shared instance does not crash when accessed in test environment. let shared = DisplayLinkManager.shared - XCTAssertNotNil(shared) - XCTAssertGreaterThan(shared.displayRefreshRate, 0.0) + #expect(shared.displayRefreshRate > 0.0) } - func testSettingDisplayRefreshRateIsReflectedByTheGetter() { + @Test func settingDisplayRefreshRateIsReflectedByTheGetter() { let manager = DisplayLinkManager(maxRefreshRateEnabled: true, refreshRate: 60.0) - XCTAssertEqual(manager.displayRefreshRate, 60.0) + #expect(manager.displayRefreshRate == 60.0) manager.displayRefreshRate = 120.0 - XCTAssertEqual(manager.displayRefreshRate, 120.0) + #expect(manager.displayRefreshRate == 120.0) } - func testDisplayConfigurationNotificationsAreHandledWithoutCrashing() { + @Test func displayConfigurationNotificationsAreHandledWithoutCrashing() async { let shared = DisplayLinkManager.shared - let expectation = expectation(description: "Notification handlers ran on the main queue") NotificationCenter.default.post(name: UIScreen.modeDidChangeNotification, object: UIScreen.main) NotificationCenter.default.post(name: .NSProcessInfoPowerStateDidChange, object: nil) NotificationCenter.default.post( name: ProcessInfo.thermalStateDidChangeNotification, object: nil) NotificationCenter.default.post(name: UIApplication.didBecomeActiveNotification, object: nil) - DispatchQueue.main.async { expectation.fulfill() } - wait(for: [expectation], timeout: 1.0) + await withCheckedContinuation { continuation in + DispatchQueue.main.async { + continuation.resume() + } + } // UIScreen.main's reported refresh rate can't be swizzled from a test, so this only // confirms that the notification handlers run to completion without crashing or // deadlocking. The locking/storage behavior they rely on is covered directly by - // testSettingDisplayRefreshRateIsReflectedByTheGetter above. - XCTAssertGreaterThan(shared.displayRefreshRate, 0.0) + // settingDisplayRefreshRateIsReflectedByTheGetter above. + #expect(shared.displayRefreshRate > 0.0) } } diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterDartProject.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterDartProject.mm index 7f5b83ca59892..2ed1a6ec4a3b6 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterDartProject.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterDartProject.mm @@ -67,6 +67,10 @@ static BOOL DoesHardwareSupportWideGamut() { auto settings = flutter::SettingsFromCommandLine(command_line, true); + FML_CHECK(settings.merged_platform_ui_thread != + flutter::Settings::MergedPlatformUIThread::kMergeAfterLaunch) + << "merged-platform-ui-thread=mergeAfterLaunch is not supported on iOS."; + settings.task_observer_add = [](intptr_t key, const fml::closure& callback) { fml::TaskQueueId queue_id = fml::MessageLoop::GetCurrentTaskQueueId(); fml::MessageLoopTaskQueues::GetInstance()->AddTaskObserver(queue_id, key, callback); diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm index 1bf55855a7ff8..b07f3c32c4844 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEngine.mm @@ -196,6 +196,10 @@ @implementation FlutterEngine { std::shared_ptr _threadHost; std::unique_ptr _shell; + // Callers to -waitForFirstFrame:callback: that are currently queued/processing. + // -destroyContext must wait for this group to drain before it is safe to free _shell. + dispatch_group_t _firstFrameWaiters; + flutter::IOSRenderingAPI _renderingApi; std::shared_ptr _profiler; @@ -571,6 +575,13 @@ - (void)notifyViewControllerDeallocated { } - (void)destroyContext { + // Clear any tasks waiting on first frame prior to destroying _shell. + if (_shell) { + _shell->CancelWaitForFirstFrame(); + } + if (_firstFrameWaiters) { + dispatch_group_wait(_firstFrameWaiters, DISPATCH_TIME_FOREVER); + } [self resetChannels]; self.isolateId = nil; _shell.reset(); @@ -1535,17 +1546,28 @@ - (void)waitForFirstFrame:(NSTimeInterval)timeout dispatch_queue_t queue = dispatch_get_global_queue(QOS_CLASS_BACKGROUND, 0); dispatch_group_t group = dispatch_group_create(); + // Increment count of tasks waiting for first frame callback. Decrement below + // on completion or timeout. In -destroyContext we block until all pending + // first frame waiter tasks are cancelled. + if (!_firstFrameWaiters) { + _firstFrameWaiters = dispatch_group_create(); + } + dispatch_group_t firstFrameWaiters = _firstFrameWaiters; + dispatch_group_enter(firstFrameWaiters); + __weak FlutterEngine* weakSelf = self; __block BOOL didTimeout = NO; dispatch_group_async(group, queue, ^{ FlutterEngine* strongSelf = weakSelf; - if (!strongSelf) { + if (!strongSelf || !strongSelf->_shell) { + dispatch_group_leave(firstFrameWaiters); return; } fml::TimeDelta waitTime = fml::TimeDelta::FromMilliseconds(timeout * 1000); fml::Status status = strongSelf.shell.WaitForFirstFrame(waitTime); didTimeout = status.code() == fml::StatusCode::kDeadlineExceeded; + dispatch_group_leave(firstFrameWaiters); }); // Only execute the main queue task once the background task has completely finished executing. diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEnginePlatformViewTest.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEnginePlatformViewTest.mm index 7cc1366bac68b..e4b3a64564ff2 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEnginePlatformViewTest.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterEnginePlatformViewTest.mm @@ -57,6 +57,9 @@ void LoadDartDeferredLibraryError(intptr_t loading_unit_id, bool transient) override {} void UpdateAssetResolverByType(std::unique_ptr updated_asset_resolver, AssetResolver::AssetResolverType type) override {} + std::shared_ptr OnPlatformViewGetShutdownSafeIOTaskRunner() const override { + return nullptr; + } flutter::Settings settings_; }; diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunnerTestHelper.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunnerTestHelper.mm index ba0e8fa8c1dcc..854b566434d60 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunnerTestHelper.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunnerTestHelper.mm @@ -4,10 +4,19 @@ #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunnerTestHelper.h" +#include + #include "flutter/fml/message_loop.h" #include "flutter/fml/thread.h" #import "flutter/shell/platform/darwin/ios/framework/Source/FlutterFMLTaskRunner+FML.h" +// Set the thread to user-interactive QoS to match the real value used in +// the actual embedder. +static void ConfigureThread(const fml::Thread::ThreadConfig& config) { + fml::Thread::SetCurrentThreadName(config); + pthread_set_qos_class_self_np(QOS_CLASS_USER_INTERACTIVE, 0); +} + // A FlutterFMLTaskRunner that owns the fml::Thread it runs on. @interface FlutterFMLThreadTaskRunner : FlutterFMLTaskRunner @end @@ -17,7 +26,8 @@ @implementation FlutterFMLThreadTaskRunner { } - (instancetype)initWithLabel:(NSString*)label { - _thread = std::make_unique(label.UTF8String); + _thread = std::make_unique(ConfigureThread, + fml::Thread::ThreadConfig(label ? label.UTF8String : "")); self = [super initWithTaskRunner:_thread->GetTaskRunner()]; return self; } diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsTest.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsTest.mm index e9f822223f939..1fdcb42de2585 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsTest.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterPlatformViewsTest.mm @@ -310,6 +310,9 @@ void LoadDartDeferredLibraryError(intptr_t loading_unit_id, bool transient) override {} void UpdateAssetResolverByType(std::unique_ptr updated_asset_resolver, flutter::AssetResolver::AssetResolverType type) override {} + std::shared_ptr OnPlatformViewGetShutdownSafeIOTaskRunner() const override { + return nullptr; + } flutter::Settings settings_; }; diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm index 8f00a4a449f5d..907e2ff8a35d2 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/FlutterTextInputPluginTest.mm @@ -125,6 +125,9 @@ void LoadDartDeferredLibraryError(intptr_t loading_unit_id, bool transient) override {} void UpdateAssetResolverByType(std::unique_ptr updated_asset_resolver, flutter::AssetResolver::AssetResolverType type) override {} + std::shared_ptr OnPlatformViewGetShutdownSafeIOTaskRunner() const override { + return nullptr; + } flutter::Settings settings_; }; diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/LaunchEngineTest.swift b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/LaunchEngineTests.swift similarity index 98% rename from engine/src/flutter/shell/platform/darwin/ios/framework/Source/LaunchEngineTest.swift rename to engine/src/flutter/shell/platform/darwin/ios/framework/Source/LaunchEngineTests.swift index 17f5212311f06..73896f1660588 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/LaunchEngineTest.swift +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/LaunchEngineTests.swift @@ -6,7 +6,7 @@ import InternalFlutterSwift import Testing @MainActor -struct LaunchEngineTest { +struct LaunchEngineTests { /// Verifies that the engine is lazily created on first access, cached on subsequent accesses, and /// successfully transferred when taken, leaving the container empty. diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SplashScreenManagerTests.swift b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SplashScreenManagerTests.swift index 3065c6dd8f6a7..e938798b72bcd 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SplashScreenManagerTests.swift +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/SplashScreenManagerTests.swift @@ -3,7 +3,7 @@ // found in the LICENSE file. import UIKit -import XCTest +import Testing @testable import InternalFlutterSwift @@ -31,66 +31,65 @@ final class MockBundle: Bundle, @unchecked Sendable { } } -class SplashScreenManagerTests: XCTestCase { +@MainActor +@Suite struct SplashScreenManagerTests { /// Verifies `loadDefaultSplashScreenView` fails when the `UILaunchStoryboardName` key is missing. - func testLoadDefaultSplashScreenViewFailsWhenNoPlistKey() { + @Test func loadDefaultSplashScreenViewFailsWhenNoPlistKey() { let mockBundle = MockBundle(path: "") let manager = SplashScreenManager(bundle: mockBundle) - XCTAssertFalse(manager.loadDefaultSplashScreenView()) + #expect(!manager.loadDefaultSplashScreenView()) } /// Verifies `loadDefaultSplashScreenView` fails when the storyboard file is not in the bundle. - func testLoadDefaultSplashScreenViewFailsWhenStoryboardNotFound() { + @Test func loadDefaultSplashScreenViewFailsWhenStoryboardNotFound() { let mockBundle = MockBundle(path: "") mockBundle.mockInfoDictionary = ["UILaunchStoryboardName": "LaunchScreen"] let manager = SplashScreenManager(bundle: mockBundle) - XCTAssertFalse(manager.loadDefaultSplashScreenView()) + #expect(!manager.loadDefaultSplashScreenView()) } /// Verifies `setSplashScreenView` sets the view and applies autoresizing masks. - func testSetSplashScreenView() { + @Test func setSplashScreenView() { let manager = SplashScreenManager() let view = UIView() manager.splashScreenView = view - XCTAssertEqual(manager.splashScreenView, view) - XCTAssertEqual(view.autoresizingMask, [.flexibleWidth, .flexibleHeight]) + #expect(manager.splashScreenView == view) + #expect(view.autoresizingMask == [.flexibleWidth, .flexibleHeight]) } /// Verifies setting `splashScreenView` to nil triggers its removal. - func testSetSplashScreenViewToNilRemovesIt() { + @Test func setSplashScreenViewToNilRemovesIt() { let manager = SplashScreenManager() let view = UIView() manager.splashScreenView = view - XCTAssertEqual(manager.splashScreenView, view) + #expect(manager.splashScreenView == view) manager.splashScreenView = nil - XCTAssertNil(manager.splashScreenView) + #expect(manager.splashScreenView == nil) } /// Verifies `removeSplashScreen` calls the completion block after fading out. - func testRemoveSplashScreenCallsCompletion() { + @Test func removeSplashScreenCallsCompletion() async { let manager = SplashScreenManager() let view = UIView() manager.splashScreenView = view - let expectation = self.expectation(description: "Completion called") - - manager.removeSplashScreen { - expectation.fulfill() + await withCheckedContinuation { continuation in + manager.removeSplashScreen { + continuation.resume() + } } - - waitForExpectations(timeout: 1.0, handler: nil) - XCTAssertNil(manager.splashScreenView) + #expect(manager.splashScreenView == nil) } /// Verifies `installSplashScreenView` adds the view to the parent view parent bounds as frame. - func testInstallSplashScreenView() { + @Test func installSplashScreenView() { let manager = SplashScreenManager() let view = UIView() manager.splashScreenView = view @@ -99,7 +98,7 @@ class SplashScreenManagerTests: XCTestCase { manager.installSplashScreenView(asSubviewOf: parentView) - XCTAssertEqual(view.superview, parentView) - XCTAssertEqual(view.frame, parentView.bounds) + #expect(view.superview == parentView) + #expect(view.frame == parentView.bounds) } } diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/TaskRunnerTests.swift b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/TaskRunnerTests.swift index 84882b2799ef0..9eae6d1329809 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/TaskRunnerTests.swift +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/TaskRunnerTests.swift @@ -3,39 +3,45 @@ // found in the LICENSE file. import InternalFlutterSwift -import XCTest +import QuartzCore +import Testing -class TaskRunnerTests: XCTestCase { +// A current-thread `TaskRunner` posts to the current thread's `fml::MessageLoop`, which is backed by +// the thread's run loop. `@MainActor` binds the runner to the main thread, whose run loop the test +// host keeps running; each `await` yields to that run loop so posted tasks execute without manual +// pumping. +@MainActor +struct TaskRunnerTests { - func testPostTask() { + @Test func postTask() async { let taskRunner = TaskRunnerTestHelper.makeCurrentThreadTaskRunner() - let expectation = self.expectation(description: "Task should be executed") - taskRunner.postTask { - expectation.fulfill() + await withCheckedContinuation { continuation in + taskRunner.postTask { + continuation.resume() + } } - - waitForExpectations(timeout: 5.0, handler: nil) } - func testPostDelayedTask() { + @Test func postDelayedTask() async { let taskRunner = TaskRunnerTestHelper.makeCurrentThreadTaskRunner() - let expectation = self.expectation(description: "Delayed task should be executed") + var elapsed: CFTimeInterval = 0 let startTime = CACurrentMediaTime() - taskRunner.postTask(delay: 0.1) { - let endTime = CACurrentMediaTime() - let epsilon = 0.001 - XCTAssertGreaterThanOrEqual(endTime - startTime, 0.1 - epsilon) - expectation.fulfill() + await withCheckedContinuation { continuation in + taskRunner.postTask(delay: 0.1) { + elapsed = CACurrentMediaTime() - startTime + continuation.resume() + } } - waitForExpectations(timeout: 5.0, handler: nil) + let epsilon = 0.001 + #expect(elapsed >= 0.1 - epsilon) } - func testRunsTasksOnCurrentThread() { + @Test func runsTasksOnCurrentThread() { let taskRunner = TaskRunnerTestHelper.makeCurrentThreadTaskRunner() - XCTAssertTrue(taskRunner.runsTasksOnCurrentThread()) + #expect(taskRunner.runsTasksOnCurrentThread()) } } diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/VSyncClientTest.swift b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/VSyncClientTests.swift similarity index 61% rename from engine/src/flutter/shell/platform/darwin/ios/framework/Source/VSyncClientTest.swift rename to engine/src/flutter/shell/platform/darwin/ios/framework/Source/VSyncClientTests.swift index 5935f2feda1c8..ea09d333686bd 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/VSyncClientTest.swift +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/VSyncClientTests.swift @@ -2,22 +2,12 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import XCTest +import Testing @testable import InternalFlutterSwift -class VSyncClientTest: XCTestCase { - var threadTaskRunner: TaskRunner! - - override func setUp() { - super.setUp() - threadTaskRunner = TaskRunnerTestHelper.makeTaskRunner(withLabel: "VSyncClientTest") - } - - override func tearDown() { - threadTaskRunner = nil - super.tearDown() - } +struct VSyncClientTests { + let threadTaskRunner = TaskRunnerTestHelper.makeTaskRunner(withLabel: "VSyncClientTest") /// Verifies that the vsync client safely synthesizes a target timestamp when the display link's /// `targetTimestamp` is invalid (i.e. evaluates to 0.0). @@ -37,7 +27,7 @@ class VSyncClientTest: XCTestCase { /// This test passes a newly created, paused `CADisplayLink` (whose properties both evaluate to /// 0.0) and asserts that the client intercepts the invalid state and synthesizes a safe, positive /// next-frame target timestamp based on the display's maximum refresh rate. - func testRealDisplayLinkVsyncTimestampsCorrect() { + @Test func realDisplayLinkVsyncTimestampsCorrect() throws { var callbackStartTime: CFTimeInterval = -1 var callbackTargetTime: CFTimeInterval = -1 let vsyncClient = VSyncClient( @@ -48,18 +38,18 @@ class VSyncClientTest: XCTestCase { callbackStartTime = startTime callbackTargetTime = targetTime } - let link = vsyncClient.displayLink! + let link = try #require(vsyncClient.displayLink) vsyncClient.onDisplayLink(link) // Since the display link is paused and has not delivered a frame yet, both timestamp and // targetTimestamp are 0.0. Verify the client synthesizes a valid target timestamp using the max // refresh rate. - XCTAssertGreaterThan(callbackStartTime, 0.0) - XCTAssertEqual(callbackTargetTime - callbackStartTime, 1.0 / 60.0, accuracy: 0.0001) + #expect(callbackStartTime > 0.0) + #expect(abs((callbackTargetTime - callbackStartTime) - 1.0 / 60.0) <= 0.0001) } - func testVsyncClientPreventsZeroRefreshRateDivision() { + @Test func vsyncClientPreventsZeroRefreshRateDivision() throws { var callbackStartTime: CFTimeInterval = -1 var callbackTargetTime: CFTimeInterval = -1 // Initialize with maxRefreshRate = 0.0 to simulate uninitialized/zero max refresh rate. @@ -71,18 +61,18 @@ class VSyncClientTest: XCTestCase { callbackStartTime = startTime callbackTargetTime = targetTime } - let link = vsyncClient.displayLink! + let link = try #require(vsyncClient.displayLink) vsyncClient.onDisplayLink(link) - XCTAssertGreaterThan(callbackStartTime, 0.0) + #expect(callbackStartTime > 0.0) // Should fallback to effectiveRefreshRate of 60.0. - XCTAssertEqual(callbackTargetTime - callbackStartTime, 1.0 / 60.0, accuracy: 0.0001) - XCTAssertFalse(callbackTargetTime.isNaN) - XCTAssertFalse(callbackTargetTime.isInfinite) + #expect(abs((callbackTargetTime - callbackStartTime) - 1.0 / 60.0) <= 0.0001) + #expect(callbackTargetTime.isNaN == false) + #expect(callbackTargetTime.isInfinite == false) } - func testRefreshRatePropertyFallsBackToDefaultWhenInvalid() { + @Test func refreshRatePropertyFallsBackToDefaultWhenInvalid() { // Initialize with 0.0 to simulate invalid state. let vsyncClient = VSyncClient( taskRunner: threadTaskRunner, @@ -91,92 +81,90 @@ class VSyncClientTest: XCTestCase { ) { _, _ in } // Should return default rate (60.0). - XCTAssertEqual(vsyncClient.refreshRate, 60.0) + #expect(vsyncClient.refreshRate == 60.0) } - func testSetAllowPauseAfterVsyncCorrect() { + @Test func setAllowPauseAfterVsyncCorrect() throws { let vsyncClient = VSyncClient( taskRunner: threadTaskRunner, isVariableRefreshRateEnabled: false, maxRefreshRate: 60.0 ) { _, _ in } - let link = vsyncClient.displayLink! + let link = try #require(vsyncClient.displayLink) vsyncClient.allowPauseAfterVsync = false vsyncClient.await() vsyncClient.onDisplayLink(link) - XCTAssertFalse(link.isPaused) + #expect(link.isPaused == false) vsyncClient.allowPauseAfterVsync = true vsyncClient.await() vsyncClient.onDisplayLink(link) - XCTAssertTrue(link.isPaused) + #expect(link.isPaused) } - func testSetCorrectVariableRefreshRates() { + @Test func setCorrectVariableRefreshRates() throws { let maxFrameRate: Double = 120.0 let vsyncClient = VSyncClient( taskRunner: threadTaskRunner, isVariableRefreshRateEnabled: true, maxRefreshRate: maxFrameRate ) { _, _ in } - let link = vsyncClient.displayLink! + let link = try #require(vsyncClient.displayLink) if #available(iOS 15.0, *) { - XCTAssertEqual(Double(link.preferredFrameRateRange.maximum), maxFrameRate, accuracy: 0.1) - XCTAssertEqual( - Double(link.preferredFrameRateRange.preferred ?? 0), maxFrameRate, accuracy: 0.1) - XCTAssertEqual(Double(link.preferredFrameRateRange.minimum), maxFrameRate / 2, accuracy: 0.1) + #expect(abs(Double(link.preferredFrameRateRange.maximum) - maxFrameRate) <= 0.1) + #expect(abs(Double(link.preferredFrameRateRange.preferred ?? 0) - maxFrameRate) <= 0.1) + #expect(abs(Double(link.preferredFrameRateRange.minimum) - maxFrameRate / 2) <= 0.1) } else { - XCTAssertEqual(Double(link.preferredFramesPerSecond), maxFrameRate, accuracy: 0.1) + #expect(abs(Double(link.preferredFramesPerSecond) - maxFrameRate) <= 0.1) } } - func testDoNotSetVariableRefreshRatesIfCADisableMinimumFrameDurationOnPhoneIsNotOn() { + @Test func doNotSetVariableRefreshRatesIfCADisableMinimumFrameDurationOnPhoneIsNotOn() throws { let maxFrameRate: Double = 120.0 let vsyncClient = VSyncClient( taskRunner: threadTaskRunner, isVariableRefreshRateEnabled: false, maxRefreshRate: maxFrameRate ) { _, _ in } - let link = vsyncClient.displayLink! + let link = try #require(vsyncClient.displayLink) if #available(iOS 15.0, *) { - XCTAssertEqual(Double(link.preferredFrameRateRange.maximum), 0, accuracy: 0.1) - XCTAssertEqual(Double(link.preferredFrameRateRange.preferred ?? 0), 0, accuracy: 0.1) - XCTAssertEqual(Double(link.preferredFrameRateRange.minimum), 0, accuracy: 0.1) + #expect(abs(Double(link.preferredFrameRateRange.maximum)) <= 0.1) + #expect(abs(Double(link.preferredFrameRateRange.preferred ?? 0)) <= 0.1) + #expect(abs(Double(link.preferredFrameRateRange.minimum)) <= 0.1) } else { - XCTAssertEqual(Double(link.preferredFramesPerSecond), 0, accuracy: 0.1) + #expect(abs(Double(link.preferredFramesPerSecond)) <= 0.1) } } - func testAwaitAndPauseWillWorkCorrectly() { + @Test func awaitAndPauseWillWorkCorrectly() throws { let vsyncClient = VSyncClient( taskRunner: threadTaskRunner, isVariableRefreshRateEnabled: false, maxRefreshRate: 60.0 ) { _, _ in } - let link = vsyncClient.displayLink! + let link = try #require(vsyncClient.displayLink) - XCTAssertTrue(link.isPaused) + #expect(link.isPaused) vsyncClient.await() - XCTAssertFalse(link.isPaused) + #expect(link.isPaused == false) vsyncClient.pause() - XCTAssertTrue(link.isPaused) + #expect(link.isPaused) } - func testReleasesLinkOnInvalidation() { - let threadTaskRunner = TaskRunnerTestHelper.makeTaskRunner(withLabel: "FlutterVSyncClientTest") + @Test func releasesLinkOnInvalidation() { weak var weakClient: VSyncClient? autoreleasepool { - let vsyncExpectation = expectation(description: "vsync") + let vsyncSignal = DispatchSemaphore(value: 0) let client = VSyncClient( taskRunner: threadTaskRunner, isVariableRefreshRateEnabled: false, maxRefreshRate: 60.0 ) { _, _ in - vsyncExpectation.fulfill() + vsyncSignal.signal() } weakClient = client @@ -184,22 +172,21 @@ class VSyncClientTest: XCTestCase { client.await() } - waitForExpectations(timeout: 1.0, handler: nil) + #expect(vsyncSignal.wait(timeout: .now() + 1.0) == .success) client.invalidate() } - let backgroundThreadFlushed = expectation(description: "Background thread flushed") + let backgroundThreadFlushed = DispatchSemaphore(value: 0) threadTaskRunner.postTask { - backgroundThreadFlushed.fulfill() + backgroundThreadFlushed.signal() } - waitForExpectations(timeout: 1.0, handler: nil) - XCTAssertNil(weakClient) + #expect(backgroundThreadFlushed.wait(timeout: .now() + 1.0) == .success) + #expect(weakClient == nil) } - func testDeallocatesWithoutExplicitInvalidation() { - let threadTaskRunner = TaskRunnerTestHelper.makeTaskRunner(withLabel: "VSyncClientTest") + @Test func deallocatesWithoutExplicitInvalidation() { weak var weakClient: VSyncClient? autoreleasepool { @@ -211,15 +198,14 @@ class VSyncClientTest: XCTestCase { weakClient = client } - XCTAssertNil(weakClient) + #expect(weakClient == nil) } /// Verifies there is no retain cycle through the display-link → relay → client chain after /// the display server has taken ownership of the link. On iOS 27+, QuartzCore holds a /// `_CADisplayLinkAssertion` on registered links; a never-unpaused link may therefore /// outlive `VSyncClient` itself, which is expected. - func testDeallocatesAfterRegistrationCompletes() { - let threadTaskRunner = TaskRunnerTestHelper.makeTaskRunner(withLabel: "VSyncClientTest") + @Test func deallocatesAfterRegistrationCompletes() { weak var weakClient: VSyncClient? autoreleasepool { @@ -233,11 +219,11 @@ class VSyncClientTest: XCTestCase { // Registration is dispatched to the task runner in init. Post a barrier task after it // so we know registration has completed before deinit fires. - let registerExpectation = expectation(description: "Wait for display link registration") - threadTaskRunner.postTask { registerExpectation.fulfill() } - waitForExpectations(timeout: 1.0, handler: nil) + let registered = DispatchSemaphore(value: 0) + threadTaskRunner.postTask { registered.signal() } + #expect(registered.wait(timeout: .now() + 1.0) == .success) } - XCTAssertNil(weakClient) + #expect(weakClient == nil) } } diff --git a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/accessibility_bridge_test.mm b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/accessibility_bridge_test.mm index 40be0c6eff884..ff0b5ba433f54 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/framework/Source/accessibility_bridge_test.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/framework/Source/accessibility_bridge_test.mm @@ -113,6 +113,9 @@ void LoadDartDeferredLibraryError(intptr_t loading_unit_id, bool transient) override {} void UpdateAssetResolverByType(std::unique_ptr updated_asset_resolver, flutter::AssetResolver::AssetResolverType type) override {} + std::shared_ptr OnPlatformViewGetShutdownSafeIOTaskRunner() const override { + return nullptr; + } flutter::Settings settings_; }; diff --git a/engine/src/flutter/shell/platform/darwin/ios/platform_view_ios_test.mm b/engine/src/flutter/shell/platform/darwin/ios/platform_view_ios_test.mm index 87cb679e7babb..0852c3055a594 100644 --- a/engine/src/flutter/shell/platform/darwin/ios/platform_view_ios_test.mm +++ b/engine/src/flutter/shell/platform/darwin/ios/platform_view_ios_test.mm @@ -53,6 +53,9 @@ void LoadDartDeferredLibraryError(intptr_t loading_unit_id, bool transient) override {} void UpdateAssetResolverByType(std::unique_ptr updated_asset_resolver, flutter::AssetResolver::AssetResolverType type) override {} + std::shared_ptr OnPlatformViewGetShutdownSafeIOTaskRunner() const override { + return nullptr; + } flutter::Settings settings_; }; diff --git a/engine/src/flutter/shell/platform/darwin/macos/BUILD.gn b/engine/src/flutter/shell/platform/darwin/macos/BUILD.gn index ea069e2674648..68891b19e1bb7 100644 --- a/engine/src/flutter/shell/platform/darwin/macos/BUILD.gn +++ b/engine/src/flutter/shell/platform/darwin/macos/BUILD.gn @@ -201,7 +201,7 @@ executable("flutter_desktop_darwin_swift_unittests") { "//flutter/shell/platform/darwin/common:test_config", "//flutter/shell/platform/darwin/common:swift_testing_config", ] - sources = [ "framework/Source/ResizeSynchronizerTest.swift" ] + sources = [ "framework/Source/ResizeSynchronizerTests.swift" ] deps = [ ":flutter_framework_source", "//flutter/shell/platform/darwin/common:swift_testing_main", diff --git a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterPlatformNodeDelegateMac.h b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterPlatformNodeDelegateMac.h index 62014f670ddf1..8ae3edfd99da0 100644 --- a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterPlatformNodeDelegateMac.h +++ b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterPlatformNodeDelegateMac.h @@ -30,6 +30,9 @@ class FlutterPlatformNodeDelegateMac : public FlutterPlatformNodeDelegate { void NodeDataChanged(const ui::AXNodeData& old_node_data, const ui::AXNodeData& new_node_data) override; + // |ui::AXPlatformNodeDelegateBase| + const ui::AXNodeData& GetData() const override; + //--------------------------------------------------------------------------- /// @brief Gets the live region text of this node in UTF-8 format. This /// is useful to determine the changes in between semantics @@ -55,6 +58,7 @@ class FlutterPlatformNodeDelegateMac : public FlutterPlatformNodeDelegate { ui::AXPlatformNode* ax_platform_node_; std::weak_ptr bridge_; __weak FlutterViewController* view_controller_; + mutable ui::AXNodeData cached_data_; gfx::RectF ConvertBoundsFromLocalToScreen( const gfx::RectF& local_bounds) const; diff --git a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterPlatformNodeDelegateMac.mm b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterPlatformNodeDelegateMac.mm index 5a6c0235d0931..744a0c452a809 100644 --- a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterPlatformNodeDelegateMac.mm +++ b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterPlatformNodeDelegateMac.mm @@ -27,7 +27,7 @@ void FlutterPlatformNodeDelegateMac::Init(std::weak_ptr bridge, ui::AXNode* node) { FlutterPlatformNodeDelegate::Init(bridge, node); - if (GetData().IsTextField()) { + if (GetData().IsTextField() && !GetData().IsReadOnlyOrDisabled()) { ax_platform_node_ = new FlutterTextPlatformNode(this, view_controller_); } else { ax_platform_node_ = ui::AXPlatformNode::Create(this); @@ -37,15 +37,29 @@ void FlutterPlatformNodeDelegateMac::NodeDataChanged(const ui::AXNodeData& old_node_data, const ui::AXNodeData& new_node_data) { - if (old_node_data.IsTextField() && !new_node_data.IsTextField()) { + bool old_is_editable_textfield = + old_node_data.IsTextField() && !old_node_data.IsReadOnlyOrDisabled(); + bool new_is_editable_textfield = + new_node_data.IsTextField() && !new_node_data.IsReadOnlyOrDisabled(); + if (old_is_editable_textfield && !new_is_editable_textfield) { ax_platform_node_->Destroy(); ax_platform_node_ = ui::AXPlatformNode::Create(this); - } else if (!old_node_data.IsTextField() && new_node_data.IsTextField()) { + } else if (!old_is_editable_textfield && new_is_editable_textfield) { ax_platform_node_->Destroy(); ax_platform_node_ = new FlutterTextPlatformNode(this, view_controller_); } } +const ui::AXNodeData& FlutterPlatformNodeDelegateMac::GetData() const { + const ui::AXNodeData& data = FlutterPlatformNodeDelegate::GetData(); + if (data.IsTextField() && data.IsReadOnlyOrDisabled()) { + cached_data_ = data; + cached_data_.role = ax::mojom::Role::kStaticText; + return cached_data_; + } + return data; +} + FlutterPlatformNodeDelegateMac::~FlutterPlatformNodeDelegateMac() { // Destroy() also calls delete on itself. ax_platform_node_->Destroy(); diff --git a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterPlatformNodeDelegateMacTest.mm b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterPlatformNodeDelegateMacTest.mm index 9509c03ccb40a..24c84818044b4 100644 --- a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterPlatformNodeDelegateMacTest.mm +++ b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterPlatformNodeDelegateMacTest.mm @@ -32,6 +32,7 @@ TEST(FlutterPlatformNodeDelegateMac, Basics) { FlutterViewController* viewController = CreateTestViewController(); FlutterEngine* engine = viewController.engine; + [viewController loadView]; engine.semanticsEnabled = YES; auto bridge = viewController.accessibilityBridge.lock(); // Initialize ax node data. @@ -60,9 +61,11 @@ // Verify the accessibility attribute matches. NSAccessibilityElement* native_accessibility = root_platform_node_delegate->GetNativeViewAccessible(); + ASSERT_NE(native_accessibility, nil); std::string value = [native_accessibility.accessibilityValue UTF8String]; EXPECT_TRUE(value == "accessibility"); - EXPECT_EQ(native_accessibility.accessibilityRole, NSAccessibilityStaticTextRole); + EXPECT_TRUE( + [native_accessibility.accessibilityRole isEqualToString:NSAccessibilityStaticTextRole]); EXPECT_EQ([native_accessibility.accessibilityChildren count], 0u); [engine shutDownEngine]; } @@ -70,6 +73,7 @@ TEST(FlutterPlatformNodeDelegateMac, SelectableTextHasCorrectSemantics) { FlutterViewController* viewController = CreateTestViewController(); FlutterEngine* engine = viewController.engine; + [viewController loadView]; engine.semanticsEnabled = YES; auto bridge = viewController.accessibilityBridge.lock(); // Initialize ax node data. @@ -99,9 +103,11 @@ // Verify the accessibility attribute matches. NSAccessibilityElement* native_accessibility = root_platform_node_delegate->GetNativeViewAccessible(); + ASSERT_NE(native_accessibility, nil); std::string value = [native_accessibility.accessibilityValue UTF8String]; EXPECT_EQ(value, "selectable text"); - EXPECT_EQ(native_accessibility.accessibilityRole, NSAccessibilityStaticTextRole); + EXPECT_TRUE( + [native_accessibility.accessibilityRole isEqualToString:NSAccessibilityStaticTextRole]); EXPECT_EQ([native_accessibility.accessibilityChildren count], 0u); NSRange selection = native_accessibility.accessibilitySelectedTextRange; EXPECT_EQ(selection.location, 1u); @@ -113,6 +119,7 @@ TEST(FlutterPlatformNodeDelegateMac, SelectableTextWithoutSelectionReturnZeroRange) { FlutterViewController* viewController = CreateTestViewController(); FlutterEngine* engine = viewController.engine; + [viewController loadView]; engine.semanticsEnabled = YES; auto bridge = viewController.accessibilityBridge.lock(); // Initialize ax node data. @@ -142,6 +149,7 @@ // Verify the accessibility attribute matches. NSAccessibilityElement* native_accessibility = root_platform_node_delegate->GetNativeViewAccessible(); + ASSERT_NE(native_accessibility, nil); NSRange selection = native_accessibility.accessibilitySelectedTextRange; EXPECT_TRUE(selection.location == NSNotFound); EXPECT_EQ(selection.length, 0u); diff --git a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/ResizeSynchronizerTest.swift b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/ResizeSynchronizerTests.swift similarity index 99% rename from engine/src/flutter/shell/platform/darwin/macos/framework/Source/ResizeSynchronizerTest.swift rename to engine/src/flutter/shell/platform/darwin/macos/framework/Source/ResizeSynchronizerTests.swift index 04cec96b24920..47fdfa9d31fa8 100644 --- a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/ResizeSynchronizerTest.swift +++ b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/ResizeSynchronizerTests.swift @@ -11,7 +11,7 @@ import Testing // in effect, a source of implicit shared state/behaviour. Because `beginResize` is a blocking call // performed on the main thread, we serialise to avoid potential interactions between tests. @Suite("ResizeSynchronizer tests", .serialized) -struct ResizeSynchronizerTest { +struct ResizeSynchronizerTests { @MainActor @Test("performCommit callback executes when no resize is active") diff --git a/engine/src/flutter/shell/platform/embedder/embedder.cc b/engine/src/flutter/shell/platform/embedder/embedder.cc index 075232e231a77..29d75fbf2d81c 100644 --- a/engine/src/flutter/shell/platform/embedder/embedder.cc +++ b/engine/src/flutter/shell/platform/embedder/embedder.cc @@ -495,6 +495,7 @@ InferOpenGLPlatformViewCreationCallback( shell.GetTaskRunners(), // task runners std::make_unique( gl_dispatch_table, fbo_reset_after_present, view_embedder, + shell.GetShutdownSafeIOTaskRunner(), impeller_flags), // embedder_surface platform_dispatch_table, // embedder platform dispatch table view_embedder // external view embedder diff --git a/engine/src/flutter/shell/platform/embedder/embedder_surface.cc b/engine/src/flutter/shell/platform/embedder/embedder_surface.cc index 029833f7434ba..8e7eef64491d5 100644 --- a/engine/src/flutter/shell/platform/embedder/embedder_surface.cc +++ b/engine/src/flutter/shell/platform/embedder/embedder_surface.cc @@ -19,4 +19,6 @@ sk_sp EmbedderSurface::CreateResourceContext() const { return nullptr; } +void EmbedderSurface::ReleaseResourceContext() const {} + } // namespace flutter diff --git a/engine/src/flutter/shell/platform/embedder/embedder_surface.h b/engine/src/flutter/shell/platform/embedder/embedder_surface.h index 32873f1a944a5..b00433e356e93 100644 --- a/engine/src/flutter/shell/platform/embedder/embedder_surface.h +++ b/engine/src/flutter/shell/platform/embedder/embedder_surface.h @@ -26,6 +26,12 @@ class EmbedderSurface { virtual sk_sp CreateResourceContext() const; + /// Release any platform specific resources associated with the graphics + /// context created by `CreateResourceContext`. + /// + /// @see `PlatformView::ReleaseResourceContext` + virtual void ReleaseResourceContext() const; + private: FML_DISALLOW_COPY_AND_ASSIGN(EmbedderSurface); }; diff --git a/engine/src/flutter/shell/platform/embedder/embedder_surface_gl_impeller.cc b/engine/src/flutter/shell/platform/embedder/embedder_surface_gl_impeller.cc index 1aad505136eb0..bb4cc4005c588 100644 --- a/engine/src/flutter/shell/platform/embedder/embedder_surface_gl_impeller.cc +++ b/engine/src/flutter/shell/platform/embedder/embedder_surface_gl_impeller.cc @@ -78,6 +78,7 @@ EmbedderSurfaceGLImpeller::EmbedderSurfaceGLImpeller( EmbedderSurfaceGLSkia::GLDispatchTable gl_dispatch_table, bool fbo_reset_after_present, std::shared_ptr external_view_embedder, + std::shared_ptr io_task_runner, impeller::Flags impeller_flags) : gl_dispatch_table_(std::move(gl_dispatch_table)), fbo_reset_after_present_(fbo_reset_after_present), @@ -108,7 +109,7 @@ EmbedderSurfaceGLImpeller::EmbedderSurfaceGLImpeller( impeller_context_ = impeller::ContextGLES::Create( impeller_flags, std::move(gl), shader_mappings, - /*enable_gpu_tracing=*/false); + /*enable_gpu_tracing=*/false, std::move(io_task_runner)); if (!impeller_context_) { FML_LOG(ERROR) << "Could not create Impeller context."; @@ -230,4 +231,10 @@ sk_sp EmbedderSurfaceGLImpeller::CreateResourceContext() return nullptr; } +// |EmbedderSurface| +void EmbedderSurfaceGLImpeller::ReleaseResourceContext() const { + worker_->SetReactionsAllowedOnCurrentThread(false); + gl_dispatch_table_.gl_clear_current_callback(); +} + } // namespace flutter diff --git a/engine/src/flutter/shell/platform/embedder/embedder_surface_gl_impeller.h b/engine/src/flutter/shell/platform/embedder/embedder_surface_gl_impeller.h index 598925f44b483..2d420af3dbaae 100644 --- a/engine/src/flutter/shell/platform/embedder/embedder_surface_gl_impeller.h +++ b/engine/src/flutter/shell/platform/embedder/embedder_surface_gl_impeller.h @@ -33,6 +33,7 @@ class EmbedderSurfaceGLImpeller final : public EmbedderSurface, EmbedderSurfaceGLSkia::GLDispatchTable gl_dispatch_table, bool fbo_reset_after_present, std::shared_ptr external_view_embedder, + std::shared_ptr io_task_runner, impeller::Flags impeller_flags = {}); ~EmbedderSurfaceGLImpeller() override; @@ -85,6 +86,9 @@ class EmbedderSurfaceGLImpeller final : public EmbedderSurface, // |EmbedderSurface| sk_sp CreateResourceContext() const override; + // |EmbedderSurface| + void ReleaseResourceContext() const override; + FML_DISALLOW_COPY_AND_ASSIGN(EmbedderSurfaceGLImpeller); }; diff --git a/engine/src/flutter/shell/platform/embedder/platform_view_embedder.cc b/engine/src/flutter/shell/platform/embedder/platform_view_embedder.cc index 712a5287e3924..04f587cdd1ba8 100644 --- a/engine/src/flutter/shell/platform/embedder/platform_view_embedder.cc +++ b/engine/src/flutter/shell/platform/embedder/platform_view_embedder.cc @@ -168,6 +168,15 @@ sk_sp PlatformViewEmbedder::CreateResourceContext() const { return embedder_surface_->CreateResourceContext(); } +// |PlatformView| +void PlatformViewEmbedder::ReleaseResourceContext() const { + if (embedder_surface_ == nullptr) { + FML_LOG(ERROR) << "Embedder surface was null."; + return; + } + embedder_surface_->ReleaseResourceContext(); +} + // |PlatformView| std::unique_ptr PlatformViewEmbedder::CreateVSyncWaiter() { if (!platform_dispatch_table_.vsync_callback) { diff --git a/engine/src/flutter/shell/platform/embedder/platform_view_embedder.h b/engine/src/flutter/shell/platform/embedder/platform_view_embedder.h index 1b9f500140e66..6f0bc976f6765 100644 --- a/engine/src/flutter/shell/platform/embedder/platform_view_embedder.h +++ b/engine/src/flutter/shell/platform/embedder/platform_view_embedder.h @@ -135,6 +135,9 @@ class PlatformViewEmbedder final : public PlatformView { // |PlatformView| sk_sp CreateResourceContext() const override; + // |PlatformView| + void ReleaseResourceContext() const override; + // |PlatformView| std::unique_ptr CreateVSyncWaiter() override; diff --git a/engine/src/flutter/shell/platform/embedder/platform_view_embedder_unittests.cc b/engine/src/flutter/shell/platform/embedder/platform_view_embedder_unittests.cc index d82265f4353ab..bf6e1b84cc2a2 100644 --- a/engine/src/flutter/shell/platform/embedder/platform_view_embedder_unittests.cc +++ b/engine/src/flutter/shell/platform/embedder/platform_view_embedder_unittests.cc @@ -104,6 +104,10 @@ class MockDelegate : public PlatformView::Delegate { OnPlatformViewGetSettings, (), (const, override)); + MOCK_METHOD(std::shared_ptr, + OnPlatformViewGetShutdownSafeIOTaskRunner, + (), + (const, override)); }; class MockResponse : public PlatformMessageResponse { diff --git a/engine/src/flutter/shell/platform/embedder/tests/embedder_test_surface_gl_impeller.cc b/engine/src/flutter/shell/platform/embedder/tests/embedder_test_surface_gl_impeller.cc index db76a1c082cec..59296dee3df29 100644 --- a/engine/src/flutter/shell/platform/embedder/tests/embedder_test_surface_gl_impeller.cc +++ b/engine/src/flutter/shell/platform/embedder/tests/embedder_test_surface_gl_impeller.cc @@ -39,7 +39,8 @@ TEST(EmbedderSurfaceGLImpellerTest, GLES3ContextHasGLES3Shaders) { StubDispatchTable(/* version */ "OpenGL ES 3.0"); const auto surface = EmbedderSurfaceGLImpeller( gl_dispatch_table, /* fbo_reset_after_present */ false, - /* external_view_embedder */ nullptr); + /* external_view_embedder */ nullptr, + /* io_task_runner */ nullptr); const std::shared_ptr context = surface.CreateImpellerContext(); @@ -61,7 +62,8 @@ TEST(EmbedderSurfaceGLImpellerTest, GLES2ContextDoesNotHaveGLES3Shaders) { StubDispatchTable(/* version */ "OpenGL ES 2.0"); const auto surface = EmbedderSurfaceGLImpeller( gl_dispatch_table, /* fbo_reset_after_present */ false, - /* external_view_embedder */ nullptr); + /* external_view_embedder */ nullptr, + /* io_task_runner */ nullptr); const std::shared_ptr context = surface.CreateImpellerContext(); diff --git a/engine/src/flutter/shell/platform/fuchsia/flutter/tests/platform_view_unittest.cc b/engine/src/flutter/shell/platform/fuchsia/flutter/tests/platform_view_unittest.cc index 9a7f8fa44ffbf..d6e5ecd73b6ad 100644 --- a/engine/src/flutter/shell/platform/fuchsia/flutter/tests/platform_view_unittest.cc +++ b/engine/src/flutter/shell/platform/fuchsia/flutter/tests/platform_view_unittest.cc @@ -107,6 +107,11 @@ class MockPlatformViewDelegate : public flutter::PlatformView::Delegate { return settings_; } // |flutter::PlatformView::Delegate| + std::shared_ptr + OnPlatformViewGetShutdownSafeIOTaskRunner() const { + return nullptr; + } + // |flutter::PlatformView::Delegate| void OnPlatformViewDispatchPlatformMessage( std::unique_ptr message) { message_ = std::move(message); diff --git a/engine/src/flutter/shell/platform/windows/flutter_window.cc b/engine/src/flutter/shell/platform/windows/flutter_window.cc index 2b778ddba021e..c7d11e2ab8580 100644 --- a/engine/src/flutter/shell/platform/windows/flutter_window.cc +++ b/engine/src/flutter/shell/platform/windows/flutter_window.cc @@ -692,9 +692,22 @@ FlutterWindow::HandleMessage(UINT const message, if (wparam & MK_SHIFT) { mods |= kShift; } + + // Mouse move with mouse buttons pressed but current HWND does not have + // capture. This can happen when switching windows mid drag - windows + // will stop honoring current capture and starts sending WM_MOUSEMOVE + // to the HWND below cursor, but without sending a WM_(LRMX)BUTTONDOWN + // first. This would confuse pointer tracking in Flutter so it is better + // to ignore these events. This also matches behavior of other + // applications where drag like this is ignored despite the mouse + // capture being lost. https://github.com/flutter/flutter/issues/189583 + auto buttons = ConvertWinMouseStateToFlutterButtons(wparam); + if (buttons != 0 && GetCapture() != window_handle_) { + break; + } + OnPointerMove(mouse_x_, mouse_y_, device_kind, kDefaultPointerDeviceId, - ConvertWinMouseStateToFlutterButtons(wparam), - /*rotation=*/0, /*pressure=*/0, mods); + buttons, /*rotation=*/0, /*pressure=*/0, mods); } break; case WM_MOUSELEAVE: @@ -735,14 +748,11 @@ FlutterWindow::HandleMessage(UINT const message, break; } - if (message == WM_LBUTTONDOWN) { - // Capture the pointer in case the user drags outside the client area. - // In this case, the "mouse leave" event is delayed until the user - // releases the button. It's only activated on left click given that - // it's more common for apps to handle dragging with only the left - // button. - SetCapture(window_handle_); - } + // Capture the pointer in case the user drags outside the client area. + // In this case, the "mouse leave" event is delayed until the user + // releases the button. + SetCapture(window_handle_); + button_pressed = message; if (message == WM_XBUTTONDOWN) { button_pressed = GET_XBUTTON_WPARAM(wparam); @@ -764,9 +774,6 @@ FlutterWindow::HandleMessage(UINT const message, break; } - if (message == WM_LBUTTONUP) { - ReleaseCapture(); - } button_pressed = message; if (message == WM_XBUTTONUP) { button_pressed = GET_XBUTTON_WPARAM(wparam); @@ -775,6 +782,17 @@ FlutterWindow::HandleMessage(UINT const message, y_pos = GET_Y_LPARAM(lparam); flutter_button = ConvertWinButtonToFlutterButton(button_pressed); + // WM_*BUTTONUP messages use wparam to report which buttons remain + // pressed after this event; release capture only after the last mouse + // button is released. WM_*BUTTONDOWN messages already identify the newly + // pressed button via the message itself. + // See: + // https://learn.microsoft.com/en-us/windows/win32/inputdev/wm-lbuttonup + if ((wparam & (MK_LBUTTON | MK_RBUTTON | MK_MBUTTON | MK_XBUTTON1 | + MK_XBUTTON2)) == 0) { + ReleaseCapture(); + } + OnPointerUp(static_cast(x_pos), static_cast(y_pos), device_kind, kDefaultPointerDeviceId, flutter_button); break; diff --git a/engine/src/flutter/shell/platform/windows/flutter_window_unittests.cc b/engine/src/flutter/shell/platform/windows/flutter_window_unittests.cc index e1fbcfe900763..741e47f4537c9 100644 --- a/engine/src/flutter/shell/platform/windows/flutter_window_unittests.cc +++ b/engine/src/flutter/shell/platform/windows/flutter_window_unittests.cc @@ -853,6 +853,116 @@ TEST_F(FlutterWindowTest, OnMousePointerDown) { 50); } +TEST_F(FlutterWindowTest, NonPrimaryMouseButtonCapturesPointer) { + MockFlutterWindow win32window(100, 100); + MockWindowBindingHandlerDelegate delegate; + EXPECT_CALL(win32window, OnWindowStateEvent).Times(AnyNumber()); + EXPECT_CALL(delegate, OnWindowStateEvent).Times(AnyNumber()); + win32window.SetView(&delegate); + + HWND window_handle = win32window.FlutterWindow::GetWindowHandle(); + ASSERT_NE(window_handle, nullptr); + ReleaseCapture(); + ASSERT_EQ(GetCapture(), nullptr); + + EXPECT_CALL(delegate, + OnPointerDown(10.0, 10.0, kFlutterPointerDeviceKindMouse, + kDefaultPointerDeviceId, + kFlutterPointerButtonMouseSecondary, 0, 0)) + .Times(1); + win32window.InjectWindowMessage(WM_RBUTTONDOWN, MK_RBUTTON, + MAKELPARAM(10, 10)); + EXPECT_EQ(GetCapture(), window_handle); + + EXPECT_CALL(delegate, OnPointerUp(10.0, 10.0, kFlutterPointerDeviceKindMouse, + kDefaultPointerDeviceId, + kFlutterPointerButtonMouseSecondary)) + .Times(1); + win32window.InjectWindowMessage(WM_RBUTTONUP, 0, MAKELPARAM(10, 10)); + EXPECT_EQ(GetCapture(), nullptr); +} + +TEST_F(FlutterWindowTest, MouseButtonCaptureReleasedAfterAllButtonsReleased) { + MockFlutterWindow win32window(100, 100); + MockWindowBindingHandlerDelegate delegate; + EXPECT_CALL(win32window, OnWindowStateEvent).Times(AnyNumber()); + EXPECT_CALL(delegate, OnWindowStateEvent).Times(AnyNumber()); + win32window.SetView(&delegate); + + HWND window_handle = win32window.FlutterWindow::GetWindowHandle(); + ASSERT_NE(window_handle, nullptr); + ReleaseCapture(); + ASSERT_EQ(GetCapture(), nullptr); + + EXPECT_CALL(delegate, + OnPointerDown(10.0, 10.0, kFlutterPointerDeviceKindMouse, + kDefaultPointerDeviceId, + kFlutterPointerButtonMouseSecondary, 0, 0)) + .Times(1); + win32window.InjectWindowMessage(WM_RBUTTONDOWN, MK_RBUTTON, + MAKELPARAM(10, 10)); + EXPECT_EQ(GetCapture(), window_handle); + + EXPECT_CALL(delegate, + OnPointerDown(10.0, 10.0, kFlutterPointerDeviceKindMouse, + kDefaultPointerDeviceId, + kFlutterPointerButtonMousePrimary, 0, 0)) + .Times(1); + win32window.InjectWindowMessage(WM_LBUTTONDOWN, MK_LBUTTON | MK_RBUTTON, + MAKELPARAM(10, 10)); + EXPECT_EQ(GetCapture(), window_handle); + + EXPECT_CALL(delegate, OnPointerUp(10.0, 10.0, kFlutterPointerDeviceKindMouse, + kDefaultPointerDeviceId, + kFlutterPointerButtonMouseSecondary)) + .Times(1); + win32window.InjectWindowMessage(WM_RBUTTONUP, MK_LBUTTON, MAKELPARAM(10, 10)); + EXPECT_EQ(GetCapture(), window_handle); + + EXPECT_CALL(delegate, OnPointerUp(10.0, 10.0, kFlutterPointerDeviceKindMouse, + kDefaultPointerDeviceId, + kFlutterPointerButtonMousePrimary)) + .Times(1); + win32window.InjectWindowMessage(WM_LBUTTONUP, 0, MAKELPARAM(10, 10)); + EXPECT_EQ(GetCapture(), nullptr); +} + +TEST_F(FlutterWindowTest, MouseMoveWithButtonIgnoredWithoutCapture) { + MockFlutterWindow win32window(100, 100); + MockWindowBindingHandlerDelegate delegate; + EXPECT_CALL(win32window, OnWindowStateEvent).Times(AnyNumber()); + EXPECT_CALL(delegate, OnWindowStateEvent).Times(AnyNumber()); + win32window.SetView(&delegate); + + ReleaseCapture(); + ASSERT_EQ(GetCapture(), nullptr); + + EXPECT_CALL(delegate, OnPointerMove).Times(0); + win32window.InjectWindowMessage(WM_MOUSEMOVE, MK_LBUTTON, MAKELPARAM(10, 10)); +} + +TEST_F(FlutterWindowTest, MouseMoveWithButtonDispatchedWithCapture) { + MockFlutterWindow win32window(100, 100); + MockWindowBindingHandlerDelegate delegate; + EXPECT_CALL(win32window, OnWindowStateEvent).Times(AnyNumber()); + EXPECT_CALL(delegate, OnWindowStateEvent).Times(AnyNumber()); + win32window.SetView(&delegate); + + HWND window_handle = win32window.FlutterWindow::GetWindowHandle(); + ASSERT_NE(window_handle, nullptr); + SetCapture(window_handle); + ASSERT_EQ(GetCapture(), window_handle); + + EXPECT_CALL(delegate, + OnPointerMove(10.0, 10.0, kFlutterPointerDeviceKindMouse, + kDefaultPointerDeviceId, + kFlutterPointerButtonMousePrimary, 0, 0, 0)) + .Times(1); + win32window.InjectWindowMessage(WM_MOUSEMOVE, MK_LBUTTON, MAKELPARAM(10, 10)); + + ReleaseCapture(); +} + TEST_F(FlutterWindowTest, OnTouchPointerDown) { auto mock_proc_table = std::make_shared(); diff --git a/engine/src/flutter/shell/platform/windows/platform_handler.cc b/engine/src/flutter/shell/platform/windows/platform_handler.cc index aa05bf3750be9..b817600ea654d 100644 --- a/engine/src/flutter/shell/platform/windows/platform_handler.cc +++ b/engine/src/flutter/shell/platform/windows/platform_handler.cc @@ -341,7 +341,7 @@ void PlatformHandler::GetHasStrings( } void PlatformHandler::SetPlainText( - const std::string& text, + std::string_view text, std::unique_ptr> result) { std::unique_ptr clipboard = scoped_clipboard_provider_(); @@ -353,7 +353,11 @@ void PlatformHandler::SetPlainText( result->Error(kClipboardError, "Unable to open clipboard", error_code); return; } - int set_result = clipboard->SetString(fml::Utf8ToWideString(text)); + std::wstring clipboard_text = fml::Utf8ToWideString(text); + // Windows clipboard strings are null-terminated, so an embedded null + // character causes other applications to paste only the text before it. + std::replace(clipboard_text.begin(), clipboard_text.end(), L'\0', L'\uFFFD'); + int set_result = clipboard->SetString(clipboard_text); if (set_result != kErrorSuccess) { rapidjson::Document error_code; error_code.SetInt(set_result); @@ -504,7 +508,9 @@ void PlatformHandler::HandleMethodCall( result->Error(kClipboardError, kUnknownClipboardFormatMessage); return; } - SetPlainText(itr->value.GetString(), std::move(result)); + SetPlainText( + std::string_view(itr->value.GetString(), itr->value.GetStringLength()), + std::move(result)); } else if (method.compare(kPlaySoundMethod) == 0) { // Only one string argument is expected. const rapidjson::Value& sound_type = method_call.arguments()[0]; diff --git a/engine/src/flutter/shell/platform/windows/platform_handler.h b/engine/src/flutter/shell/platform/windows/platform_handler.h index cab985cb61cc1..08763633dc03e 100644 --- a/engine/src/flutter/shell/platform/windows/platform_handler.h +++ b/engine/src/flutter/shell/platform/windows/platform_handler.h @@ -69,7 +69,7 @@ class PlatformHandler { // Sets the clipboard's plain text to |text|, and reports the result (either // an error, or null for success) to |result|. virtual void SetPlainText( - const std::string& text, + std::string_view text, std::unique_ptr> result); virtual void SystemSoundPlay( diff --git a/engine/src/flutter/shell/platform/windows/platform_handler_unittests.cc b/engine/src/flutter/shell/platform/windows/platform_handler_unittests.cc index 878245556a299..ab438f5a440cd 100644 --- a/engine/src/flutter/shell/platform/windows/platform_handler_unittests.cc +++ b/engine/src/flutter/shell/platform/windows/platform_handler_unittests.cc @@ -38,6 +38,9 @@ static constexpr char kClipboardHasStringsFakeContentTypeMessage[] = "{\"method\":\"Clipboard.hasStrings\",\"args\":\"text/madeupcontenttype\"}"; static constexpr char kClipboardSetDataMessage[] = "{\"method\":\"Clipboard.setData\",\"args\":{\"text\":\"hello\"}}"; +static constexpr char kClipboardSetDataNullTerminatorMessage[] = + "{\"method\":\"Clipboard.setData\",\"args\":{\"text\":" + "\"hello\\u0000world\"}}"; static constexpr char kClipboardSetDataNullTextMessage[] = "{\"method\":\"Clipboard.setData\",\"args\":{\"text\":null}}"; static constexpr char kClipboardSetDataUnknownTypeMessage[] = @@ -82,7 +85,7 @@ class MockPlatformHandler : public PlatformHandler { (override)); MOCK_METHOD(void, SetPlainText, - (const std::string&, + (std::string_view, std::unique_ptr>), (override)); MOCK_METHOD(void, @@ -358,6 +361,33 @@ TEST_F(PlatformHandlerTest, ClipboardSetData) { EXPECT_EQ(result, "[null]"); } +// Regression test for https://github.com/flutter/flutter/issues/162226. +TEST_F(PlatformHandlerTest, ClipboardSetDataReplacesNullTerminators) { + UseHeadlessEngine(); + + TestBinaryMessenger messenger; + PlatformHandler platform_handler(&messenger, engine(), []() { + auto clipboard = std::make_unique(); + + EXPECT_CALL(*clipboard.get(), Open) + .Times(1) + .WillOnce(Return(kErrorSuccess)); + EXPECT_CALL(*clipboard.get(), SetString) + .Times(1) + .WillOnce([](std::wstring string) { + EXPECT_EQ(string, L"hello\uFFFDworld"); + return kErrorSuccess; + }); + + return clipboard; + }); + + std::string result = SimulatePlatformMessage( + &messenger, kClipboardSetDataNullTerminatorMessage); + + EXPECT_EQ(result, "[null]"); +} + // Regression test for: https://github.com/flutter/flutter/issues/121976 TEST_F(PlatformHandlerTest, ClipboardSetDataTextMustBeString) { UseHeadlessEngine(); diff --git a/engine/src/flutter/sky/packages/sky_engine/LICENSE b/engine/src/flutter/sky/packages/sky_engine/LICENSE index 2bbf8078864c0..88f05bfe30c20 100644 --- a/engine/src/flutter/sky/packages/sky_engine/LICENSE +++ b/engine/src/flutter/sky/packages/sky_engine/LICENSE @@ -18007,6 +18007,13 @@ found in the LICENSE file. -------------------------------------------------------------------------------- skia +Copyright 2025 Google LLC. + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- +skia + Copyright 2025 Google, LLC Use of this source code is governed by a BSD-style license that can be @@ -18103,6 +18110,13 @@ THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- +skia + +Copyright 2026 Apple Inc. + +Use of this source code is governed by a BSD-style license that can be +found in the LICENSE file. +-------------------------------------------------------------------------------- glslang Copyright 2026 Google LLC @@ -18193,13 +18207,6 @@ Copyright 2026 The ANGLE project authors. All Rights Reserved. Use of this source code is governed by a BSD-style license that can be found in the LICENSE file -------------------------------------------------------------------------------- -skia - -Copyright 2026 The Android Open Source Project - -Use of this source code is governed by a BSD-style license that can be -found in the LICENSE file. --------------------------------------------------------------------------------- angle Copyright The ANGLE Project Authors. All rights reserved. diff --git a/engine/src/flutter/testing/dart/BUILD.gn b/engine/src/flutter/testing/dart/BUILD.gn index 885d0ea2e55fa..71b41edf596a3 100644 --- a/engine/src/flutter/testing/dart/BUILD.gn +++ b/engine/src/flutter/testing/dart/BUILD.gn @@ -48,7 +48,6 @@ tests = [ "plugin_utilities_test.dart", "process_test.dart", "semantics_test.dart", - "serial_gc_test.dart", "spawn_helper.dart", "spawn_test.dart", "stringification_test.dart", diff --git a/engine/src/flutter/testing/dart/fragment_shader_test.dart b/engine/src/flutter/testing/dart/fragment_shader_test.dart index e130b65ac7d31..79edd1b85e555 100644 --- a/engine/src/flutter/testing/dart/fragment_shader_test.dart +++ b/engine/src/flutter/testing/dart/fragment_shader_test.dart @@ -1630,6 +1630,11 @@ void main() async { expect(filter, filter_2); expect(identical(filter, filter_2), false); + final filterLowQuality = ImageFilter.shader(shader, filterQuality: FilterQuality.low); + expect(filter, isNot(filterLowQuality)); + expect(filterLowQuality, ImageFilter.shader(shader, filterQuality: FilterQuality.low)); + expect(identical(filter, filterLowQuality), false); + shader.setFloat(0, 1); final filter_3 = ImageFilter.shader(shader); diff --git a/engine/src/flutter/testing/dart/gpu_test.dart b/engine/src/flutter/testing/dart/gpu_test.dart index 652c337d11897..b31d0ce8c4687 100644 --- a/engine/src/flutter/testing/dart/gpu_test.dart +++ b/engine/src/flutter/testing/dart/gpu_test.dart @@ -1410,6 +1410,34 @@ void main() async { } }, skip: !(impellerEnabled && flutterGpuEnabled)); + test('Shader.getUniformSlot returns the same slot for repeat lookups', () async { + final gpu.RenderPipeline pipeline = await createUnlitRenderPipeline(); + final gpu.UniformSlot first = pipeline.vertexShader.getUniformSlot('VertInfo'); + final gpu.UniformSlot second = pipeline.vertexShader.getUniformSlot('VertInfo'); + expect(identical(first, second), isTrue); + }, skip: !(impellerEnabled && flutterGpuEnabled)); + + test('RenderPass.bindUniform throws for an unknown uniform name', () async { + final RenderPassState state = createSimpleRenderPass(); + + final gpu.RenderPipeline pipeline = await createUnlitRenderPipeline(); + final gpu.DeviceBuffer uniformBuffer = gpu.gpuContext.createDeviceBufferWithCopy( + float32([1, 2, 3, 4]), + ); + final uniformBufferView = gpu.BufferView( + uniformBuffer, + offsetInBytes: 0, + lengthInBytes: uniformBuffer.sizeInBytes, + ); + final gpu.UniformSlot unknownSlot = pipeline.vertexShader.getUniformSlot('DoesNotExist'); + try { + state.renderPass.bindUniform(unknownSlot, uniformBufferView); + fail('Exception not thrown for an unknown uniform name.'); + } catch (e) { + expect(e.toString(), contains('Failed to bind uniform')); + } + }, skip: !(impellerEnabled && flutterGpuEnabled)); + // Renders a green triangle pointing downwards. test('Can render triangle', () async { final RenderPassState state = createSimpleRenderPass(); diff --git a/engine/src/flutter/testing/dart/painting_test.dart b/engine/src/flutter/testing/dart/painting_test.dart index dc83c2b1db12a..d8a6b2b87c800 100644 --- a/engine/src/flutter/testing/dart/painting_test.dart +++ b/engine/src/flutter/testing/dart/painting_test.dart @@ -9,6 +9,7 @@ import 'package:test/test.dart'; import 'package:vector_math/vector_math_64.dart'; import 'goldens.dart'; +import 'impeller_enabled.dart'; typedef CanvasCallback = void Function(Canvas canvas); @@ -94,16 +95,9 @@ void main() { test('BackdropFilter with multiple clips', () async { // Regression test for https://github.com/flutter/flutter/issues/144211 - Picture makePicture(CanvasCallback callback) { - final recorder = PictureRecorder(); - final canvas = Canvas(recorder); - callback(canvas); - return recorder.endRecording(); - } - final sceneBuilder = SceneBuilder(); - final Picture redClippedPicture = makePicture((Canvas canvas) { + final Picture redClippedPicture = _makePicture((Canvas canvas) { canvas.drawPaint(Paint()..color = const Color(0xFFFFFFFF)); canvas.clipRect(const Rect.fromLTRB(10, 10, 200, 200)); canvas.clipRect(const Rect.fromLTRB(11, 10, 300, 200)); @@ -114,7 +108,7 @@ void main() { final matrix = Float64List(16); sceneBuilder.pushBackdropFilter(ImageFilter.matrix(matrix)); - final Picture whitePicture = makePicture((Canvas canvas) { + final Picture whitePicture = _makePicture((Canvas canvas) { canvas.drawPaint(Paint()..color = const Color(0xFFFFFFFF)); }); sceneBuilder.addPicture(Offset.zero, whitePicture); @@ -135,59 +129,34 @@ void main() { redClippedPicture.dispose(); }); - Image backdropBlurWithTileMode(TileMode? tileMode) { - Picture makePicture(CanvasCallback callback) { - final recorder = PictureRecorder(); - final canvas = Canvas(recorder); - callback(canvas); - return recorder.endRecording(); + test('BackdropFilter with ImageFilter.shader honors FilterQuality', () async { + // Regression test for https://github.com/flutter/flutter/issues/188365. + if (!impellerEnabled) { + print('Skipped for Skia.'); + return; } - const double rectSize = 10; - const count = 50; - const double imgSize = rectSize * count; - - final Picture blueGreenGridPicture = makePicture((Canvas canvas) { - const white = Color(0xFFFFFFFF); - const purple = Color(0xFFFF00FF); - const blue = Color(0xFF0000FF); - const green = Color(0xFF00FF00); - const yellow = Color(0xFFFFFF00); - const red = Color(0xFFFF0000); - canvas.drawColor(white, BlendMode.src); - for (var i = 0; i < count; i++) { - for (var j = 0; j < count; j++) { - final rectOdd = (i + j) & 1 == 0; - final fg = (i < count / 2) - ? ((j < count / 2) ? green : blue) - : ((j < count / 2) ? yellow : red); - canvas.drawRect( - Rect.fromLTWH(i * rectSize, j * rectSize, rectSize, rectSize), - Paint()..color = rectOdd ? fg : white, - ); - } - } - canvas.drawRect(const Rect.fromLTWH(0, 0, imgSize, 1), Paint()..color = purple); - canvas.drawRect(const Rect.fromLTWH(0, 0, 1, imgSize), Paint()..color = purple); - canvas.drawRect(const Rect.fromLTWH(0, imgSize - 1, imgSize, 1), Paint()..color = purple); - canvas.drawRect(const Rect.fromLTWH(imgSize - 1, 0, 1, imgSize), Paint()..color = purple); - }); - - final sceneBuilder = SceneBuilder(); - sceneBuilder.addPicture(Offset.zero, blueGreenGridPicture); - sceneBuilder.pushBackdropFilter(ImageFilter.blur(sigmaX: 20, sigmaY: 20, tileMode: tileMode)); + // The helper draws a black/white striped backdrop and filters it with a + // shader that samples the backdrop at a fractional texel coordinate. Because + // the stripes are grayscale, checking a single color channel is enough to + // tell whether the sample came from a source texel or was interpolated. + final Image nearest = await _backdropShaderWithFilterQuality(FilterQuality.none); + final Image linear = await _backdropShaderWithFilterQuality(FilterQuality.low); - final Scene scene = sceneBuilder.build(); - final Image image = scene.toImageSync(imgSize.round(), imgSize.round()); + // Nearest-neighbor sampling should pick either black or white exactly. + // Linear sampling should blend the adjacent black and white stripes. + final int nearestSample = await _redAt(nearest, 0, 1); + final int linearSample = await _redAt(linear, 0, 1); - scene.dispose(); - blueGreenGridPicture.dispose(); + expect(nearestSample, anyOf(0, 255)); + expect(linearSample, allOf(greaterThan(0), lessThan(255))); - return image; - } + nearest.dispose(); + linear.dispose(); + }); test('BackdropFilter with Blur honors TileMode.decal', () async { - final Image image = backdropBlurWithTileMode(TileMode.decal); + final Image image = _backdropBlurWithTileMode(TileMode.decal); final ImageComparer comparer = await ImageComparer.create(); await comparer.addGoldenImage(image, 'dart_ui_backdrop_filter_blur_decal_tile_mode.png'); @@ -196,7 +165,7 @@ void main() { }); test('BackdropFilter with Blur honors TileMode.clamp', () async { - final Image image = backdropBlurWithTileMode(TileMode.clamp); + final Image image = _backdropBlurWithTileMode(TileMode.clamp); final ImageComparer comparer = await ImageComparer.create(); await comparer.addGoldenImage(image, 'dart_ui_backdrop_filter_blur_clamp_tile_mode.png'); @@ -205,7 +174,7 @@ void main() { }); test('BackdropFilter with Blur honors TileMode.mirror', () async { - final Image image = backdropBlurWithTileMode(TileMode.mirror); + final Image image = _backdropBlurWithTileMode(TileMode.mirror); final ImageComparer comparer = await ImageComparer.create(); await comparer.addGoldenImage(image, 'dart_ui_backdrop_filter_blur_mirror_tile_mode.png'); @@ -214,7 +183,7 @@ void main() { }); test('BackdropFilter with Blur honors TileMode.repeated', () async { - final Image image = backdropBlurWithTileMode(TileMode.repeated); + final Image image = _backdropBlurWithTileMode(TileMode.repeated); final ImageComparer comparer = await ImageComparer.create(); await comparer.addGoldenImage(image, 'dart_ui_backdrop_filter_blur_repeated_tile_mode.png'); @@ -223,7 +192,7 @@ void main() { }); test('BackdropFilter with Blur default TileMode acts as TileMode.mirror', () async { - final Image image = backdropBlurWithTileMode(null); + final Image image = _backdropBlurWithTileMode(null); final ImageComparer comparer = await ImageComparer.create(); // It would be nice to compare the output here to the "mirror" golden @@ -278,3 +247,102 @@ void main() { } }); } + +Picture _makePicture(CanvasCallback callback) { + final recorder = PictureRecorder(); + final canvas = Canvas(recorder); + callback(canvas); + return recorder.endRecording(); +} + +Image _backdropBlurWithTileMode(TileMode? tileMode) { + const double rectSize = 10; + const count = 50; + const double imgSize = rectSize * count; + + final Picture blueGreenGridPicture = _makePicture((Canvas canvas) { + const white = Color(0xFFFFFFFF); + const purple = Color(0xFFFF00FF); + const blue = Color(0xFF0000FF); + const green = Color(0xFF00FF00); + const yellow = Color(0xFFFFFF00); + const red = Color(0xFFFF0000); + canvas.drawColor(white, BlendMode.src); + for (var i = 0; i < count; i++) { + for (var j = 0; j < count; j++) { + final rectOdd = (i + j) & 1 == 0; + final fg = (i < count / 2) + ? ((j < count / 2) ? green : blue) + : ((j < count / 2) ? yellow : red); + canvas.drawRect( + Rect.fromLTWH(i * rectSize, j * rectSize, rectSize, rectSize), + Paint()..color = rectOdd ? fg : white, + ); + } + } + canvas.drawRect(const Rect.fromLTWH(0, 0, imgSize, 1), Paint()..color = purple); + canvas.drawRect(const Rect.fromLTWH(0, 0, 1, imgSize), Paint()..color = purple); + canvas.drawRect(const Rect.fromLTWH(0, imgSize - 1, imgSize, 1), Paint()..color = purple); + canvas.drawRect(const Rect.fromLTWH(imgSize - 1, 0, 1, imgSize), Paint()..color = purple); + }); + + final sceneBuilder = SceneBuilder(); + sceneBuilder.addPicture(Offset.zero, blueGreenGridPicture); + sceneBuilder.pushBackdropFilter(ImageFilter.blur(sigmaX: 20, sigmaY: 20, tileMode: tileMode)); + + final Scene scene = sceneBuilder.build(); + final Image image = scene.toImageSync(imgSize.round(), imgSize.round()); + + scene.dispose(); + blueGreenGridPicture.dispose(); + + return image; +} + +Future _backdropShaderWithFilterQuality(FilterQuality filterQuality) async { + const width = 16; + const height = 4; + const stripeWidth = 1.0; + + final FragmentProgram program = await FragmentProgram.fromAsset( + 'filter_shader_fractional_texel.frag.iplr', + ); + final FragmentShader shader = program.fragmentShader(); + + final Picture stripePicture = _makePicture((Canvas canvas) { + for (var x = 0; x < width; x++) { + canvas.drawRect( + Rect.fromLTWH(x * stripeWidth, 0, stripeWidth, height.toDouble()), + Paint()..color = x.isEven ? const Color(0xFF000000) : const Color(0xFFFFFFFF), + ); + } + }); + + final Picture transparentPicture = _makePicture((Canvas canvas) { + canvas.drawRect( + Rect.fromLTWH(0, 0, width.toDouble(), height.toDouble()), + Paint()..color = const Color(0x00000000), + ); + }); + + final sceneBuilder = SceneBuilder(); + sceneBuilder.addPicture(Offset.zero, stripePicture); + sceneBuilder.pushBackdropFilter(ImageFilter.shader(shader, filterQuality: filterQuality)); + sceneBuilder.addPicture(Offset.zero, transparentPicture); + sceneBuilder.pop(); + + final Scene scene = sceneBuilder.build(); + final Image image = scene.toImageSync(width, height); + + scene.dispose(); + stripePicture.dispose(); + transparentPicture.dispose(); + shader.dispose(); + + return image; +} + +Future _redAt(Image image, int x, int y) async { + final ByteData data = (await image.toByteData())!; + return data.getUint8((y * image.width + x) * 4); +} diff --git a/engine/src/flutter/testing/dart/serial_gc_test.dart b/engine/src/flutter/testing/dart/serial_gc_test.dart deleted file mode 100644 index 4d7e580995812..0000000000000 --- a/engine/src/flutter/testing/dart/serial_gc_test.dart +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright 2013 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -// FlutterTesterOptions=--enable-serial-gc - -import 'package:test/test.dart'; - -int use(List a) { - return a[0]; -} - -void main() { - test('Serial GC option test ', () async { - const threw = false; - for (var i = 0; i < 100; i++) { - final a = [100]; - use(a); - } - expect(threw, false); - }); -} diff --git a/engine/src/flutter/testing/run_tests.py b/engine/src/flutter/testing/run_tests.py index 32dd2ba23a047..df89a48384fef 100755 --- a/engine/src/flutter/testing/run_tests.py +++ b/engine/src/flutter/testing/run_tests.py @@ -164,6 +164,12 @@ def is_aarm64() -> bool: return aarm64 +def mac_hardware_model() -> str: + assert is_mac() + output = subprocess.check_output(['sysctl', '-n', 'hw.model']) + return output.decode('utf-8').strip() + + def is_linux() -> bool: return sys_platform.startswith('linux') @@ -568,7 +574,13 @@ def make_test( ) extra_env = metal_validation_env() extra_env.update(vulkan_validation_env(build_dir)) - mac_impeller_unittests_flags = repeat_flags + [ + if mac_hardware_model() == 'Macmini9,1': + # For the Mac Minis used on CI, limit the number of Impeller test cases run in parallel + # in order to reduce the risk of resource exhaustion errors. + workers_flag = ['--workers=%d' % (os.cpu_count() - 2)] + else: + workers_flag = [] + mac_impeller_unittests_flags = repeat_flags + workers_flag + [ '--gtest_filter=-*OpenGLES', # These are covered in the golden tests. '--', '--enable_vulkan_validation', 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/engine/src/flutter/tools/cipd/android_embedding_bundle/build.gradle b/engine/src/flutter/tools/cipd/android_embedding_bundle/build.gradle index b9cebf621b236..af18220246034 100644 --- a/engine/src/flutter/tools/cipd/android_embedding_bundle/build.gradle +++ b/engine/src/flutter/tools/cipd/android_embedding_bundle/build.gradle @@ -13,7 +13,7 @@ buildscript { mavenCentral() } dependencies { - classpath "com.android.tools.build:gradle:8.11.1" + classpath "com.android.tools.build:gradle:9.1.0" } } diff --git a/engine/src/flutter/tools/clang_tidy/lib/src/command.dart b/engine/src/flutter/tools/clang_tidy/lib/src/command.dart index a37196562de37..fb608315a510a 100644 --- a/engine/src/flutter/tools/clang_tidy/lib/src/command.dart +++ b/engine/src/flutter/tools/clang_tidy/lib/src/command.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'dart:convert' show LineSplitter, utf8; +import 'dart:convert' show LineSplitter, Utf8Decoder; import 'dart:io' as io; import 'package:meta/meta.dart'; @@ -111,7 +111,7 @@ class Command { } final Stream lines = file .openRead() - .transform(utf8.decoder) + .transform(const Utf8Decoder(allowMalformed: true)) .transform(const LineSplitter()); return lintActionFromContents(lines); } diff --git a/engine/src/flutter/tools/clang_tidy/test/clang_tidy_test.dart b/engine/src/flutter/tools/clang_tidy/test/clang_tidy_test.dart index 4889df3266c1f..c767363cbed26 100644 --- a/engine/src/flutter/tools/clang_tidy/test/clang_tidy_test.dart +++ b/engine/src/flutter/tools/clang_tidy/test/clang_tidy_test.dart @@ -532,6 +532,18 @@ void main() { expect(lintAction, equals(LintAction.skipMissing)); }); + test('Command getLintAction handles non-UTF8 files without throwing', () async { + final io.Directory tempDir = io.Directory.systemTemp.createTempSync('clang_tidy_test_'); + final tempFile = io.File(path.join(tempDir.path, 'non_utf8_file.cc')); + tempFile.writeAsBytesSync([0x80, 0x81, 0xFE, 0xFF, 0x0A]); + try { + final LintAction lintAction = await Command.getLintAction(tempFile.path); + expect(lintAction, equals(LintAction.lint)); + } finally { + tempDir.deleteSync(recursive: true); + } + }); + test('Command getLintActionFromContents flags FLUTTER_NOLINT', () async { final LintAction lintAction = await Command.lintActionFromContents( Stream.fromIterable([ diff --git a/engine/src/flutter/tools/clang_tidy/test/header_filter_regex_test.dart b/engine/src/flutter/tools/clang_tidy/test/header_filter_regex_test.dart index 0d97ec4e82ca6..3abde0f901e48 100644 --- a/engine/src/flutter/tools/clang_tidy/test/header_filter_regex_test.dart +++ b/engine/src/flutter/tools/clang_tidy/test/header_filter_regex_test.dart @@ -40,6 +40,7 @@ void main() { '.git', '.gemini', '.github', + '.sourcekit-lsp', '.vscode', 'build_overrides', 'buildtools', diff --git a/engine/src/flutter/tools/engine_tool/test/commands/build_command_test.dart b/engine/src/flutter/tools/engine_tool/test/commands/build_command_test.dart index 99d15ef38ca7c..bba1b8900e0e7 100644 --- a/engine/src/flutter/tools/engine_tool/test/commands/build_command_test.dart +++ b/engine/src/flutter/tools/engine_tool/test/commands/build_command_test.dart @@ -54,7 +54,10 @@ void main() { printOnFailure(testEnv.testLogs.map((r) => r.message).join('\n')); expect(result, equals(0)); expect(testEnv.processHistory.length, greaterThanOrEqualTo(2)); - expect(testEnv.processHistory[1].command[0], contains('ninja')); + final ExecutedProcess ninja = testEnv.processHistory.firstWhere( + (p) => p.command.contains('-C'), + ); + expect(ninja.command[0], contains('ninja')); }); test('build command invokes generator', () async { @@ -127,7 +130,12 @@ void main() { printOnFailure(testEnv.testLogs.map((r) => r.message).join('\n')); expect(result, equals(0)); - final [ExecutedProcess gnCall, ExecutedProcess reclientCall, ..._] = testEnv.processHistory; + final ExecutedProcess gnCall = testEnv.processHistory.firstWhere( + (p) => p.command.first.endsWith('tools/gn'), + ); + final ExecutedProcess reclientCall = testEnv.processHistory.firstWhere( + (p) => p.command.first.endsWith('reclient/bootstrap'), + ); expect(gnCall.command, containsAllInOrder([endsWith('tools/gn'), contains('--rbe')])); expect(reclientCall.command, containsAllInOrder([endsWith('reclient/bootstrap')])); }); @@ -154,8 +162,9 @@ void main() { printOnFailure(testEnv.testLogs.map((r) => r.message).join('\n')); expect(result, equals(0)); - print(testEnv.processHistory); - final [_, ExecutedProcess ninja, ..._] = testEnv.processHistory; + final ExecutedProcess ninja = testEnv.processHistory.firstWhere( + (p) => p.command.contains('-C'), + ); expect(ninja.command, containsAllInOrder([endsWith('ninja/ninja'), '-j', '500'])); }); @@ -268,8 +277,11 @@ void main() { ); final int result = await runner.run(['build', '--config', 'host_debug']); expect(result, equals(0)); - expect(testEnv.processHistory[1].command[0], contains(path.join('ninja', 'ninja'))); - expect(testEnv.processHistory[1].command[2], contains('local_host_debug')); + final ExecutedProcess ninja = testEnv.processHistory.firstWhere( + (p) => p.command.contains('-C'), + ); + expect(ninja.command[0], contains(path.join('ninja', 'ninja'))); + expect(ninja.command[2], contains('local_host_debug')); }); test('ci config name on the command line is correctly translated', () async { @@ -297,8 +309,11 @@ void main() { ); final int result = await runner.run(['build', '--config', 'ci/host_debug']); expect(result, equals(0)); - expect(testEnv.processHistory[1].command[0], contains(path.join('ninja', 'ninja'))); - expect(testEnv.processHistory[1].command[2], contains('ci/host_debug')); + final ExecutedProcess ninja = testEnv.processHistory.firstWhere( + (p) => p.command.contains('-C'), + ); + expect(ninja.command[0], contains(path.join('ninja', 'ninja'))); + expect(ninja.command[2], contains('ci/host_debug')); }); test('build command invokes ninja with the specified target', () async { @@ -340,7 +355,7 @@ void main() { expect(result, equals(0)); final ExecutedProcess ninjaCmd = testEnv.processHistory.firstWhere( - (p) => p.command.first.endsWith('ninja'), + (p) => p.command.first.endsWith('ninja') && p.command.contains('-C'), ); expect(ninjaCmd.command, containsAllInOrder([endsWith('ninja'), '-C', endsWith('host_debug')])); expect(ninjaCmd.command, contains(contains('flutter/fml:fml_unittests'))); @@ -389,7 +404,7 @@ void main() { expect(result, equals(0)); final ExecutedProcess ninjaCmd = testEnv.processHistory.firstWhere( - (p) => p.command.first.endsWith('ninja'), + (p) => p.command.first.endsWith('ninja') && p.command.contains('-C'), ); expect(ninjaCmd.command, containsAllInOrder([endsWith('ninja'), '-C', endsWith('host_debug')])); diff --git a/engine/src/flutter/tools/engine_tool/test/commands/run_command_test.dart b/engine/src/flutter/tools/engine_tool/test/commands/run_command_test.dart index 2d3c6c4aef1b4..d1ca5bcce7a90 100644 --- a/engine/src/flutter/tools/engine_tool/test/commands/run_command_test.dart +++ b/engine/src/flutter/tools/engine_tool/test/commands/run_command_test.dart @@ -334,7 +334,7 @@ void main() { test('builds only once if the target and host are the same', () async { await et.run(['run', '--config=host_debug']); - expect(commandsRun, containsOnce(containsAllInOrder([endsWith('ninja')]))); + expect(commandsRun, containsOnce(containsAllInOrder([endsWith('ninja'), '-C']))); }); test('builds both the target and host if they are different', () async { diff --git a/engine/src/flutter/tools/pkg/engine_build_configs/lib/src/build_config_runner.dart b/engine/src/flutter/tools/pkg/engine_build_configs/lib/src/build_config_runner.dart index b472509a234d5..d7aecb21e425f 100644 --- a/engine/src/flutter/tools/pkg/engine_build_configs/lib/src/build_config_runner.dart +++ b/engine/src/flutter/tools/pkg/engine_build_configs/lib/src/build_config_runner.dart @@ -383,21 +383,56 @@ final class BuildRunner extends Runner { return result.ok; } + /// Generates and returns the ninja compilation database for the build. + /// + /// GN's default compile_commands.json generator only exports C and C++ + /// targets and omits custom wrapper rules like `swiftc.py`. This invokes + /// `ninja -t compdb` to retrieve all compilation targets in the build graph + /// and falls back to reading `compile_commands.json` from disk if the command + /// fails or returns empty. + Future _getCompilationDatabase() async { + final String ninjaPath = p.join( + engineSrcDir.parent.parent.path, + 'third_party', + 'ninja', + 'ninja', + ); + final String outDir = p.join(engineSrcDir.path, 'out', build.ninja.config); + final ProcessRunnerResult result = await processRunner.runProcess( + [ninjaPath, '-t', 'compdb'], + workingDirectory: io.Directory(outDir), + failOk: true, + ); + if (result.exitCode == 0 && result.stdout.isNotEmpty) { + return result.stdout; + } + final commandsFile = io.File(p.join(outDir, 'compile_commands.json')); + if (commandsFile.existsSync()) { + return commandsFile.readAsString(); + } + return null; + } + + /// Performs post-GN build steps. + /// + /// Retrieves the full compilation database from Ninja and processes it to + /// strip compiler wrapper prefixes and expand Swift compilation rules for IDE + /// language server compatibility. Future _postGn() async { if (dryRun) { return; } - final commandsFile = io.File( - p.join(engineSrcDir.path, 'out', build.ninja.config, 'compile_commands.json'), - ); - if (!commandsFile.existsSync()) { + final String? rawContents = await _getCompilationDatabase(); + if (rawContents == null) { return; } - final String contents = await commandsFile.readAsString(); - final String updated = stripCompilerWrappers(contents); - if (contents != updated) { + final String updated = updateCompilationDatabase(rawContents); + final commandsFile = io.File( + p.join(engineSrcDir.path, 'out', build.ninja.config, 'compile_commands.json'), + ); + if (rawContents != updated || !commandsFile.existsSync()) { await commandsFile.writeAsString(updated); } } diff --git a/engine/src/flutter/tools/pkg/engine_build_configs/lib/src/update_compdb.dart b/engine/src/flutter/tools/pkg/engine_build_configs/lib/src/update_compdb.dart index 0397eddd7fc2c..7d79ebdc210e1 100644 --- a/engine/src/flutter/tools/pkg/engine_build_configs/lib/src/update_compdb.dart +++ b/engine/src/flutter/tools/pkg/engine_build_configs/lib/src/update_compdb.dart @@ -2,7 +2,25 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'dart:convert' as convert; + +import 'package:path/path.dart' as p; + +// Matches a compile_commands.json `"command"` entry up to the clang executable. +// +// Matches up to `clang`/`clang++` so any wrapper prefix (rewrapper, ccache) +// before it can be stripped. +// +// Group 1 is the leading `"command": "`. +// Group 2 is the ` clang[++]` executable. +// +// For example, given the entry: +// "command": "../../buildtools/mac-arm64/reclient/rewrapper ../../buildtools/mac-arm64/clang/bin/clang++ -c foo.cc" +// group 2 is ` ../../buildtools/mac-arm64/clang/bin/clang++`. final RegExp _clangRegexp = RegExp(r'("command"\s*:\s*").*(\s(?:\S*/)?clang(\+\+)?)(?=[\s"])'); +final RegExp _swiftEntryRegexp = RegExp(r'\{[^{}]*swiftc\.py[^{}]*\}'); +final RegExp _shellQuoteRegexp = RegExp(r'[\s"\\$`!#&*|?()<>;~]'); +const convert.JsonEncoder _jsonEncoder = convert.JsonEncoder.withIndent(' '); /// Strips compiler wrapper prefixes from compiler commands in [contents]. /// @@ -16,3 +34,320 @@ String stripCompilerWrappers(String contents) { return '${match[1]}${match[2]!.trim()}'; }); } + +/// Converts GN `swiftc.py` invocations in [contents] into native `swiftc` commands. +/// +/// Our build toolchain invokes `swiftc` from a `swiftc.py` wrapper. Language +/// servers such as SourceKit-LSP expect expanded, per-file `swiftc` invocations +/// in `compile_commands.json`. This replaces wrapper calls with direct +/// invocations that language servers understand. +String expandSwiftcCommands(String contents) { + if (!contents.contains('swiftc.py')) { + return contents; + } + + return contents.replaceAllMapped(_swiftEntryRegexp, (Match match) { + final String rawJson = match.group(0)!; + try { + final entry = convert.jsonDecode(rawJson) as Map; + final List> expanded = expandSwiftEntry(entry); + return expanded.map((Map map) => _jsonEncoder.convert(map)).join(',\n '); + } catch (_) { + // If parsing fails for any reason, leave the block untouched. + return rawJson; + } + }); +} + +/// Post-processes the contents of a `compile_commands.json` file. +/// +/// Strips compiler wrapper prefixes (such as rewrapper and ccache) from clang +/// commands, and converts GN `swiftc.py` invocations into native `swiftc` +/// commands expanded per-file for SourceKit-LSP. +String updateCompilationDatabase(String contents) { + contents = stripCompilerWrappers(contents); + return expandSwiftcCommands(contents); +} + +/// Expands a single `swiftc.py` [entry] into per-file `swiftc` compilation entries. +/// +/// If [entry] has a `command` that invokes `swiftc.py`, this parses the +/// arguments, translates them to `swiftc` arguments, makes paths absolute +/// against the entry's `directory`, and returns an entry for each `.swift` file +/// compiled. +List> expandSwiftEntry(Map entry) { + final String entryDir = (entry['directory'] as String?) ?? ''; + final Map baseEntry = _absolutizeEntryFile(entry, entryDir); + + final origCommand = baseEntry['command'] as String?; + if (origCommand == null) { + return [baseEntry]; + } + + final _SwiftcTranslation? translation = _translateSwiftcCommand(origCommand); + if (translation == null) { + return [baseEntry]; + } + + final translatedEntry = { + ...baseEntry, + 'command': _resolveCommandPaths(entryDir, translation.args), + }; + + if (translation.swiftFiles.isEmpty) { + return [translatedEntry]; + } + + final absSwiftFiles = [ + for (final String f in translation.swiftFiles) makePathAbsolute(entryDir, f), + ]; + return _duplicateEntryPerSwiftFile(translatedEntry, absSwiftFiles); +} + +/// Returns a copy of [entry] with an absolute `file` path. +Map _absolutizeEntryFile(Map entry, String entryDir) { + final file = entry['file'] as String?; + return { + ...entry, + if (file != null && !p.isAbsolute(file)) 'file': makePathAbsolute(entryDir, file), + }; +} + +/// Duplicates [entry] once per file in [absSwiftFiles]. +/// +/// This skips [entry]'s own `file`, which is already covered, overriding `file` +/// on each copy, so the language server indexes every compiled Swift file. +List> _duplicateEntryPerSwiftFile( + Map entry, + List absSwiftFiles, +) { + final origFileAbs = entry['file'] as String?; + final results = >[entry]; + for (final absFile in absSwiftFiles) { + if (absFile != origFileAbs) { + results.add({...entry, 'file': absFile}); + } + } + return results; +} + +/// Resolves [filePath] against [directory] and returns the normalized absolute path. +String makePathAbsolute(String directory, String filePath) => + p.normalize(p.isAbsolute(filePath) ? filePath : p.join(directory, filePath)); + +/// The `swiftc` arguments and Swift source files extracted from a GN `swiftc.py` command. +typedef _SwiftcTranslation = ({List args, List swiftFiles}); + +/// Parses [cmdStr] and translates it to `swiftc` arguments. +/// +/// Returns null if [cmdStr] is not a `swiftc.py` invocation. +_SwiftcTranslation? _translateSwiftcCommand(String cmdStr) { + if (!cmdStr.contains('swiftc.py')) { + return null; + } + final List words = splitShellWords(cmdStr); + final int swiftcPyIdx = words.indexWhere((String w) => w.contains('swiftc.py')); + if (swiftcPyIdx == -1) { + return null; + } + return _translateSwiftcArgs(words.sublist(swiftcPyIdx + 1)); +} + +/// Returns true if the flag [arg] is a boolean flag that does not take a value. +/// +/// Most of swiftc.py's flags take a value; this returns true for those that +/// don't. +bool _isBooleanFlag(String arg) { + return arg == '--fix-generated-header'; +} + +/// Translates GN `swiftc.py` [args] into native `swiftc` arguments. +/// +/// Non-path `-Xcc` flag synthesis (such as +/// preprocessor defines `-D`) is handled here, during syntactic argument +/// translation. +/// +/// Path-dependent `-Xcc` flag synthesis (such as `-I` or `-F`) is deferred to +/// [_resolveCommandPaths], where relative filesystem paths are resolved to +/// absolute paths. +_SwiftcTranslation _translateSwiftcArgs(List args) { + final newArgs = ['swiftc', '-parse-as-library']; + final swiftFiles = []; + + var i = 0; + while (i < args.length) { + final String arg = args[i]; + switch (arg) { + case '-import-objc-header': + // Extract header argument. + if (i + 1 < args.length) { + final String val = args[i + 1]; + if (val.isNotEmpty && val != '""' && val != "''") { + newArgs.addAll(['-import-objc-header', val]); + } + } + i += 2; + case '--whole-module-optimization': + newArgs.add('-whole-module-optimization'); + i += 1; + case _ when arg.startsWith('--'): + i += _isBooleanFlag(arg) ? 1 : 2; + case '-D': + if (i + 1 < args.length) { + final String val = args[i + 1]; + // Swift's `-D` only supports bare conditional-compilation flags, not + // `key=value` defines, so those are only forwarded to clang. + if (!val.contains('=')) { + newArgs.addAll(['-D', val]); + } + newArgs.addAll(['-Xcc', '-D$val']); + } + i += 2; + case _ when arg.startsWith('-D'): + final String val = arg.substring(2); + if (!val.contains('=')) { + newArgs.add(arg); + } + newArgs.addAll(['-Xcc', '-D$val']); + i += 1; + case _: + newArgs.add(arg); + if (arg.endsWith('.swift')) { + swiftFiles.add(arg); + } + i += 1; + } + } + + return (args: newArgs, swiftFiles: swiftFiles); +} + +/// Resolves relative paths in [words] to absolute paths against [directory]. +/// +/// Synthesizes path-dependent `-Xcc` flags for include and framework search +/// paths (`-I`, `-F`, `-isystem`, `-Fsystem`), and returns the quoted, joined +/// command string. Non-path `-Xcc` synthesis (`-D`) is handled upstream, in +/// [_translateSwiftcArgs]. +/// +/// `-isystem` is clang-only: it's forwarded to swift only as `-Xcc -isystem +/// -Xcc `, never as a bare swift-side flag. +String _resolveCommandPaths(String directory, List words) { + final newArgs = []; + + // `-isystem` is the only one of these forwarded to clang exclusively; see + // the doc comment above. + bool isClangOnly(String flag) => flag == '-isystem'; + + int addSeparatedIncludeFlag(int i) { + final String flag = words[i]; + if (!isClangOnly(flag)) { + newArgs.add(flag); + } + if (i + 1 >= words.length) { + return i + 1; + } + final String absVal = makePathAbsolute(directory, words[i + 1]); + if (!isClangOnly(flag)) { + newArgs.add(absVal); + } + if (flag case '-I' || '-isystem' || '-F' || '-Fsystem') { + newArgs.addAll(['-Xcc', flag, '-Xcc', absVal]); + } + return i + 2; + } + + void addAttachedIncludeFlag(String arg) { + final String prefix = switch (arg) { + _ when arg.startsWith('-isystem') => '-isystem', + _ when arg.startsWith('-Fsystem') => '-Fsystem', + _ when arg.startsWith('-F') => '-F', + _ => '-I', + }; + + // A bare `-I`/`-isystem`/`-F`/`-Fsystem` (no attached value) is caught by + // the exact-match branch below instead, so `val` is never empty here. + final String val = makePathAbsolute(directory, arg.substring(prefix.length)); + if (!isClangOnly(prefix)) { + newArgs.add('$prefix$val'); + } + newArgs.addAll(['-Xcc', '$prefix$val']); + } + + var i = 0; + while (i < words.length) { + final String arg = words[i]; + switch (arg) { + case _ when arg.endsWith('.swift'): + newArgs.add(makePathAbsolute(directory, arg)); + i += 1; + case '-I' || '-isystem' || '-F' || '-Fsystem' || '-import-objc-header' || '-sdk': + i = addSeparatedIncludeFlag(i); + case _ when arg.startsWith('-I') || arg.startsWith('-F') || arg.startsWith('-isystem'): + addAttachedIncludeFlag(arg); + i += 1; + case _: + newArgs.add(arg); + i += 1; + } + } + return newArgs.map(quoteShellWord).join(' '); +} + +/// Parses [cmd] into individual shell arguments. +/// +/// An empty quoted argument (`""` or `''`) is preserved as an empty-string +/// element rather than disappearing, so callers can distinguish "argument +/// present but empty" from "argument absent". +List splitShellWords(String cmd) { + final args = []; + final buffer = StringBuffer(); + var inSingleQuote = false; + var inDoubleQuote = false; + var escape = false; + var quoted = false; + + for (var i = 0; i < cmd.length; i += 1) { + final String char = cmd[i]; + switch (char) { + case _ when escape: + buffer.write(char); + escape = false; + case r'\' when !inSingleQuote: + escape = true; + case "'" when !inDoubleQuote: + inSingleQuote = !inSingleQuote; + quoted = true; + case '"' when !inSingleQuote: + inDoubleQuote = !inDoubleQuote; + quoted = true; + case ' ' || '\t' when !inSingleQuote && !inDoubleQuote: + if (buffer.isNotEmpty || quoted) { + args.add(buffer.toString()); + buffer.clear(); + quoted = false; + } + case _: + buffer.write(char); + } + } + if (buffer.isNotEmpty || quoted) { + args.add(buffer.toString()); + } + return args; +} + +/// Quotes and escapes [arg] for safe inclusion as a shell argument. +String quoteShellWord(String arg) { + if (arg.isEmpty) { + return "''"; + } + if (!arg.contains(_shellQuoteRegexp)) { + return arg; + } + if (!arg.contains("'")) { + return "'$arg'"; + } + return r'"' + '${arg.replaceAll(r'\', r'\\').replaceAll('"', r'\"')}' + r'"'; +} diff --git a/engine/src/flutter/tools/pkg/engine_build_configs/test/update_compdb_test.dart b/engine/src/flutter/tools/pkg/engine_build_configs/test/update_compdb_test.dart index 81678fcce6e1a..96df159482a19 100644 --- a/engine/src/flutter/tools/pkg/engine_build_configs/test/update_compdb_test.dart +++ b/engine/src/flutter/tools/pkg/engine_build_configs/test/update_compdb_test.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:convert' as convert; + import 'package:engine_build_configs/src/update_compdb.dart'; import 'package:test/test.dart'; @@ -26,6 +28,7 @@ void main() { } ] '''; + expect(updateCompilationDatabase(input), equals(expected)); expect(stripCompilerWrappers(input), equals(expected)); }); @@ -48,6 +51,7 @@ void main() { } ] '''; + expect(updateCompilationDatabase(input), equals(expected)); expect(stripCompilerWrappers(input), equals(expected)); }); @@ -70,6 +74,7 @@ void main() { } ] '''; + expect(updateCompilationDatabase(input), equals(expected)); expect(stripCompilerWrappers(input), equals(expected)); }); @@ -83,7 +88,220 @@ void main() { } ] '''; + expect(updateCompilationDatabase(input), equals(input)); expect(stripCompilerWrappers(input), equals(input)); }); }); + + group('expandSwiftcCommands', () { + test('leaves standard compile_commands untouched if no swiftc.py or wrapper is present', () { + const input = r''' +[ + { + "file": "../../flutter/foo.cc", + "directory": "/out/config", + "command": "../../clang/bin/clang++ -c ../../flutter/foo.cc" + } +] +'''; + expect(updateCompilationDatabase(input), equals(input)); + expect(stripCompilerWrappers(input), equals(input)); + expect(expandSwiftcCommands(input), equals(input)); + }); + + test('translates swiftc.py flags and makes paths absolute', () { + const input = r''' +[ + { + "file": "../../flutter/bar.swift", + "directory": "/out/config", + "command": "python3 ../../flutter/tools/swiftc.py -module-name Bar -import-objc-header ../../flutter/header.h -target arm64-macos14.0 -I ../../flutter/include -D FOO_BAR ../../flutter/bar.swift" + } +] +'''; + final String output = updateCompilationDatabase(input); + final json = convert.jsonDecode(output) as List; + expect(json.length, equals(1)); + final entry = json[0] as Map; + expect(entry['file'], equals('/flutter/bar.swift')); + final command = entry['command'] as String; + expect(command, contains('swiftc')); + expect(command, contains('-parse-as-library')); + expect(command, contains('-module-name Bar')); + expect(command, contains('-import-objc-header /flutter/header.h')); + expect(command, contains('-target arm64-macos14.0')); + expect(command, contains('-I /flutter/include')); + expect(command, contains('-Xcc -I -Xcc /flutter/include')); + expect(command, contains('-D FOO_BAR')); + expect(command, contains('-Xcc -DFOO_BAR')); + }); + + test('-isystem is forwarded to clang only, never bare to swiftc', () { + const input = r''' +[ + { + "file": "../../flutter/bar.swift", + "directory": "/out/config", + "command": "python3 ../../flutter/tools/swiftc.py -module-name Bar -isystem ../../flutter/sysinclude ../../flutter/bar.swift" + } +] +'''; + final String output = updateCompilationDatabase(input); + final json = convert.jsonDecode(output) as List; + final entry = json[0] as Map; + final List args = splitShellWords(entry['command'] as String); + expect( + args, + equals([ + 'swiftc', + '-parse-as-library', + '-module-name', + 'Bar', + '-Xcc', + '-isystem', + '-Xcc', + '/flutter/sysinclude', + '/flutter/bar.swift', + ]), + ); + }); + + test('-F and -Fsystem are forwarded to both swiftc and clang', () { + const input = r''' +[ + { + "file": "../../flutter/bar.swift", + "directory": "/out/config", + "command": "python3 ../../flutter/tools/swiftc.py -module-name Bar -F ../../flutter/frameworks -Fsystem../../flutter/sysframeworks ../../flutter/bar.swift" + } +] +'''; + final String output = updateCompilationDatabase(input); + final json = convert.jsonDecode(output) as List; + final entry = json[0] as Map; + final List args = splitShellWords(entry['command'] as String); + expect( + args, + equals([ + 'swiftc', + '-parse-as-library', + '-module-name', + 'Bar', + '-F', + '/flutter/frameworks', + '-Xcc', + '-F', + '-Xcc', + '/flutter/frameworks', + '-Fsystem/flutter/sysframeworks', + '-Xcc', + '-Fsystem/flutter/sysframeworks', + '/flutter/bar.swift', + ]), + ); + }); + + test('-D with a value ("key=value") is forwarded to clang only', () { + const input = r''' +[ + { + "file": "../../flutter/bar.swift", + "directory": "/out/config", + "command": "python3 ../../flutter/tools/swiftc.py -module-name Bar -D FOO=1 -DBAR=2 ../../flutter/bar.swift" + } +] +'''; + final String output = updateCompilationDatabase(input); + final json = convert.jsonDecode(output) as List; + final entry = json[0] as Map; + final List args = splitShellWords(entry['command'] as String); + expect( + args, + equals([ + 'swiftc', + '-parse-as-library', + '-module-name', + 'Bar', + '-Xcc', + '-DFOO=1', + '-Xcc', + '-DBAR=2', + '/flutter/bar.swift', + ]), + ); + }); + + test('drops -import-objc-header when the value is an empty quoted string', () { + const input = r''' +[ + { + "file": "../../flutter/bar.swift", + "directory": "/out/config", + "command": "python3 ../../flutter/tools/swiftc.py -module-name Bar -import-objc-header \"\" -target arm64-macos14.0 ../../flutter/bar.swift" + } +] +'''; + final String output = updateCompilationDatabase(input); + final json = convert.jsonDecode(output) as List; + final entry = json[0] as Map; + final command = entry['command'] as String; + expect(command, isNot(contains('-import-objc-header'))); + expect(command, contains('-module-name Bar')); + expect(command, contains('-target arm64-macos14.0')); + }); + + test('leaves a malformed swiftc.py JSON block untouched', () { + const input = r''' +[ + { + "file": "../../flutter/bar.swift" + "directory": "/out/config", + "command": "python3 ../../flutter/tools/swiftc.py -module-name Bar ../../flutter/bar.swift" + } +] +'''; + expect(expandSwiftcCommands(input), equals(input)); + }); + + test('expands multi-file swiftc.py invocations into individual entries', () { + const input = r''' +[ + { + "file": "../../flutter/bar.swift", + "directory": "/out/config", + "command": "python3 ../../flutter/tools/swiftc.py -module-name Bar ../../flutter/bar.swift ../../flutter/baz.swift" + } +] +'''; + final String output = updateCompilationDatabase(input); + final json = convert.jsonDecode(output) as List; + expect(json.length, equals(2)); + final entry1 = json[0] as Map; + final entry2 = json[1] as Map; + expect(entry1['file'], equals('/flutter/bar.swift')); + expect(entry2['file'], equals('/flutter/baz.swift')); + expect(entry1['command'], equals(entry2['command'])); + }); + }); + + group('splitShellWords / quoteShellWord', () { + test('splitShellWords and quoteShellWord work correctly', () { + final List words = splitShellWords("python3 'foo bar' \"baz qux\" -D \"\\\$FOO\""); + expect(words, equals(['python3', 'foo bar', 'baz qux', '-D', r'$FOO'])); + expect(quoteShellWord('foo bar'), equals("'foo bar'")); + expect(quoteShellWord('simple'), equals('simple')); + }); + + test('splitShellWords handles edge cases gracefully', () { + expect(splitShellWords(''), isEmpty); + expect(splitShellWords(' \t '), isEmpty); + expect(splitShellWords(r'foo\'), equals(['foo'])); + expect(quoteShellWord(''), equals("''")); + }); + + test('splitShellWords preserves an empty quoted argument as an empty string', () { + expect(splitShellWords('-foo "" -bar'), equals(['-foo', '', '-bar'])); + expect(splitShellWords("-foo '' -bar"), equals(['-foo', '', '-bar'])); + }); + }); } diff --git a/engine/src/flutter/tools/vscode_workspace/engine-workspace.yaml b/engine/src/flutter/tools/vscode_workspace/engine-workspace.yaml index e70e3504e0d57..9e794046ac1ca 100644 --- a/engine/src/flutter/tools/vscode_workspace/engine-workspace.yaml +++ b/engine/src/flutter/tools/vscode_workspace/engine-workspace.yaml @@ -129,6 +129,8 @@ settings: - ${workspaceFolder} dotnet.defaultSolution: disable dart.showTodos: false + swift.sourcekit-lsp.supported-languages: + - swift testMate.cpp.test.advancedExecutables: - name: impeller_unittests_arm64 pattern: ../out/host_debug_unopt_arm64/impeller_unittests diff --git a/examples/api/lib/material/selectable_region/selectable_region.0.dart b/examples/api/lib/material/selectable_region/selectable_region.0.dart index eb775d8acae56..f79d89bc1360b 100644 --- a/examples/api/lib/material/selectable_region/selectable_region.0.dart +++ b/examples/api/lib/material/selectable_region/selectable_region.0.dart @@ -54,8 +54,10 @@ class MySelectableAdapter extends StatelessWidget { } class _SelectableAdapter extends SingleChildRenderObjectWidget { - const _SelectableAdapter({required this.registrar, required Widget child}) - : super(child: child); + const _SelectableAdapter({ + required this.registrar, + required Widget super.child, + }); final SelectionRegistrar registrar; diff --git a/examples/api/lib/widgets/nested_scroll_view/nested_scroll_view.0.dart b/examples/api/lib/widgets/nested_scroll_view/nested_scroll_view.0.dart index c88b9e985af47..400eb2e5f7158 100644 --- a/examples/api/lib/widgets/nested_scroll_view/nested_scroll_view.0.dart +++ b/examples/api/lib/widgets/nested_scroll_view/nested_scroll_view.0.dart @@ -64,66 +64,75 @@ class NestedScrollViewExample extends StatelessWidget { ), ]; }, - body: TabBarView( - // These are the contents of the tab views, below the tabs. - children: tabs.map((String name) { - return SafeArea( - top: false, - bottom: false, - child: Builder( - // This Builder is needed to provide a BuildContext that is - // "inside" the NestedScrollView, so that - // sliverOverlapAbsorberHandleFor() can find the - // NestedScrollView. - builder: (BuildContext context) { - return CustomScrollView( - // The "controller" and "primary" members should be left - // unset, so that the NestedScrollView can control this - // inner scroll view. - // If the "controller" property is set, then this scroll - // view will not be associated with the NestedScrollView. - // The PageStorageKey should be unique to this ScrollView; - // it allows the list to remember its scroll position when - // the tab view is not on the screen. - key: PageStorageKey(name), - slivers: [ - SliverOverlapInjector( - // This is the flip side of the SliverOverlapAbsorber - // above. - handle: - NestedScrollView.sliverOverlapAbsorberHandleFor( - context, - ), - ), - SliverPadding( - padding: const .all(8.0), - // In this example, the inner scroll view has - // fixed-height list items, hence the use of - // SliverFixedExtentList. However, one could use any - // sliver widget here, e.g. SliverList or SliverGrid. - sliver: SliverFixedExtentList.builder( - // The items in this example are fixed to 48 pixels - // high. This matches the Material Design spec for - // ListTile widgets. - itemExtent: 48.0, - // The itemCount of the SliverFixedExtentList.builder - // specifies how many children this inner list - // has. In this example, each tab has a list of - // exactly 30 items, but this is arbitrary. - itemCount: 30, - itemBuilder: (BuildContext context, int index) { - // This builder is called for each child. - // In this example, we just number each list item. - return ListTile(title: Text('Item $index')); - }, + body: ScrollConfiguration( + // Scrollbars have different default behaviors based on platform + // expectations. For the purpose of this sample, which can be run on + // any platform, default scrollbars are disabled for the inner + // scrollables. + behavior: ScrollConfiguration.of( + context, + ).copyWith(scrollbars: false), + child: TabBarView( + // These are the contents of the tab views, below the tabs. + children: tabs.map((String name) { + return SafeArea( + top: false, + bottom: false, + child: Builder( + // This Builder is needed to provide a BuildContext that is + // "inside" the NestedScrollView, so that + // sliverOverlapAbsorberHandleFor() can find the + // NestedScrollView. + builder: (BuildContext context) { + return CustomScrollView( + // The "controller" and "primary" members should be left + // unset, so that the NestedScrollView can control this + // inner scroll view. + // If the "controller" property is set, then this scroll + // view will not be associated with the NestedScrollView. + // The PageStorageKey should be unique to this ScrollView; + // it allows the list to remember its scroll position when + // the tab view is not on the screen. + key: PageStorageKey(name), + slivers: [ + SliverOverlapInjector( + // This is the flip side of the SliverOverlapAbsorber + // above. + handle: + NestedScrollView.sliverOverlapAbsorberHandleFor( + context, + ), ), - ), - ], - ); - }, - ), - ); - }).toList(), + SliverPadding( + padding: const .all(8.0), + // In this example, the inner scroll view has + // fixed-height list items, hence the use of + // SliverFixedExtentList. However, one could use any + // sliver widget here, e.g. SliverList or SliverGrid. + sliver: SliverFixedExtentList.builder( + // The items in this example are fixed to 48 pixels + // high. This matches the Material Design spec for + // ListTile widgets. + itemExtent: 48.0, + // The itemCount of the SliverFixedExtentList.builder + // specifies how many children this inner list + // has. In this example, each tab has a list of + // exactly 30 items, but this is arbitrary. + itemCount: 30, + itemBuilder: (BuildContext context, int index) { + // This builder is called for each child. + // In this example, we just number each list item. + return ListTile(title: Text('Item $index')); + }, + ), + ), + ], + ); + }, + ), + ); + }).toList(), + ), ), ), ), diff --git a/examples/api/lib/widgets/windows/popup.0.dart b/examples/api/lib/widgets/windows/popup.0.dart index 60f57c8dad259..ecfdc65e762eb 100644 --- a/examples/api/lib/widgets/windows/popup.0.dart +++ b/examples/api/lib/widgets/windows/popup.0.dart @@ -15,8 +15,8 @@ import 'package:flutter/src/widgets/_window_positioner.dart'; void main() { try { runWidget( - RegularWindow( - controller: RegularWindowController( + Window( + controller: WindowController( size: const Size(800, 600), constraints: const BoxConstraints(minWidth: 640, minHeight: 480), title: 'Example Window', diff --git a/examples/api/lib/widgets/windows/satellite.0.dart b/examples/api/lib/widgets/windows/satellite.0.dart index fab69069a180e..f52ca066d3f79 100644 --- a/examples/api/lib/widgets/windows/satellite.0.dart +++ b/examples/api/lib/widgets/windows/satellite.0.dart @@ -14,8 +14,8 @@ void main() { try { WidgetsFlutterBinding.ensureInitialized(); runWidget( - RegularWindow( - controller: RegularWindowController( + Window( + controller: WindowController( size: const Size(800, 600), constraints: const BoxConstraints(minWidth: 640, minHeight: 480), title: 'Example Window', diff --git a/examples/api/lib/widgets/windows/tooltip.0.dart b/examples/api/lib/widgets/windows/tooltip.0.dart index 72db917d1406d..c15216a0d0370 100644 --- a/examples/api/lib/widgets/windows/tooltip.0.dart +++ b/examples/api/lib/widgets/windows/tooltip.0.dart @@ -15,8 +15,8 @@ import 'package:flutter/src/widgets/_window_positioner.dart'; void main() { try { runWidget( - RegularWindow( - controller: RegularWindowController( + Window( + controller: WindowController( size: const Size(800, 600), constraints: const BoxConstraints(minWidth: 640, minHeight: 480), title: 'Example Window', diff --git a/examples/api/lib/widgets/windows/window_manager.0.dart b/examples/api/lib/widgets/windows/window_manager.0.dart index 71dad55a5f584..634a0fafa680f 100644 --- a/examples/api/lib/widgets/windows/window_manager.0.dart +++ b/examples/api/lib/widgets/windows/window_manager.0.dart @@ -12,12 +12,17 @@ import 'package:flutter/src/widgets/_window.dart'; void main() { try { WidgetsFlutterBinding.ensureInitialized(); - final RegularWindowController controller = RegularWindowController( + final WindowController controller = WindowController( size: const Size(800, 600), ); runWidget( WindowManager( - child: RegularWindow(controller: controller, child: const MainWindow()), + initialWindows: [ + WindowEntry( + controller: controller, + builder: (context) => const MainWindow(), + ), + ], ), ); } on UnsupportedError catch (e) { diff --git a/examples/api/test/widgets/nested_scroll_view/nested_scroll_view.0_test.dart b/examples/api/test/widgets/nested_scroll_view/nested_scroll_view.0_test.dart index 7768df8a97892..b183365ba4d00 100644 --- a/examples/api/test/widgets/nested_scroll_view/nested_scroll_view.0_test.dart +++ b/examples/api/test/widgets/nested_scroll_view/nested_scroll_view.0_test.dart @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_api_samples/widgets/nested_scroll_view/nested_scroll_view.0.dart' as example; @@ -50,4 +51,52 @@ void main() { lessThan(initialAppBarHeight), ); }); + + testWidgets( + 'Does not crash when scrolling an inner list then switching tabs on desktop', + (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/183199 + // + // Without the ScrollConfiguration in this example, the default desktop + // scrollbar attaches to the NestedScrollView's coordinated controller and + // throws once more than one ScrollPosition is attached to it, which happens + // mid tab transition. + await tester.pumpWidget(const example.NestedScrollViewExampleApp()); + await tester.pumpAndSettle(); + + // The example opts out of the default scrollbars for its body. + expect(find.byType(Scrollbar), findsNothing); + + // Scroll the first tab's inner list. + await tester.drag( + find.text('Item 0'), + const Offset(0.0, -100.0), + touchSlopY: 0.0, + ); + await tester.pump(); + + // Begin, but do not finish, a tab transition so both tabs' inner scroll + // views are attached to the coordinated controller at the same time. + await tester.fling( + find.byType(TabBarView), + const Offset(-300.0, 0.0), + 800.0, + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 30)); + + // A mouse-wheel pointer signal mid-transition previously drove the + // scrollbar validation while more than one position was attached. + final TestPointer pointer = TestPointer(1, PointerDeviceKind.mouse); + final Offset center = tester.getCenter(find.byType(TabBarView)); + await tester.sendEventToBinding(pointer.hover(center)); + await tester.sendEventToBinding(pointer.scroll(const Offset(0.0, 60.0))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 700)); + await tester.pumpAndSettle(); + + expect(tester.takeException(), isNull); + }, + variant: TargetPlatformVariant.desktop(), + ); } diff --git a/examples/layers/pubspec.yaml b/examples/layers/pubspec.yaml index 204a6440ff4eb..c3529ed0aee52 100644 --- a/examples/layers/pubspec.yaml +++ b/examples/layers/pubspec.yaml @@ -8,16 +8,15 @@ resolution: workspace dependencies: flutter: sdk: flutter - + material_ui: ^0.0.2 dev_dependencies: flutter_test: sdk: flutter - flutter: assets: - services/data.json uses-material-design: true -# PUBSPEC CHECKSUM: 60tfp7 +# PUBSPEC CHECKSUM: oescuq diff --git a/examples/layers/rendering/touch_input.dart b/examples/layers/rendering/touch_input.dart index dcb46b4ee22c7..2d7030bbe13b9 100644 --- a/examples/layers/rendering/touch_input.dart +++ b/examples/layers/rendering/touch_input.dart @@ -5,8 +5,8 @@ // This example shows how to use process input events in the underlying render // tree. -import 'package:flutter/material.dart'; // Imported just for its color palette. import 'package:flutter/rendering.dart'; +import 'package:material_ui/material_ui.dart'; // Imported just for its color palette. import 'src/binding.dart'; diff --git a/examples/layers/services/isolate.dart b/examples/layers/services/isolate.dart index 228be44c1a1ee..12d9a632769a4 100644 --- a/examples/layers/services/isolate.dart +++ b/examples/layers/services/isolate.dart @@ -5,8 +5,8 @@ import 'dart:convert'; import 'dart:isolate'; -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:material_ui/material_ui.dart'; typedef OnProgressListener = void Function(double completed, double total); typedef OnResultListener = void Function(String result); diff --git a/examples/layers/test/gestures_test.dart b/examples/layers/test/gestures_test.dart index 47cd843b06bcd..044c8b103b0fc 100644 --- a/examples/layers/test/gestures_test.dart +++ b/examples/layers/test/gestures_test.dart @@ -2,8 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; import '../widgets/gestures.dart'; diff --git a/examples/layers/widgets/gestures.dart b/examples/layers/widgets/gestures.dart index ae622f31901ae..e45b39e9c45e2 100644 --- a/examples/layers/widgets/gestures.dart +++ b/examples/layers/widgets/gestures.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; class _GesturePainter extends CustomPainter { const _GesturePainter({ diff --git a/examples/layers/widgets/media_query.dart b/examples/layers/widgets/media_query.dart index 281170966e542..4c67e9e6b11ca 100644 --- a/examples/layers/widgets/media_query.dart +++ b/examples/layers/widgets/media_query.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; class AdaptedListItem extends StatelessWidget { const AdaptedListItem({super.key, required this.name}); diff --git a/examples/layers/widgets/sectors.dart b/examples/layers/widgets/sectors.dart index 3a48868d33d21..9871ca9a73faf 100644 --- a/examples/layers/widgets/sectors.dart +++ b/examples/layers/widgets/sectors.dart @@ -4,7 +4,7 @@ import 'dart:math' as math; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import '../rendering/src/sector_layout.dart'; diff --git a/examples/layers/widgets/spinning_mixed.dart b/examples/layers/widgets/spinning_mixed.dart index c30e8dab6301a..184d219c11d70 100644 --- a/examples/layers/widgets/spinning_mixed.dart +++ b/examples/layers/widgets/spinning_mixed.dart @@ -2,8 +2,8 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:material_ui/material_ui.dart'; import '../rendering/src/solid_color_box.dart'; diff --git a/examples/layers/widgets/styled_text.dart b/examples/layers/widgets/styled_text.dart index 4ef75dd47b79a..f8c221d734443 100644 --- a/examples/layers/widgets/styled_text.dart +++ b/examples/layers/widgets/styled_text.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; typedef _TextTransformer = Widget Function(String name, String text); diff --git a/examples/multiple_windows/lib/app/dialog_window_content.dart b/examples/multiple_windows/lib/app/dialog_window_content.dart index 46935af34067e..194a7b3f83cce 100644 --- a/examples/multiple_windows/lib/app/dialog_window_content.dart +++ b/examples/multiple_windows/lib/app/dialog_window_content.dart @@ -5,8 +5,8 @@ // ignore_for_file: invalid_use_of_internal_member // ignore_for_file: implementation_imports -import 'package:flutter/material.dart'; import 'package:flutter/src/widgets/_window.dart'; +import 'package:material_ui/material_ui.dart'; import 'models.dart'; @@ -19,9 +19,8 @@ class DialogWindowContent extends StatelessWidget { Widget build(BuildContext context) { final WindowSettings windowSettings = WindowSettingsAccessor.of(context); - return Overlay.wrap( - alwaysSizeToContent: true, - child: FocusScope( + return MaterialApp( + home: FocusScope( autofocus: true, child: IntrinsicWidth( child: Material( diff --git a/examples/multiple_windows/lib/app/dialog_window_edit_dialog.dart b/examples/multiple_windows/lib/app/dialog_window_edit_dialog.dart index 442da3f3c5385..671fec36265b0 100644 --- a/examples/multiple_windows/lib/app/dialog_window_edit_dialog.dart +++ b/examples/multiple_windows/lib/app/dialog_window_edit_dialog.dart @@ -5,8 +5,8 @@ // ignore_for_file: invalid_use_of_internal_member // ignore_for_file: implementation_imports -import 'package:flutter/material.dart'; import 'package:flutter/src/widgets/_window.dart'; +import 'package:material_ui/material_ui.dart'; void showDialogWindowEditDialog({ required BuildContext context, diff --git a/examples/multiple_windows/lib/app/main_window.dart b/examples/multiple_windows/lib/app/main_window.dart index 988406e1dc6bb..40c023851e9c0 100644 --- a/examples/multiple_windows/lib/app/main_window.dart +++ b/examples/multiple_windows/lib/app/main_window.dart @@ -5,24 +5,24 @@ // ignore_for_file: invalid_use_of_internal_member // ignore_for_file: implementation_imports -import 'package:flutter/material.dart'; import 'package:flutter/src/widgets/_window.dart'; +import 'package:material_ui/material_ui.dart'; import 'dialog_window_content.dart'; import 'dialog_window_edit_dialog.dart'; import 'models.dart'; import 'popup_button.dart'; import 'popup_window_edit_dialog.dart'; -import 'regular_window_content.dart'; -import 'regular_window_edit_dialog.dart'; import 'tooltip_button.dart'; import 'tooltip_window_edit_dialog.dart'; +import 'window_content.dart'; +import 'window_edit_dialog.dart'; import 'window_settings_dialog.dart'; class MainWindow extends StatelessWidget { const MainWindow({super.key, required this.controller}); - final RegularWindowController controller; + final WindowController controller; @override Widget build(BuildContext context) { @@ -58,7 +58,7 @@ class MainWindow extends StatelessWidget { class _WindowsTable extends StatelessWidget { const _WindowsTable({required this.mainWindow}); - final RegularWindowController mainWindow; + final WindowController mainWindow; DataRow _buildRow(BaseWindowController controller, BuildContext context) { return DataRow( @@ -94,7 +94,7 @@ class _WindowsTable extends StatelessWidget { } List _buildRows(WindowRegistry windowRegistry, BuildContext context) { - final List rows = [_buildRow(mainWindow, context)]; + final List rows = []; for (final WindowEntry entry in windowRegistry.windows) { final BaseWindowController controller = entry.controller; rows.add(_buildRow(controller, context)); @@ -105,10 +105,7 @@ class _WindowsTable extends StatelessWidget { void _showWindowEditDialog(BaseWindowController controller, BuildContext context) { return switch (controller) { - final RegularWindowController regular => showRegularWindowEditDialog( - context: context, - controller: regular, - ), + final WindowController regular => showWindowEditDialog(context: context, controller: regular), final DialogWindowController dialog => showDialogWindowEditDialog( context: context, controller: dialog, @@ -127,7 +124,7 @@ class _WindowsTable extends StatelessWidget { static String _getWindowTypeName(BaseWindowController controller) { return switch (controller) { - RegularWindowController() => 'Regular', + WindowController() => 'Regular', DialogWindowController() => 'Dialog', TooltipWindowController() => 'Tooltip', PopupWindowController() => 'Popup', @@ -187,18 +184,18 @@ class _WindowCreatorCard extends StatelessWidget { OutlinedButton( onPressed: () { late final WindowEntry entry; - final RegularWindowController controller; - if (windowSettings.regularSizedToContent) { - controller = RegularWindowController.sizedToContent( + final WindowController controller; + if (windowSettings.shrinkWrap) { + controller = WindowController.shrinkWrap( resizable: windowSettings.regularResizable, - delegate: CallbackRegularWindowControllerDelegate( + delegate: CallbackWindowControllerDelegate( onDestroyed: () => windowRegistry.unregister(entry), ), title: 'Regular', ); } else { - controller = RegularWindowController( - delegate: CallbackRegularWindowControllerDelegate( + controller = WindowController( + delegate: CallbackWindowControllerDelegate( onDestroyed: () => windowRegistry.unregister(entry), ), title: 'Regular', @@ -209,7 +206,7 @@ class _WindowCreatorCard extends StatelessWidget { entry = WindowEntry( controller: controller, builder: (BuildContext context) => - RegularWindowContent(regularWindowController: controller), + WindowContent(windowController: controller), ); windowRegistry.register(entry); }, @@ -222,8 +219,8 @@ class _WindowCreatorCard extends StatelessWidget { onPressed: () { late final WindowEntry entry; final DialogWindowController controller; - if (windowSettings.dialogSizedToContent) { - controller = DialogWindowController.sizedToContent( + if (windowSettings.dialogShrinkWrap) { + controller = DialogWindowController.shrinkWrap( resizable: windowSettings.dialogResizable, delegate: CallbackDialogWindowControllerDelegate( onDestroyed: () => windowRegistry.unregister(entry), @@ -254,8 +251,8 @@ class _WindowCreatorCard extends StatelessWidget { onPressed: () { late final WindowEntry entry; final DialogWindowController controller; - if (windowSettings.dialogSizedToContent) { - controller = DialogWindowController.sizedToContent( + if (windowSettings.dialogShrinkWrap) { + controller = DialogWindowController.shrinkWrap( resizable: windowSettings.dialogResizable, delegate: CallbackDialogWindowControllerDelegate( onDestroyed: () => windowRegistry.unregister(entry), diff --git a/examples/multiple_windows/lib/app/models.dart b/examples/multiple_windows/lib/app/models.dart index 30d198d69e38a..7e481e778cd11 100644 --- a/examples/multiple_windows/lib/app/models.dart +++ b/examples/multiple_windows/lib/app/models.dart @@ -15,10 +15,10 @@ class TooltipSettings {} class WindowSettings { WindowSettings({ this.regularSize = const Size(800, 600), - this.regularSizedToContent = false, + this.shrinkWrap = false, this.regularResizable = true, this.dialogSize = const Size(400, 400), - this.dialogSizedToContent = false, + this.dialogShrinkWrap = false, this.dialogResizable = true, this.positioner = const WindowPositioner( parentAnchor: WindowPositionerAnchor.right, @@ -27,21 +27,21 @@ class WindowSettings { }); /// The initial size for newly created regular windows. - /// Ignored when [regularSizedToContent] is true. + /// Ignored when [shrinkWrap] is true. Size regularSize; /// If true, new regular windows will be sized to fit their content. - bool regularSizedToContent; + bool shrinkWrap; /// If true, regular windows may be manually resized by the user. bool regularResizable; /// The initial size of the dialog window. - /// Ignored when [dialogSizedToContent] is true. + /// Ignored when [dialogShrinkWrap] is true. Size dialogSize; /// If true, new dialog windows will be sized to fit their content. - bool dialogSizedToContent; + bool dialogShrinkWrap; /// If true, dialog windows may be manually resized by the user. bool dialogResizable; diff --git a/examples/multiple_windows/lib/app/popup_button.dart b/examples/multiple_windows/lib/app/popup_button.dart index 3f18cec5da5ad..426d7da70a168 100644 --- a/examples/multiple_windows/lib/app/popup_button.dart +++ b/examples/multiple_windows/lib/app/popup_button.dart @@ -5,8 +5,8 @@ // ignore_for_file: invalid_use_of_internal_member // ignore_for_file: implementation_imports -import 'package:flutter/material.dart'; import 'package:flutter/src/widgets/_window.dart'; +import 'package:material_ui/material_ui.dart'; import 'element_position_tracker.dart'; import 'models.dart'; @@ -32,7 +32,7 @@ class _PopupButtonState extends State { super.dispose(); } - void _onPressed(WindowRegistry windowRegistry, WindowSettings windowSettings) { + void _onPressed(WindowSettings windowSettings) { // Toggle popup visibility. if (_popupWindowEntry != null) { _popupWindowEntry!.controller.destroy(); @@ -50,7 +50,6 @@ class _PopupButtonState extends State { positioner: windowSettings.positioner, delegate: _PopupWindowControllerDelegate( onDestroyed: () { - windowRegistry.unregister(entry); tracker.dispose(); if (mounted) { setState(() { @@ -66,7 +65,6 @@ class _PopupButtonState extends State { controller: controller, builder: (BuildContext context) => PopupWindowContent(controller: controller), ); - windowRegistry.register(entry); tracker.onGlobalRectChange = (rect) { controller.updatePosition(anchorRect: rect); }; @@ -79,13 +77,20 @@ class _PopupButtonState extends State { @override Widget build(BuildContext context) { - final WindowRegistry windowManager = WindowRegistry.of(context); final WindowSettings windowSettings = WindowSettingsAccessor.of(context); return OutlinedButton( key: _popupButtonKey, - onPressed: () => _onPressed(windowManager, windowSettings), - child: Text(_popupWindowEntry != null ? 'Hide Popup' : 'Show Popup'), + onPressed: () => _onPressed(windowSettings), + child: ViewAnchor( + view: _popupWindowEntry != null + ? View( + view: _popupWindowEntry!.controller.rootView, + child: Builder(builder: _popupWindowEntry!.builder), + ) + : null, + child: Text(_popupWindowEntry != null ? 'Hide Popup' : 'Show Popup'), + ), ); } } diff --git a/examples/multiple_windows/lib/app/popup_window_content.dart b/examples/multiple_windows/lib/app/popup_window_content.dart index ab6e242c2ab6c..53769309cb293 100644 --- a/examples/multiple_windows/lib/app/popup_window_content.dart +++ b/examples/multiple_windows/lib/app/popup_window_content.dart @@ -5,8 +5,8 @@ // ignore_for_file: invalid_use_of_internal_member // ignore_for_file: implementation_imports -import 'package:flutter/material.dart'; import 'package:flutter/src/widgets/_window.dart'; +import 'package:material_ui/material_ui.dart'; class PopupWindowContent extends StatefulWidget { /// Creates a popup window widget. diff --git a/examples/multiple_windows/lib/app/popup_window_edit_dialog.dart b/examples/multiple_windows/lib/app/popup_window_edit_dialog.dart index 10bcd25350e13..a112bfaecda81 100644 --- a/examples/multiple_windows/lib/app/popup_window_edit_dialog.dart +++ b/examples/multiple_windows/lib/app/popup_window_edit_dialog.dart @@ -5,9 +5,9 @@ // ignore_for_file: invalid_use_of_internal_member // ignore_for_file: implementation_imports -import 'package:flutter/material.dart'; import 'package:flutter/src/widgets/_window.dart'; import 'package:flutter/src/widgets/_window_positioner.dart'; +import 'package:material_ui/material_ui.dart'; import 'models.dart'; void showPopupWindowEditDialog({ diff --git a/examples/multiple_windows/lib/app/rotated_wire_cube.dart b/examples/multiple_windows/lib/app/rotated_wire_cube.dart index ad50fe7c6cf3a..2c0ef80f65dc1 100644 --- a/examples/multiple_windows/lib/app/rotated_wire_cube.dart +++ b/examples/multiple_windows/lib/app/rotated_wire_cube.dart @@ -4,7 +4,7 @@ import 'dart:math'; -import 'package:flutter/material.dart'; +import 'package:material_ui/material_ui.dart'; import 'package:vector_math/vector_math_64.dart'; class RotatedWireCube extends StatefulWidget { diff --git a/examples/multiple_windows/lib/app/tooltip_button.dart b/examples/multiple_windows/lib/app/tooltip_button.dart index d2e5fa3674472..3f5af0c059ee7 100644 --- a/examples/multiple_windows/lib/app/tooltip_button.dart +++ b/examples/multiple_windows/lib/app/tooltip_button.dart @@ -5,8 +5,8 @@ // ignore_for_file: invalid_use_of_internal_member // ignore_for_file: implementation_imports -import 'package:flutter/material.dart'; import 'package:flutter/src/widgets/_window.dart'; +import 'package:material_ui/material_ui.dart'; import 'element_position_tracker.dart'; import 'models.dart'; @@ -32,7 +32,7 @@ class _TooltipButtonState extends State { super.dispose(); } - void _onPressed(WindowRegistry windowRegistry, WindowSettings windowSettings) { + void _onPressed(WindowSettings windowSettings) { // Toggle tooltip visibility. if (_tooltipEntry != null) { _tooltipEntry!.controller.destroy(); @@ -50,7 +50,6 @@ class _TooltipButtonState extends State { positioner: windowSettings.positioner, delegate: _TooltipWindowControllerDelegate( onDestroyed: () { - windowRegistry.unregister(entry); tracker.dispose(); if (mounted) { setState(() { @@ -66,7 +65,6 @@ class _TooltipButtonState extends State { controller: controller, builder: (BuildContext context) => TooltipWindowContent(controller: controller), ); - windowRegistry.register(entry); tracker.onGlobalRectChange = (rect) { controller.updatePosition(anchorRect: rect); }; @@ -79,13 +77,20 @@ class _TooltipButtonState extends State { @override Widget build(BuildContext context) { - final WindowRegistry windowManager = WindowRegistry.of(context); final WindowSettings windowSettings = WindowSettingsAccessor.of(context); return OutlinedButton( key: _tooltipButtonKey, - onPressed: () => _onPressed(windowManager, windowSettings), - child: Text(_tooltipEntry != null ? 'Hide Tooltip' : 'Show Tooltip'), + onPressed: () => _onPressed(windowSettings), + child: ViewAnchor( + view: _tooltipEntry != null + ? View( + view: _tooltipEntry!.controller.rootView, + child: Builder(builder: _tooltipEntry!.builder), + ) + : null, + child: Text(_tooltipEntry != null ? 'Hide Tooltip' : 'Show Tooltip'), + ), ); } } diff --git a/examples/multiple_windows/lib/app/tooltip_window_content.dart b/examples/multiple_windows/lib/app/tooltip_window_content.dart index 1202985b6b065..ee5639c73686e 100644 --- a/examples/multiple_windows/lib/app/tooltip_window_content.dart +++ b/examples/multiple_windows/lib/app/tooltip_window_content.dart @@ -7,8 +7,8 @@ import 'dart:io' show Platform; -import 'package:flutter/material.dart'; import 'package:flutter/src/widgets/_window.dart'; +import 'package:material_ui/material_ui.dart'; class TooltipWindowContent extends StatelessWidget { /// Creates a tooltip window widget. diff --git a/examples/multiple_windows/lib/app/tooltip_window_edit_dialog.dart b/examples/multiple_windows/lib/app/tooltip_window_edit_dialog.dart index 129bc3f725c47..fe74a87b4fbfc 100644 --- a/examples/multiple_windows/lib/app/tooltip_window_edit_dialog.dart +++ b/examples/multiple_windows/lib/app/tooltip_window_edit_dialog.dart @@ -5,9 +5,9 @@ // ignore_for_file: invalid_use_of_internal_member // ignore_for_file: implementation_imports -import 'package:flutter/material.dart'; import 'package:flutter/src/widgets/_window.dart'; import 'package:flutter/src/widgets/_window_positioner.dart'; +import 'package:material_ui/material_ui.dart'; import 'models.dart'; void showTooltipWindowEditDialog({ diff --git a/examples/multiple_windows/lib/app/regular_window_content.dart b/examples/multiple_windows/lib/app/window_content.dart similarity index 76% rename from examples/multiple_windows/lib/app/regular_window_content.dart rename to examples/multiple_windows/lib/app/window_content.dart index 7c71ec237fdad..7d79b1f0f4143 100644 --- a/examples/multiple_windows/lib/app/regular_window_content.dart +++ b/examples/multiple_windows/lib/app/window_content.dart @@ -7,8 +7,8 @@ import 'dart:math'; -import 'package:flutter/material.dart'; import 'package:flutter/src/widgets/_window.dart'; +import 'package:material_ui/material_ui.dart'; import 'dialog_window_content.dart'; import 'models.dart'; @@ -16,16 +16,16 @@ import 'popup_button.dart'; import 'rotated_wire_cube.dart'; import 'tooltip_button.dart'; -class RegularWindowContent extends StatefulWidget { - const RegularWindowContent({super.key, required this.regularWindowController}); +class WindowContent extends StatefulWidget { + const WindowContent({super.key, required this.windowController}); - final RegularWindowController regularWindowController; + final WindowController windowController; @override - State createState() => _RegularWindowContentState(); + State createState() => _WindowContentState(); } -class _RegularWindowContentState extends State { +class _WindowContentState extends State { late final Color cubeColor; static Color _generateRandomDarkColor() { @@ -49,9 +49,8 @@ class _RegularWindowContentState extends State { final double dpr = MediaQuery.of(context).devicePixelRatio; final Size windowSize = WindowScope.contentSizeOf(context); - return Overlay.wrap( - alwaysSizeToContent: true, - child: IntrinsicWidth( + return MaterialApp( + home: IntrinsicWidth( child: Material( child: Column( mainAxisSize: .min, @@ -71,15 +70,15 @@ class _RegularWindowContentState extends State { mainAxisSize: .min, children: [ _WindowCreationButtons( - regularWindowController: widget.regularWindowController, + windowController: widget.windowController, ), const SizedBox(height: 20), - TooltipButton(parentController: widget.regularWindowController), + TooltipButton(parentController: widget.windowController), const SizedBox(height: 20), - PopupButton(parentController: widget.regularWindowController), + PopupButton(parentController: widget.windowController), const SizedBox(height: 20), Text( - 'View #${widget.regularWindowController.rootView.viewId}\n' + 'View #${widget.windowController.rootView.viewId}\n' 'Size: ${windowSize.width.toStringAsFixed(1)}\u00D7${windowSize.height.toStringAsFixed(1)}\n' 'Device Pixel Ratio: $dpr', textAlign: TextAlign.center, @@ -99,11 +98,11 @@ class _RegularWindowContentState extends State { /// Extracted widget that depends on [WindowRegistry] so that registry changes /// (e.g. opening/closing windows) only rebuild these buttons, not the entire -/// [RegularWindowContent] tree. +/// [WindowContent] tree. class _WindowCreationButtons extends StatelessWidget { - const _WindowCreationButtons({required this.regularWindowController}); + const _WindowCreationButtons({required this.windowController}); - final RegularWindowController regularWindowController; + final WindowController windowController; @override Widget build(BuildContext context) { @@ -116,8 +115,8 @@ class _WindowCreationButtons extends StatelessWidget { ElevatedButton( onPressed: () { late final WindowEntry entry; - final controller = RegularWindowController( - delegate: CallbackRegularWindowControllerDelegate( + final controller = WindowController( + delegate: CallbackWindowControllerDelegate( onDestroyed: () => windowRegistry.unregister(entry), ), title: 'Regular', @@ -127,7 +126,7 @@ class _WindowCreationButtons extends StatelessWidget { entry = WindowEntry( controller: controller, builder: (BuildContext context) => - RegularWindowContent(regularWindowController: controller), + WindowContent(windowController: controller), ); windowRegistry.register(entry); }, @@ -143,7 +142,7 @@ class _WindowCreationButtons extends StatelessWidget { ), title: 'Modal Dialog', size: windowSettings.dialogSize, - parent: regularWindowController, + parent: windowController, ); entry = WindowEntry( @@ -160,8 +159,8 @@ class _WindowCreationButtons extends StatelessWidget { } } -class CallbackRegularWindowControllerDelegate with RegularWindowControllerDelegate { - CallbackRegularWindowControllerDelegate({required this.onDestroyed}); +class CallbackWindowControllerDelegate with WindowControllerDelegate { + CallbackWindowControllerDelegate({required this.onDestroyed}); @override void onWindowDestroyed() { diff --git a/examples/multiple_windows/lib/app/regular_window_edit_dialog.dart b/examples/multiple_windows/lib/app/window_edit_dialog.dart similarity index 90% rename from examples/multiple_windows/lib/app/regular_window_edit_dialog.dart rename to examples/multiple_windows/lib/app/window_edit_dialog.dart index 0232d30637539..996218788f1d4 100644 --- a/examples/multiple_windows/lib/app/regular_window_edit_dialog.dart +++ b/examples/multiple_windows/lib/app/window_edit_dialog.dart @@ -5,31 +5,31 @@ // ignore_for_file: invalid_use_of_internal_member // ignore_for_file: implementation_imports -import 'package:flutter/material.dart'; import 'package:flutter/src/widgets/_window.dart'; +import 'package:material_ui/material_ui.dart'; -void showRegularWindowEditDialog({ +void showWindowEditDialog({ required BuildContext context, - required RegularWindowController controller, + required WindowController controller, }) { showDialog( context: context, builder: (context) => - _RegularWindowEditDialog(controller: controller, onClose: () => Navigator.pop(context)), + _WindowEditDialog(controller: controller, onClose: () => Navigator.pop(context)), ); } -class _RegularWindowEditDialog extends StatefulWidget { - const _RegularWindowEditDialog({required this.controller, required this.onClose}); +class _WindowEditDialog extends StatefulWidget { + const _WindowEditDialog({required this.controller, required this.onClose}); - final RegularWindowController controller; + final WindowController controller; final VoidCallback onClose; @override - State createState() => _RegularWindowEditDialogState(); + State createState() => _WindowEditDialogState(); } -class _RegularWindowEditDialogState extends State<_RegularWindowEditDialog> { +class _WindowEditDialogState extends State<_WindowEditDialog> { late Size initialSize; late String initialTitle; late bool initialFullscreen; @@ -67,7 +67,7 @@ class _RegularWindowEditDialogState extends State<_RegularWindowEditDialog> { } @override - void didUpdateWidget(covariant _RegularWindowEditDialog oldWidget) { + void didUpdateWidget(covariant _WindowEditDialog oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.controller != widget.controller) { oldWidget.controller.removeListener(_onNotification); diff --git a/examples/multiple_windows/lib/app/window_settings_dialog.dart b/examples/multiple_windows/lib/app/window_settings_dialog.dart index bec23eda95e47..d32803604bf0a 100644 --- a/examples/multiple_windows/lib/app/window_settings_dialog.dart +++ b/examples/multiple_windows/lib/app/window_settings_dialog.dart @@ -4,9 +4,9 @@ // ignore_for_file: invalid_use_of_internal_member -import 'package:flutter/material.dart'; // ignore: implementation_imports import 'package:flutter/src/widgets/_window_positioner.dart'; +import 'package:material_ui/material_ui.dart'; import 'models.dart'; @@ -40,9 +40,9 @@ class _WindowSettingsEditorState extends State<_WindowSettingsEditor> { final TextEditingController _offsetDxController = TextEditingController(); final TextEditingController _offsetDyController = TextEditingController(); - late bool _regularSizedToContent; + late bool _regularShrinkWrap; late bool _regularResizable; - late bool _dialogSizedToContent; + late bool _dialogShrinkWrap; late bool _dialogResizable; late bool _flipX; @@ -67,9 +67,9 @@ class _WindowSettingsEditorState extends State<_WindowSettingsEditor> { _dialogHeightController.addListener(_updateDialogSize); _dialogWidthController.text = widget.settings.dialogSize.width.toString(); _dialogHeightController.text = widget.settings.dialogSize.height.toString(); - _regularSizedToContent = widget.settings.regularSizedToContent; + _regularShrinkWrap = widget.settings.shrinkWrap; _regularResizable = widget.settings.regularResizable; - _dialogSizedToContent = widget.settings.dialogSizedToContent; + _dialogShrinkWrap = widget.settings.dialogShrinkWrap; _dialogResizable = widget.settings.dialogResizable; _offsetDxController.text = widget.settings.positioner.offset.dx.toString(); _offsetDyController.text = widget.settings.positioner.offset.dy.toString(); @@ -127,7 +127,7 @@ class _WindowSettingsEditorState extends State<_WindowSettingsEditor> { child: TextFormField( controller: _regularWidthController, decoration: const InputDecoration(labelText: 'Initial width'), - enabled: !_regularSizedToContent, + enabled: !_regularShrinkWrap, ), ), const SizedBox(width: 20), @@ -135,7 +135,7 @@ class _WindowSettingsEditorState extends State<_WindowSettingsEditor> { child: TextFormField( controller: _regularHeightController, decoration: const InputDecoration(labelText: 'Initial height'), - enabled: !_regularSizedToContent, + enabled: !_regularShrinkWrap, ), ), ], @@ -145,8 +145,8 @@ class _WindowSettingsEditorState extends State<_WindowSettingsEditor> { children: [ const SizedBox(width: 100, child: Text('Sized to content')), Switch( - value: _regularSizedToContent, - onChanged: (bool value) => setState(() => _regularSizedToContent = value), + value: _regularShrinkWrap, + onChanged: (bool value) => setState(() => _regularShrinkWrap = value), ), const SizedBox(width: 24), const SizedBox(width: 70, child: Text('Resizable')), @@ -173,7 +173,7 @@ class _WindowSettingsEditorState extends State<_WindowSettingsEditor> { child: TextFormField( controller: _dialogWidthController, decoration: const InputDecoration(labelText: 'Initial width'), - enabled: !_dialogSizedToContent, + enabled: !_dialogShrinkWrap, ), ), const SizedBox(width: 20), @@ -181,7 +181,7 @@ class _WindowSettingsEditorState extends State<_WindowSettingsEditor> { child: TextFormField( controller: _dialogHeightController, decoration: const InputDecoration(labelText: 'Initial height'), - enabled: !_dialogSizedToContent, + enabled: !_dialogShrinkWrap, ), ), ], @@ -191,8 +191,8 @@ class _WindowSettingsEditorState extends State<_WindowSettingsEditor> { children: [ const SizedBox(width: 100, child: Text('Sized to content')), Switch( - value: _dialogSizedToContent, - onChanged: (bool value) => setState(() => _dialogSizedToContent = value), + value: _dialogShrinkWrap, + onChanged: (bool value) => setState(() => _dialogShrinkWrap = value), ), const SizedBox(width: 24), const SizedBox(width: 70, child: Text('Resizable')), @@ -383,13 +383,13 @@ class _WindowSettingsEditorState extends State<_WindowSettingsEditor> { double.tryParse(_regularHeightController.text) ?? widget.settings.regularSize.height, ); - widget.settings.regularSizedToContent = _regularSizedToContent; + widget.settings.shrinkWrap = _regularShrinkWrap; widget.settings.regularResizable = _regularResizable; widget.settings.dialogSize = Size( double.tryParse(_dialogWidthController.text) ?? widget.settings.dialogSize.width, double.tryParse(_dialogHeightController.text) ?? widget.settings.dialogSize.height, ); - widget.settings.dialogSizedToContent = _dialogSizedToContent; + widget.settings.dialogShrinkWrap = _dialogShrinkWrap; widget.settings.dialogResizable = _dialogResizable; widget.settings.positioner = widget.settings.positioner.copyWith( diff --git a/examples/multiple_windows/lib/main.dart b/examples/multiple_windows/lib/main.dart index d62ecfe21e9b9..3b698a56260d8 100644 --- a/examples/multiple_windows/lib/main.dart +++ b/examples/multiple_windows/lib/main.dart @@ -7,14 +7,14 @@ import 'dart:ui'; -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter/src/widgets/_window.dart'; +import 'package:material_ui/material_ui.dart'; import 'app/main_window.dart'; import 'app/models.dart'; -class MainControllerWindowDelegate with RegularWindowControllerDelegate { +class MainControllerWindowDelegate with WindowControllerDelegate { @override void onWindowDestroyed() { super.onWindowDestroyed(); @@ -35,7 +35,7 @@ class MultiWindowApp extends StatefulWidget { } class _MultiWindowAppState extends State { - final RegularWindowController controller = RegularWindowController( + final WindowController controller = WindowController( size: const Size(800, 600), title: 'Multi-Window Reference Application', delegate: MainControllerWindowDelegate(), @@ -52,9 +52,13 @@ class _MultiWindowAppState extends State { Widget build(BuildContext context) { return WindowSettingsAccessor( windowSettings: settings, - child: RegularWindow( - controller: controller, - child: MaterialApp(home: MainWindow(controller: controller)), + child: WindowManager( + initialWindows: [ + WindowEntry( + controller: controller, + builder: (context) => MaterialApp(home: MainWindow(controller: controller)), + ), + ], ), ); } diff --git a/examples/multiple_windows/pubspec.yaml b/examples/multiple_windows/pubspec.yaml index f0a6a2771a213..9cf00a98ec67c 100644 --- a/examples/multiple_windows/pubspec.yaml +++ b/examples/multiple_windows/pubspec.yaml @@ -6,6 +6,7 @@ environment: dependencies: flutter: sdk: flutter + material_ui: ^0.0.2 vector_math: ^2.2.0 dev_dependencies: @@ -16,4 +17,4 @@ dev_dependencies: flutter: uses-material-design: true -# PUBSPEC CHECKSUM: dpf2lg +# PUBSPEC CHECKSUM: mej66b diff --git a/examples/multiple_windows/test/multiple_windows_test.dart b/examples/multiple_windows/test/multiple_windows_test.dart index 5c279a9128739..fb7608aa18159 100644 --- a/examples/multiple_windows/test/multiple_windows_test.dart +++ b/examples/multiple_windows/test/multiple_windows_test.dart @@ -4,9 +4,9 @@ // ignore_for_file: invalid_use_of_internal_member -import 'package:flutter/material.dart'; import 'package:flutter/src/foundation/_features.dart' show isWindowingEnabled; import 'package:flutter_test/flutter_test.dart'; +import 'package:material_ui/material_ui.dart'; // ignore: avoid_relative_lib_imports import '../lib/main.dart' as multiple_windows; diff --git a/examples/platform_channel/lib/main.dart b/examples/platform_channel/lib/main.dart index 0c057ab13b164..9a47720b6aa5e 100644 --- a/examples/platform_channel/lib/main.dart +++ b/examples/platform_channel/lib/main.dart @@ -4,8 +4,8 @@ import 'dart:async'; -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:material_ui/material_ui.dart'; class PlatformChannel extends StatefulWidget { const PlatformChannel({super.key}); diff --git a/examples/platform_channel/pubspec.yaml b/examples/platform_channel/pubspec.yaml index c3a0462d9efa1..a52af88d2b27e 100644 --- a/examples/platform_channel/pubspec.yaml +++ b/examples/platform_channel/pubspec.yaml @@ -8,6 +8,7 @@ resolution: workspace dependencies: flutter: sdk: flutter + material_ui: ^0.0.2 dev_dependencies: @@ -21,4 +22,4 @@ dev_dependencies: flutter: uses-material-design: true -# PUBSPEC CHECKSUM: f9g2jl +# PUBSPEC CHECKSUM: kujv36 diff --git a/examples/platform_channel_swift/lib/main.dart b/examples/platform_channel_swift/lib/main.dart index febf360c2cbad..c8d12af8e7815 100644 --- a/examples/platform_channel_swift/lib/main.dart +++ b/examples/platform_channel_swift/lib/main.dart @@ -4,8 +4,8 @@ import 'dart:async'; -import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:material_ui/material_ui.dart'; class PlatformChannel extends StatefulWidget { const PlatformChannel({super.key}); diff --git a/examples/platform_channel_swift/pubspec.yaml b/examples/platform_channel_swift/pubspec.yaml index 3888319e33f96..e7ec8dec2ae46 100644 --- a/examples/platform_channel_swift/pubspec.yaml +++ b/examples/platform_channel_swift/pubspec.yaml @@ -8,6 +8,7 @@ resolution: workspace dependencies: flutter: sdk: flutter + material_ui: ^0.0.2 dev_dependencies: @@ -21,4 +22,4 @@ dev_dependencies: flutter: uses-material-design: true -# PUBSPEC CHECKSUM: f9g2jl +# PUBSPEC CHECKSUM: kujv36 diff --git a/packages/flutter/lib/src/painting/borders.dart b/packages/flutter/lib/src/painting/borders.dart index 1d6a33a58eb30..a228ee491c7d5 100644 --- a/packages/flutter/lib/src/painting/borders.dart +++ b/packages/flutter/lib/src/painting/borders.dart @@ -562,6 +562,24 @@ abstract class ShapeBorder { /// * [Path.contains], which can tell if an [Offset] is within a [Path]. Path getInnerPath(Rect rect, {TextDirection? textDirection}); + /// Tests whether the outer boundary of this border contains [position]. + /// + /// The [position] must be in the same coordinate space as [rect]. The default + /// implementation checks [position] against the path returned by + /// [getOuterPath]. + /// + /// The `textDirection` argument must be provided and non-null if the border + /// has a text direction dependency. It may be null if the border will not need + /// the text direction to describe its geometry. + /// + /// See also: + /// + /// * [getOuterPath], which creates the path for the outer edge. + /// * [ShapeDecoration.hitTest], which delegates to this method. + bool hitTest(Rect rect, Offset position, {TextDirection? textDirection}) { + return getOuterPath(rect, textDirection: textDirection).contains(position); + } + /// Paint a canvas with the appropriate shape. /// /// On [ShapeBorder] subclasses whose [preferPaintInterior] method returns @@ -825,6 +843,11 @@ class _CompoundBorder extends ShapeBorder { return borders.first.getOuterPath(rect, textDirection: textDirection); } + @override + bool hitTest(Rect rect, Offset position, {TextDirection? textDirection}) { + return borders.first.hitTest(rect, position, textDirection: textDirection); + } + @override void paintInterior(Canvas canvas, Rect rect, Paint paint, {TextDirection? textDirection}) { borders.first.paintInterior(canvas, rect, paint, textDirection: textDirection); diff --git a/packages/flutter/lib/src/painting/box_border.dart b/packages/flutter/lib/src/painting/box_border.dart index 425e6d745be55..2ae1e905767dd 100644 --- a/packages/flutter/lib/src/painting/box_border.dart +++ b/packages/flutter/lib/src/painting/box_border.dart @@ -227,6 +227,11 @@ abstract class BoxBorder extends ShapeBorder { return Path()..addRect(rect); } + @override + bool hitTest(Rect rect, Offset position, {TextDirection? textDirection}) { + return rect.contains(position); + } + @override void paintInterior(Canvas canvas, Rect rect, Paint paint, {TextDirection? textDirection}) { // For `ShapeDecoration(shape: Border.all())`, a rectangle with sharp edges diff --git a/packages/flutter/lib/src/painting/box_decoration.dart b/packages/flutter/lib/src/painting/box_decoration.dart index ad12ffe4971e2..a9ca00e2c7408 100644 --- a/packages/flutter/lib/src/painting/box_decoration.dart +++ b/packages/flutter/lib/src/painting/box_decoration.dart @@ -384,8 +384,9 @@ class BoxDecoration extends Decoration { case BoxShape.circle: // Circles are inscribed into our smallest dimension. final Offset center = size.center(Offset.zero); - final double distance = (position - center).distance; - return distance <= math.min(size.width, size.height) / 2.0; + final double radius = math.min(size.width, size.height) / 2.0; + // Comparing squared distances avoids computing sqrt(dx * dx + dy * dy). + return (position - center).distanceSquared <= radius * radius; } } diff --git a/packages/flutter/lib/src/painting/circle_border.dart b/packages/flutter/lib/src/painting/circle_border.dart index 2f02344953669..e5c5d55b8c638 100644 --- a/packages/flutter/lib/src/painting/circle_border.dart +++ b/packages/flutter/lib/src/painting/circle_border.dart @@ -87,6 +87,15 @@ class CircleBorder extends OutlinedBorder { return Path()..addOval(_adjustRect(rect)); } + @override + bool hitTest(Rect rect, Offset position, {TextDirection? textDirection}) { + final Rect adjustedRect = _adjustRect(rect); + return RRect.fromRectAndRadius( + adjustedRect, + Radius.elliptical(adjustedRect.width / 2.0, adjustedRect.height / 2.0), + ).contains(position); + } + @override void paintInterior(Canvas canvas, Rect rect, Paint paint, {TextDirection? textDirection}) { if (eccentricity == 0.0) { diff --git a/packages/flutter/lib/src/painting/rounded_rectangle_border.dart b/packages/flutter/lib/src/painting/rounded_rectangle_border.dart index 7cdcd77b91e8b..045f84b2fdb2d 100644 --- a/packages/flutter/lib/src/painting/rounded_rectangle_border.dart +++ b/packages/flutter/lib/src/painting/rounded_rectangle_border.dart @@ -108,6 +108,15 @@ class RoundedRectangleBorder extends OutlinedBorder with _RRectLikeBorder { return Path()..addRRect(borderRadius.resolve(textDirection).toRRect(rect)); } + @override + bool hitTest(Rect rect, Offset position, {TextDirection? textDirection}) { + final BorderRadius resolvedBorderRadius = borderRadius.resolve(textDirection); + if (resolvedBorderRadius == BorderRadius.zero) { + return rect.contains(position); + } + return resolvedBorderRadius.toRRect(rect).contains(position); + } + @override void paintInterior(Canvas canvas, Rect rect, Paint paint, {TextDirection? textDirection}) { if (borderRadius == BorderRadius.zero) { @@ -183,6 +192,11 @@ class _RoundedRectangleToCircleBorder extends _ShapeToCircleBorder extends Outlined void drawShape(Canvas canvas, Rect rect, BorderRadius radius, Paint paint, [double? inflation]); Path buildPath(Rect rect, BorderRadius radius, [double? inflation]); + bool containsOuterShape(Rect rect, BorderRadius radius, Offset position); final BorderRadiusGeometry borderRadius; final double circularity; @@ -544,6 +573,16 @@ abstract class _ShapeToCircleBorder extends Outlined return buildPath(_adjustRect(rect), _adjustBorderRadius(rect, textDirection)); } + @override + bool hitTest(Rect rect, Offset position, {TextDirection? textDirection}) { + final Rect adjustedRect = _adjustRect(rect); + final BorderRadius adjustedBorderRadius = _adjustBorderRadius(rect, textDirection); + if (adjustedBorderRadius == BorderRadius.zero) { + return adjustedRect.contains(position); + } + return containsOuterShape(adjustedRect, adjustedBorderRadius, position); + } + @override void paintInterior(Canvas canvas, Rect rect, Paint paint, {TextDirection? textDirection}) { final BorderRadius adjustedBorderRadius = _adjustBorderRadius(rect, textDirection); diff --git a/packages/flutter/lib/src/painting/shape_decoration.dart b/packages/flutter/lib/src/painting/shape_decoration.dart index 5f0a66c051344..ec8b9a51e4bf6 100644 --- a/packages/flutter/lib/src/painting/shape_decoration.dart +++ b/packages/flutter/lib/src/painting/shape_decoration.dart @@ -294,7 +294,7 @@ class ShapeDecoration extends Decoration { @override bool hitTest(Size size, Offset position, {TextDirection? textDirection}) { - return shape.getOuterPath(Offset.zero & size, textDirection: textDirection).contains(position); + return shape.hitTest(Offset.zero & size, position, textDirection: textDirection); } @override diff --git a/packages/flutter/lib/src/painting/stadium_border.dart b/packages/flutter/lib/src/painting/stadium_border.dart index 1821c2d6f8e9b..daba29c75a13a 100644 --- a/packages/flutter/lib/src/painting/stadium_border.dart +++ b/packages/flutter/lib/src/painting/stadium_border.dart @@ -96,6 +96,12 @@ class StadiumBorder extends OutlinedBorder { return Path()..addRRect(RRect.fromRectAndRadius(rect, radius)); } + @override + bool hitTest(Rect rect, Offset position, {TextDirection? textDirection}) { + final radius = Radius.circular(rect.shortestSide / 2.0); + return RRect.fromRectAndRadius(rect, radius).contains(position); + } + @override void paintInterior(Canvas canvas, Rect rect, Paint paint, {TextDirection? textDirection}) { final radius = Radius.circular(rect.shortestSide / 2.0); @@ -248,6 +254,11 @@ class _StadiumToCircleBorder extends OutlinedBorder { return Path()..addRRect(_adjustBorderRadius(rect).toRRect(_adjustRect(rect))); } + @override + bool hitTest(Rect rect, Offset position, {TextDirection? textDirection}) { + return _adjustBorderRadius(rect).toRRect(_adjustRect(rect)).contains(position); + } + @override void paintInterior(Canvas canvas, Rect rect, Paint paint, {TextDirection? textDirection}) { canvas.drawRRect(_adjustBorderRadius(rect).toRRect(_adjustRect(rect)), paint); @@ -391,6 +402,15 @@ class _StadiumToRoundedRectangleBorder extends OutlinedBorder { return Path()..addRRect(_adjustBorderRadius(rect).resolve(textDirection).toRRect(rect)); } + @override + bool hitTest(Rect rect, Offset position, {TextDirection? textDirection}) { + final BorderRadius adjustedBorderRadius = _adjustBorderRadius(rect).resolve(textDirection); + if (adjustedBorderRadius == BorderRadius.zero) { + return rect.contains(position); + } + return adjustedBorderRadius.toRRect(rect).contains(position); + } + @override void paintInterior(Canvas canvas, Rect rect, Paint paint, {TextDirection? textDirection}) { final BorderRadiusGeometry adjustedBorderRadius = _adjustBorderRadius(rect); diff --git a/packages/flutter/lib/src/rendering/proxy_box.dart b/packages/flutter/lib/src/rendering/proxy_box.dart index 18b5949ddb342..e47a18c9a668f 100644 --- a/packages/flutter/lib/src/rendering/proxy_box.dart +++ b/packages/flutter/lib/src/rendering/proxy_box.dart @@ -102,8 +102,10 @@ mixin RenderProxyBoxMixin on RenderBox, RenderObjectWithChi @override @protected double? computeDryBaseline(covariant BoxConstraints constraints, TextBaseline baseline) { - final double? result = child?.getDryBaseline(constraints, baseline); - return result ?? super.computeDryBaseline(constraints, baseline); + final RenderBox? child = this.child; + return child == null + ? super.computeDryBaseline(constraints, baseline) + : child.getDryBaseline(constraints, baseline); } @override @@ -2210,7 +2212,6 @@ class RenderPhysicalModel extends _RenderPhysicalModelBase { _updateClip(); final RRect offsetRRect = _clip!.shift(offset); - final offsetRRectAsPath = Path()..addRRect(offsetRRect); var paintShadows = true; assert(() { if (debugDisableShadows) { @@ -2230,6 +2231,7 @@ class RenderPhysicalModel extends _RenderPhysicalModelBase { final Canvas canvas = context.canvas; if (elevation != 0.0 && paintShadows) { + final offsetRRectAsPath = Path()..addRRect(offsetRRect); canvas.drawShadow(offsetRRectAsPath, shadowColor, elevation, color.alpha != 0xFF); } final usesSaveLayer = clipBehavior == Clip.antiAliasWithSaveLayer; diff --git a/packages/flutter/lib/src/rendering/sliver_tree.dart b/packages/flutter/lib/src/rendering/sliver_tree.dart index 5edb2a4b07aef..d51c68dd8851d 100644 --- a/packages/flutter/lib/src/rendering/sliver_tree.dart +++ b/packages/flutter/lib/src/rendering/sliver_tree.dart @@ -371,10 +371,14 @@ class RenderTreeSliver extends RenderSliverVariedExtentList { // leadingIndex), and the trailing edge of the trailing index. We cannot // rely on the leading edge of the leading index, because it is currently // moving. + // + // parentIndex is always a real animating node (the unclipped first + // segment is already painted), so its extent is added even when it is + // index 0. Otherwise the clip starts at the parent's leading edge and its + // children paint over it. final int parentIndex = math.max(segment.leadingIndex - 1, 0); final double leadingOffset = - indexToLayoutOffset(0.0, parentIndex) + - (parentIndex == 0 ? 0.0 : itemExtentBuilder(parentIndex, layoutDimensions)!); + indexToLayoutOffset(0.0, parentIndex) + itemExtentBuilder(parentIndex, layoutDimensions)!; final double trailingOffset = indexToLayoutOffset(0.0, segment.trailingIndex) + itemExtentBuilder(segment.trailingIndex, layoutDimensions)!; diff --git a/packages/flutter/lib/src/widgets/_platform_selectable_region_context_menu_io.dart b/packages/flutter/lib/src/widgets/_platform_selectable_region_context_menu_io.dart index b1035f3460d1e..e0bba3393a00d 100644 --- a/packages/flutter/lib/src/widgets/_platform_selectable_region_context_menu_io.dart +++ b/packages/flutter/lib/src/widgets/_platform_selectable_region_context_menu_io.dart @@ -40,6 +40,12 @@ class PlatformSelectableRegionContextMenu extends StatelessWidget { /// Detaches the `client` from the platform-appropriate selection context menus. static void detach(SelectionContainerDelegate client) => throw UnimplementedError(); + /// The client currently attached to the [PlatformSelectableRegionContextMenu]. + /// + /// This should only be used for testing. + @visibleForTesting + static SelectionContainerDelegate? get debugActiveClient => throw UnimplementedError(); + /// Override this to provide a custom implementation of `ui_web.platformViewRegistry.registerViewFactory`. /// /// This should only be used for testing. diff --git a/packages/flutter/lib/src/widgets/_platform_selectable_region_context_menu_web.dart b/packages/flutter/lib/src/widgets/_platform_selectable_region_context_menu_web.dart index 5965ac0c506a7..0883dcdc6a312 100644 --- a/packages/flutter/lib/src/widgets/_platform_selectable_region_context_menu_web.dart +++ b/packages/flutter/lib/src/widgets/_platform_selectable_region_context_menu_web.dart @@ -56,13 +56,19 @@ class PlatformSelectableRegionContextMenu extends StatelessWidget { /// See `_platform_selectable_region_context_menu_io.dart`. static void detach(SelectionContainerDelegate client) { - if (_activeClient != client) { + if (_activeClient == client) { _activeClient = null; } } static SelectionContainerDelegate? _activeClient; + /// The client currently attached to the [PlatformSelectableRegionContextMenu]. + /// + /// This should only be used for testing. + @visibleForTesting + static SelectionContainerDelegate? get debugActiveClient => _activeClient; + // Keeps track if this widget has already registered its view factories or not. static String? _registeredViewType; diff --git a/packages/flutter/lib/src/widgets/_window.dart b/packages/flutter/lib/src/widgets/_window.dart index 89fc13045eeb3..c7a1f8833f784 100644 --- a/packages/flutter/lib/src/widgets/_window.dart +++ b/packages/flutter/lib/src/widgets/_window.dart @@ -61,7 +61,7 @@ See: https://github.com/flutter/flutter/issues/30701. /// /// See also: /// -/// * [RegularWindowController], the controller for regular top-level windows. +/// * [WindowController], the controller for regular top-level windows. @internal sealed class BaseWindowController extends ChangeNotifier { /// The current size of the drawable area of the window. @@ -111,10 +111,10 @@ sealed class BaseWindowController extends ChangeNotifier { /// /// See also: /// -/// * [RegularWindowController], the controller that creates and manages regular windows. -/// * [RegularWindow], the widget for a regular window. +/// * [WindowController], the controller that creates and manages regular windows. +/// * [Window], the widget for a regular window. @internal -mixin class RegularWindowControllerDelegate { +mixin class WindowControllerDelegate { /// Invoked when the user attempts to close the window. /// /// The default implementation destroys the window. Subclasses @@ -126,7 +126,7 @@ mixin class RegularWindowControllerDelegate { /// /// * [onWindowDestroyed], which is invoked after the window is closed. @internal - void onWindowCloseRequested(RegularWindowController controller) { + void onWindowCloseRequested(WindowController controller) { if (!isWindowingEnabled) { throw UnsupportedError(_kWindowingDisabledErrorMessage); } @@ -156,7 +156,7 @@ mixin class RegularWindowControllerDelegate { /// platform with the provided properties. /// /// This class does not interact with the widget tree. Instead, it is typically -/// provided to the [RegularWindow] widget, who does the work of rendering the +/// provided to the [Window] widget, who does the work of rendering the /// content inside of this window. /// /// The user of this class is responsible for managing the lifecycle of the window. @@ -176,8 +176,8 @@ mixin class RegularWindowControllerDelegate { /// /// void main() { /// runWidget( -/// RegularWindow( -/// controller: RegularWindowController( +/// Window( +/// controller: WindowController( /// size: const Size(800, 600), /// constraints: const BoxConstraints(minWidth: 640, minHeight: 480), /// title: 'Example Window', @@ -189,13 +189,13 @@ mixin class RegularWindowControllerDelegate { /// ``` /// {@end-tool} /// -/// Children of a [RegularWindow] widget can access the [RegularWindowController] +/// Children of a [Window] widget can access the [WindowController] /// via the [WindowScope] inherited widget. /// /// {@macro flutter.widgets.windowing.experimental} @internal -abstract class RegularWindowController extends BaseWindowController { - /// Creates a [RegularWindowController] with a specific size. +abstract class WindowController extends BaseWindowController { + /// Creates a [WindowController] with a specific size. /// /// Upon construction, the window is created by the platform with the /// given [size]. @@ -217,7 +217,7 @@ abstract class RegularWindowController extends BaseWindowController { /// {@endtemplate} /// /// To create a window that is sized to its content instead, use - /// [RegularWindowController.sizedToContent]. + /// [WindowController.shrinkWrap]. /// /// {@template flutter.widgets.windowing.shared} /// The [title] argument configures the window's title. @@ -230,11 +230,11 @@ abstract class RegularWindowController extends BaseWindowController { /// /// {@macro flutter.widgets.windowing.experimental} @internal - factory RegularWindowController({ + factory WindowController({ required Size size, BoxConstraints? constraints, String? title, - RegularWindowControllerDelegate? delegate, + WindowControllerDelegate? delegate, }) { if (!isWindowingEnabled) { throw UnsupportedError(_kWindowingDisabledErrorMessage); @@ -245,8 +245,8 @@ abstract class RegularWindowController extends BaseWindowController { } final WindowingOwner owner = WidgetsBinding.instance.windowingOwner; - return owner.createRegularWindowController( - delegate: delegate ?? RegularWindowControllerDelegate(), + return owner.createWindowController( + delegate: delegate ?? WindowControllerDelegate(), size: size, constraints: constraints, title: title, @@ -254,9 +254,9 @@ abstract class RegularWindowController extends BaseWindowController { ); } - /// Creates a [RegularWindowController] that sizes the window to its content. + /// Creates a [WindowController] that sizes the window to its content. /// - /// {@template flutter.widgets.windowing.sizedToContentConstructor} + /// {@template flutter.widgets.windowing.shrinkWrapConstructor} /// The window is created by the platform and initially /// sized to fit its content. /// @@ -279,17 +279,17 @@ abstract class RegularWindowController extends BaseWindowController { /// {@endtemplate} /// /// To create a window with a specific size instead, use the default - /// [RegularWindowController] constructor. + /// [WindowController] constructor. /// /// {@macro flutter.widgets.windowing.shared} /// /// {@macro flutter.widgets.windowing.experimental} @internal - factory RegularWindowController.sizedToContent({ + factory WindowController.shrinkWrap({ bool resizable = false, BoxConstraints? constraints, String? title, - RegularWindowControllerDelegate? delegate, + WindowControllerDelegate? delegate, }) { if (!isWindowingEnabled) { throw UnsupportedError(_kWindowingDisabledErrorMessage); @@ -298,27 +298,27 @@ abstract class RegularWindowController extends BaseWindowController { WidgetsFlutterBinding.ensureInitialized(); final WindowingOwner owner = WidgetsBinding.instance.windowingOwner; - return owner.createRegularWindowController( - delegate: delegate ?? RegularWindowControllerDelegate(), + return owner.createWindowController( + delegate: delegate ?? WindowControllerDelegate(), constraints: constraints, resizable: resizable, title: title, ); } - /// Creates an empty [RegularWindowController]. + /// Creates an empty [WindowController]. /// /// This method is only intended to be used by subclasses of the - /// [RegularWindowController]. + /// [WindowController]. /// - /// Users who want to instantiate a new [RegularWindowController] should + /// Users who want to instantiate a new [WindowController] should /// always use the factory method to create a controller that is valid /// for their particular platform. /// /// {@macro flutter.widgets.windowing.experimental} @internal @protected - RegularWindowController.empty(); + WindowController.empty(); /// The current title of the window. /// @@ -445,7 +445,7 @@ abstract class RegularWindowController extends BaseWindowController { /// /// * [DialogWindowController], the controller that creates and manages dialog windows. /// * [DialogWindow], the widget for a dialog window. -/// * [RegularWindowControllerDelegate], the delegate for regular window controllers. +/// * [WindowControllerDelegate], the delegate for regular window controllers. @internal mixin class DialogWindowControllerDelegate { /// Invoked when the user attempts to close the window. @@ -511,8 +511,8 @@ mixin class DialogWindowControllerDelegate { /// /// void main() { /// runWidget( -/// RegularWindow( -/// controller: RegularWindowController( +/// Window( +/// controller: WindowController( /// size: const Size(800, 600), /// constraints: const BoxConstraints(minWidth: 640, minHeight: 480), /// title: 'Example Window', @@ -555,7 +555,7 @@ abstract class DialogWindowController extends BaseWindowController { /// {@macro flutter.widgets.windowing.sizedConstructor} /// /// To create a dialog that is sized to its content instead, use - /// [DialogWindowController.sizedToContent]. + /// [DialogWindowController.shrinkWrap]. /// /// {@template flutter.widgets.windowing.dialogParent} /// The [parent] argument specifies the parent window of this dialog. @@ -601,7 +601,7 @@ abstract class DialogWindowController extends BaseWindowController { /// Creates a [DialogWindowController] that sizes the window to its content. /// - /// {@macro flutter.widgets.windowing.sizedToContentConstructor} + /// {@macro flutter.widgets.windowing.shrinkWrapConstructor} /// /// To create a dialog with a specific size instead, use the default /// [DialogWindowController] constructor. @@ -611,7 +611,7 @@ abstract class DialogWindowController extends BaseWindowController { /// {@macro flutter.widgets.windowing.shared} /// /// {@macro flutter.widgets.windowing.experimental} - factory DialogWindowController.sizedToContent({ + factory DialogWindowController.shrinkWrap({ bool resizable = false, BoxConstraints? constraints, BaseWindowController? parent, @@ -738,7 +738,7 @@ abstract class DialogWindowController extends BaseWindowController { /// /// * [TooltipWindowController], the controller that creates and manages tooltip windows. /// * [TooltipWindow], the widget for a tooltip window. -/// * [RegularWindowControllerDelegate], the delegate for regular window controllers. +/// * [WindowControllerDelegate], the delegate for regular window controllers. mixin class TooltipWindowControllerDelegate { /// Invoked after the window is closed. /// @@ -883,7 +883,7 @@ abstract class TooltipWindowController extends BaseWindowController { /// /// * [PopupWindowController], the controller that creates and manages popup windows. /// * [PopupWindow], the widget for a popup window. -/// * [RegularWindowControllerDelegate], the delegate for regular window controllers. +/// * [WindowControllerDelegate], the delegate for regular window controllers. mixin class PopupWindowControllerDelegate { /// Invoked after the window is closed. /// @@ -1025,7 +1025,7 @@ abstract class PopupWindowController extends BaseWindowController { void activate() { BaseWindowController parent = this.parent; while (true) { - if (parent is RegularWindowController) { + if (parent is WindowController) { parent.activate(); break; } else if (parent is DialogWindowController) { @@ -1046,7 +1046,7 @@ abstract class PopupWindowController extends BaseWindowController { bool get isActivated { BaseWindowController parent = this.parent; while (true) { - if (parent is RegularWindowController) { + if (parent is WindowController) { return parent.isActivated; } else if (parent is DialogWindowController) { return parent.isActivated; @@ -1217,15 +1217,15 @@ abstract class SatelliteWindowController extends BaseWindowController { /// /// {@macro flutter.widgets.windowing.satelliteConstructorCommon} /// - /// {@macro flutter.widgets.windowing.sizedToContentConstructor} + /// {@macro flutter.widgets.windowing.shrinkWrapConstructor} /// - /// To create a dialog with a specific size instead, use the default + /// To create a satellite window with a specific size instead, use the default /// [SatelliteWindowController] constructor. /// /// {@macro flutter.widgets.windowing.shared} /// /// {@macro flutter.widgets.windowing.experimental} - factory SatelliteWindowController.sizedToContent({ + factory SatelliteWindowController.shrinkWrap({ required BaseWindowController parent, required WindowPositioner initialPositioner, Rect? initialAnchorRect, @@ -1346,16 +1346,16 @@ abstract class SatelliteWindowController extends BaseWindowController { /// {@macro flutter.widgets.windowing.experimental} @internal abstract class WindowingOwner { - /// Creates a [RegularWindowController] with the provided properties. + /// Creates a [WindowController] with the provided properties. /// - /// Most app developers should use [RegularWindowController]'s constructor + /// Most app developers should use [WindowController]'s constructor /// instead of calling this method directly. This method allows platforms /// to inject platform-specific logic. /// /// {@macro flutter.widgets.windowing.experimental} @internal - RegularWindowController createRegularWindowController({ - required RegularWindowControllerDelegate delegate, + WindowController createWindowController({ + required WindowControllerDelegate delegate, Size? size, BoxConstraints? constraints, required bool resizable, @@ -1455,8 +1455,8 @@ class _WindowingOwnerUnsupported extends WindowingOwner { final String errorMessage; @override - RegularWindowController createRegularWindowController({ - required RegularWindowControllerDelegate delegate, + WindowController createWindowController({ + required WindowControllerDelegate delegate, Size? size, BoxConstraints? constraints, bool resizable = true, @@ -1514,18 +1514,18 @@ class _WindowingOwnerUnsupported extends WindowingOwner { } } -/// The [RegularWindow] widget provides a way to render a regular window in the +/// The [Window] widget provides a way to render a regular window in the /// widget tree. /// /// The provided [controller] creates the native window that backs /// the widget. The [child] widget is rendered into this newly created window. /// -/// When a [RegularWindow] widget is removed from the tree, the window that was created +/// When a [Window] widget is removed from the tree, the window that was created /// by the [controller] remains valid until the caller destroys it by calling -/// [RegularWindowController.destroy]. +/// [WindowController.destroy]. /// /// Widgets in the same tree as the [child] widget will have access to the -/// [RegularWindowController] via the [WindowScope] widget. +/// [WindowController] via the [WindowScope] widget. /// /// {@tool snippet} /// An example usage might look like: @@ -1539,8 +1539,8 @@ class _WindowingOwnerUnsupported extends WindowingOwner { /// /// void main() { /// runWidget( -/// RegularWindow( -/// controller: RegularWindowController( +/// Window( +/// controller: WindowController( /// size: const Size(800, 600), /// constraints: const BoxConstraints(minWidth: 640, minHeight: 480), /// title: 'Example Window', @@ -1554,18 +1554,18 @@ class _WindowingOwnerUnsupported extends WindowingOwner { /// /// {@macro flutter.widgets.windowing.experimental} @internal -class RegularWindow extends StatelessWidget { +class Window extends StatelessWidget { /// Creates a regular window widget. /// /// The [controller] creates the native backing window into which the /// [child] widget is rendered. /// /// It is up to the caller to destroy the window by calling - /// [RegularWindowController.destroy] when the window is no longer needed. + /// [WindowController.destroy] when the window is no longer needed. /// /// {@macro flutter.widgets.windowing.experimental} @internal - RegularWindow({super.key, required this.controller, required this.child}) { + Window({super.key, required this.controller, required this.child}) { if (!isWindowingEnabled) { throw UnsupportedError(_kWindowingDisabledErrorMessage); } @@ -1575,7 +1575,7 @@ class RegularWindow extends StatelessWidget { /// /// {@macro flutter.widgets.windowing.experimental} @internal - final RegularWindowController controller; + final WindowController controller; /// The content rendered into this window. /// @@ -1622,8 +1622,8 @@ class RegularWindow extends StatelessWidget { /// /// void main() { /// runWidget( -/// RegularWindow( -/// controller: RegularWindowController( +/// Window( +/// controller: WindowController( /// size: const Size(800, 600), /// constraints: const BoxConstraints(minWidth: 640, minHeight: 480), /// title: 'Example Window', @@ -1887,7 +1887,7 @@ enum _WindowControllerAspect { /// /// See also: /// -/// * [RegularWindow], the widget to create a regular window. +/// * [Window], the widget to create a regular window. /// * [DialogWindow], the widget to create a dialog window. @internal class WindowScope extends InheritedModel<_WindowControllerAspect> { @@ -1960,9 +1960,9 @@ class WindowScope extends InheritedModel<_WindowControllerAspect> { /// /// See also: /// - /// * [RegularWindowController], the controller for regular top-level windows. + /// * [WindowController], the controller for regular top-level windows. /// * [DialogWindowController], the controller for dialog windows. - /// * [RegularWindow], the widget for a regular window. + /// * [Window], the widget for a regular window. /// * [DialogWindow], the widget for a dialog window. /// * [maybeOf], which doesn't throw or assert if it doesn't find a /// [WindowScope] ancestor. It returns null instead. @@ -1977,9 +1977,9 @@ class WindowScope extends InheritedModel<_WindowControllerAspect> { /// /// See also: /// - /// * [RegularWindowController], the controller for regular top-level windows. + /// * [WindowController], the controller for regular top-level windows. /// * [DialogWindowController], the controller for dialog windows. - /// * [RegularWindow], the widget for a regular window. + /// * [Window], the widget for a regular window. /// * [DialogWindow], the widget for a dialog window. /// * [of], which will throw if it doesn't find a [WindowScope] ancestor, /// instead of returning null. @@ -2026,7 +2026,7 @@ class WindowScope extends InheritedModel<_WindowControllerAspect> { /// /// See also: /// - /// * [RegularWindowController.title], which returns the current title of the window. + /// * [WindowController.title], which returns the current title of the window. /// * [of], which returns the [BaseWindowController] associated with the window. @internal static String titleOf(BuildContext context) { @@ -2039,7 +2039,7 @@ class WindowScope extends InheritedModel<_WindowControllerAspect> { /// /// See also: /// - /// * [RegularWindowController.title], which returns the current title of the window. + /// * [WindowController.title], which returns the current title of the window. /// * [maybeOf], which returns the [BaseWindowController] associated with the window, or null if not found. @internal static String? maybeTitleOf(BuildContext context) { @@ -2062,7 +2062,7 @@ class WindowScope extends InheritedModel<_WindowControllerAspect> { /// /// See also: /// - /// * [RegularWindowController.isActivated], which returns the current activation status of the window. + /// * [WindowController.isActivated], which returns the current activation status of the window. /// * [of], which returns the [BaseWindowController] associated with the window. @internal static bool isActivatedOf(BuildContext context) { @@ -2076,7 +2076,7 @@ class WindowScope extends InheritedModel<_WindowControllerAspect> { /// /// See also: /// - /// * [RegularWindowController.isActivated], which returns the current activation status of the window. + /// * [WindowController.isActivated], which returns the current activation status of the window. /// * [maybeOf], which returns the [BaseWindowController] associated with the window, or null if not found. @internal static bool? maybeIsActivatedOf(BuildContext context) { @@ -2099,7 +2099,7 @@ class WindowScope extends InheritedModel<_WindowControllerAspect> { /// /// See also: /// - /// * [RegularWindowController.isMinimized], which returns the current minimized status of the window. + /// * [WindowController.isMinimized], which returns the current minimized status of the window. /// * [of], which returns the [BaseWindowController] associated with the window. @internal static bool isMinimizedOf(BuildContext context) { @@ -2113,7 +2113,7 @@ class WindowScope extends InheritedModel<_WindowControllerAspect> { /// /// See also: /// - /// * [RegularWindowController.isMinimized], which returns the current minimized status of the window. + /// * [WindowController.isMinimized], which returns the current minimized status of the window. /// * [maybeOf], which returns the [BaseWindowController] associated with the window, or null if not found. @internal static bool? maybeIsMinimizedOf(BuildContext context) { @@ -2136,7 +2136,7 @@ class WindowScope extends InheritedModel<_WindowControllerAspect> { /// /// See also: /// - /// * [RegularWindowController.isMaximized], which returns the current maximized status of the window. + /// * [WindowController.isMaximized], which returns the current maximized status of the window. /// * [of], which returns the [BaseWindowController] associated with the window. @internal static bool isMaximizedOf(BuildContext context) { @@ -2150,7 +2150,7 @@ class WindowScope extends InheritedModel<_WindowControllerAspect> { /// /// See also: /// - /// * [RegularWindowController.isMaximized], which returns the current maximized status of the window. + /// * [WindowController.isMaximized], which returns the current maximized status of the window. /// * [maybeOf], which returns the [BaseWindowController] associated with the window, or null if not found. @internal static bool? maybeIsMaximizedOf(BuildContext context) { @@ -2173,7 +2173,7 @@ class WindowScope extends InheritedModel<_WindowControllerAspect> { /// /// See also: /// - /// * [RegularWindowController.isFullscreen], which returns the current fullscreen status of the window. + /// * [WindowController.isFullscreen], which returns the current fullscreen status of the window. /// * [of], which returns the [BaseWindowController] associated with the window. @internal static bool isFullscreenOf(BuildContext context) { @@ -2187,7 +2187,7 @@ class WindowScope extends InheritedModel<_WindowControllerAspect> { /// /// See also: /// - /// * [RegularWindowController.isFullscreen], which returns the current fullscreen status of the window. + /// * [WindowController.isFullscreen], which returns the current fullscreen status of the window. /// * [maybeOf], which returns the [BaseWindowController] associated with the window, or null if not found. @internal static bool? maybeIsFullscreenOf(BuildContext context) { @@ -2234,7 +2234,7 @@ class WindowScope extends InheritedModel<_WindowControllerAspect> { /// given [controller]. Controllers that do not support titles report an empty /// string. static String _titleValue(BaseWindowController controller) => switch (controller) { - RegularWindowController() => controller.title, + WindowController() => controller.title, DialogWindowController() => controller.title, TooltipWindowController() => '', PopupWindowController() => '', @@ -2244,7 +2244,7 @@ class WindowScope extends InheritedModel<_WindowControllerAspect> { // Computes the value of the [_WindowControllerAspect.activated] aspect for the // given [controller]. Controllers that do not support activation report false. static bool _isActivatedValue(BaseWindowController controller) => switch (controller) { - RegularWindowController() => controller.isActivated, + WindowController() => controller.isActivated, DialogWindowController() => controller.isActivated, TooltipWindowController() => false, PopupWindowController() => controller.isActivated, @@ -2255,7 +2255,7 @@ class WindowScope extends InheritedModel<_WindowControllerAspect> { /// given [controller]. Controllers that do not support maximization report /// false. static bool _isMaximizedValue(BaseWindowController controller) => switch (controller) { - RegularWindowController() => controller.isMaximized, + WindowController() => controller.isMaximized, DialogWindowController() => false, TooltipWindowController() => false, PopupWindowController() => false, @@ -2266,7 +2266,7 @@ class WindowScope extends InheritedModel<_WindowControllerAspect> { /// given [controller]. Controllers that do not support minimization report /// false. static bool _isMinimizedValue(BaseWindowController controller) => switch (controller) { - RegularWindowController() => controller.isMinimized, + WindowController() => controller.isMinimized, DialogWindowController() => controller.isMinimized, TooltipWindowController() => false, PopupWindowController() => false, @@ -2277,7 +2277,7 @@ class WindowScope extends InheritedModel<_WindowControllerAspect> { /// the given [controller]. Controllers that do not support fullscreen report /// false. static bool _isFullscreenValue(BaseWindowController controller) => switch (controller) { - RegularWindowController() => controller.isFullscreen, + WindowController() => controller.isFullscreen, DialogWindowController() => false, TooltipWindowController() => false, PopupWindowController() => false, @@ -2314,7 +2314,7 @@ class WindowScope extends InheritedModel<_WindowControllerAspect> { ErrorHint( 'No WindowScope ancestor could be found starting from the context ' 'that was passed to WindowScope.of(). This can happen because the ' - 'context used is not a descendant of a RegularWindow widget, which introduces ' + 'context used is not a descendant of a Window widget, which introduces ' 'a WindowScope.', ), ]); @@ -2549,7 +2549,7 @@ class WindowEntry { /// {@tool dartpad} /// An example usage might look like this, where the window manager wraps /// the root of the widget tree so that dialogs can be rendered at the same level -/// as a [RegularWindow]. +/// as a [Window]. /// /// ** See code in examples/api/lib/widgets/windows/window_manager.0.dart ** /// {@end-tool} @@ -2567,10 +2567,10 @@ class WindowManager extends StatefulWidget { /// /// {@macro flutter.widgets.windowing.experimental} @internal - const WindowManager({super.key, required this.child}); + const WindowManager({super.key, required this.initialWindows}); - /// The child widget of the window manager. - final Widget child; + /// The initial windows to be registered and managed by this window manager. + final List initialWindows; @override State createState() => _WindowManagerState(); @@ -2580,11 +2580,13 @@ class _WindowManagerState extends State { final WindowRegistry _registry = WindowRegistry(); @override - Widget build(BuildContext context) { - if (!isWindowingEnabled) { - return widget.child; - } + void initState() { + super.initState(); + widget.initialWindows.forEach(_registry.register); + } + @override + Widget build(BuildContext context) { return _WindowRegistryScope( registry: _registry, child: ListenableBuilder( @@ -2596,7 +2598,7 @@ class _WindowManagerState extends State { controller: dialog, child: entry.builder(context), ), - final RegularWindowController regular => RegularWindow( + final WindowController regular => Window( controller: regular, child: entry.builder(context), ), @@ -2615,17 +2617,8 @@ class _WindowManagerState extends State { }; }).toList(); - final FlutterView? view = View.maybeOf(context); - if (view == null) { - return ViewCollection(views: subViews); - } - - return ViewAnchor( - view: subViews.isNotEmpty ? ViewCollection(views: subViews) : null, - child: child!, - ); + return ViewCollection(views: subViews); }, - child: widget.child, ), ); } diff --git a/packages/flutter/lib/src/widgets/_window_linux.dart b/packages/flutter/lib/src/widgets/_window_linux.dart index 228a88759e4d5..b94afc6b1a748 100644 --- a/packages/flutter/lib/src/widgets/_window_linux.dart +++ b/packages/flutter/lib/src/widgets/_window_linux.dart @@ -96,14 +96,14 @@ class WindowingOwnerLinux extends WindowingOwner { @internal @override - RegularWindowController createRegularWindowController({ + WindowController createWindowController({ Size? size, BoxConstraints? constraints, required bool resizable, String? title, - required RegularWindowControllerDelegate delegate, + required WindowControllerDelegate delegate, }) { - final controller = RegularWindowControllerLinux( + final controller = WindowControllerLinux( owner: this, delegate: delegate, size: size, @@ -269,7 +269,7 @@ class LinuxWindowRegistrar { /// /// {@macro flutter.widgets.windowing.experimental} @internal -abstract interface class WindowControllerLinux { +abstract interface class BaseWindowControllerLinux { /// Returns pointer to the underlying [GtkWindow](https://docs.gtk.org/gtk3/class.Window.html). /// /// Using this pointer implies the user is aware of any side effects changes may have to Flutter behavior. @@ -294,15 +294,15 @@ abstract interface class WindowControllerLinux { ffi.Pointer get flutterViewHandle; } -/// Implementation of [RegularWindowController] for the Linux platform. +/// Implementation of [WindowController] for the Linux platform. /// /// {@macro flutter.widgets.windowing.experimental} /// /// See also: /// -/// * [RegularWindowController], the base class for regular windows. -class RegularWindowControllerLinux extends RegularWindowController - implements WindowControllerLinux { +/// * [WindowController], the base class for regular windows. +class WindowControllerLinux extends WindowController + implements BaseWindowControllerLinux { /// Creates a new regular window controller for Linux. /// /// When this constructor completes the native window has been created and @@ -312,11 +312,11 @@ class RegularWindowControllerLinux extends RegularWindowController /// /// See also: /// - /// * [RegularWindowController], the base class for regular windows. + /// * [WindowController], the base class for regular windows. @internal - RegularWindowControllerLinux({ + WindowControllerLinux({ required WindowingOwnerLinux owner, - required RegularWindowControllerDelegate delegate, + required WindowControllerDelegate delegate, Size? size, BoxConstraints? constraints, String? title, @@ -370,7 +370,7 @@ class RegularWindowControllerLinux extends RegularWindowController } final WindowingOwnerLinux _owner; - final RegularWindowControllerDelegate _delegate; + final WindowControllerDelegate _delegate; final _GtkWindow _window; late final _FlView _view; late final _FlViewMonitor _viewMonitor; @@ -509,7 +509,7 @@ class RegularWindowControllerLinux extends RegularWindowController /// See also: /// /// * [DialogWindowController], the base class for dialog windows. -class DialogWindowControllerLinux extends DialogWindowController implements WindowControllerLinux { +class DialogWindowControllerLinux extends DialogWindowController implements BaseWindowControllerLinux { /// Creates a new dialog window controller for Linux. /// /// When this constructor completes the native window has been created and @@ -700,7 +700,7 @@ class DialogWindowControllerLinux extends DialogWindowController implements Wind /// /// * [TooltipWindowController], the base class for tooltip windows. class TooltipWindowControllerLinux extends TooltipWindowController - implements WindowControllerLinux { + implements BaseWindowControllerLinux { /// Creates a new tooltip window controller for Linux. /// /// When this constructor completes the native window has been created and @@ -895,7 +895,7 @@ class TooltipWindowControllerLinux extends TooltipWindowController /// See also: /// /// * [PopupWindowController], the base class for popup windows. -class PopupWindowControllerLinux extends PopupWindowController implements WindowControllerLinux { +class PopupWindowControllerLinux extends PopupWindowController implements BaseWindowControllerLinux { /// Creates a new popup window controller for Linux. /// /// When this constructor completes the native window has been created and diff --git a/packages/flutter/lib/src/widgets/_window_macos.dart b/packages/flutter/lib/src/widgets/_window_macos.dart index 7066805e7a735..438e9636105d2 100644 --- a/packages/flutter/lib/src/widgets/_window_macos.dart +++ b/packages/flutter/lib/src/widgets/_window_macos.dart @@ -75,14 +75,14 @@ class WindowingOwnerMacOS extends WindowingOwner { } @override - RegularWindowController createRegularWindowController({ - required RegularWindowControllerDelegate delegate, + WindowController createWindowController({ + required WindowControllerDelegate delegate, Size? size, BoxConstraints? constraints, required bool resizable, String? title, }) { - final controller = RegularWindowControllerMacOS( + final controller = WindowControllerMacOS( owner: this, delegate: delegate, size: size, @@ -187,7 +187,7 @@ class WindowingOwnerMacOS extends WindowingOwner { /// /// {@macro flutter.widgets.windowing.experimental} @internal -abstract interface class WindowControllerMacOS { +abstract interface class BaseWindowControllerMacOS { /// Returns pointer to the underlying NSWindow. /// /// Using this pointer implies the user is aware of any side effects changes may have to Flutter behavior. @@ -206,7 +206,7 @@ abstract interface class WindowControllerMacOS { bool get isDestroyed; } -mixin _WindowControllerMixin implements WindowControllerMacOS { +mixin _WindowControllerMixin implements BaseWindowControllerMacOS { void _initController(WindowingOwnerMacOS owner) { if (!isWindowingEnabled) { throw UnsupportedError(_kWindowingDisabledErrorMessage); @@ -501,19 +501,19 @@ class PopupWindowControllerMacOS extends PopupWindowController with _WindowContr Rect _anchorRect; } -/// Implementation of [RegularWindowController] for the macOS platform. +/// Implementation of [WindowController] for the macOS platform. /// /// {@macro flutter.widgets.windowing.experimental} /// /// See also: /// -/// * [RegularWindowController], the base class for regular windows. -class RegularWindowControllerMacOS extends RegularWindowController with _WindowControllerMixin { +/// * [WindowController], the base class for regular windows. +class WindowControllerMacOS extends WindowController with _WindowControllerMixin { /// Creates a new regular window controller for macOS. When this constructor /// completes the FlutterView is created and framework is aware of it. - RegularWindowControllerMacOS({ + WindowControllerMacOS({ required WindowingOwnerMacOS owner, - required RegularWindowControllerDelegate delegate, + required WindowControllerDelegate delegate, required Size? size, BoxConstraints? constraints, String? title, @@ -521,7 +521,7 @@ class RegularWindowControllerMacOS extends RegularWindowController with _WindowC super.empty() { _initController(owner); - final int viewId = _MacOSPlatformInterface.createRegularWindow( + final int viewId = _MacOSPlatformInterface.createWindow( size: size, constraints: constraints, onShouldClose: _onShouldClose.nativeFunction, @@ -627,7 +627,7 @@ class RegularWindowControllerMacOS extends RegularWindowController with _WindowC return _MacOSPlatformInterface.isFullscreen(windowHandle); } - final RegularWindowControllerDelegate _delegate; + final WindowControllerDelegate _delegate; @override bool get isActivated => _MacOSPlatformInterface.isActivated(windowHandle); @@ -883,10 +883,10 @@ class _MacOSPlatformInterface { @Native)>( symbol: 'InternalFlutter_WindowController_CreateRegularWindow', ) - external static int _createRegularWindow(int engineId, Pointer<_WindowCreationRequest> request); + external static int _createWindow(int engineId, Pointer<_WindowCreationRequest> request); /// Creates a new window and returns the viewId of the created FlutterView. - static int createRegularWindow({ + static int createWindow({ required Size? size, BoxConstraints? constraints, required Pointer> onShouldClose, @@ -913,7 +913,7 @@ class _MacOSPlatformInterface { ..constraints.maxWidth = constraints.maxWidth ..constraints.maxHeight = constraints.maxHeight; } - final int viewId = _createRegularWindow( + final int viewId = _createWindow( WidgetsBinding.instance.platformDispatcher.engineId!, request, ); diff --git a/packages/flutter/lib/src/widgets/_window_win32.dart b/packages/flutter/lib/src/widgets/_window_win32.dart index 4e25c82a131d9..0e9d3db33c7a1 100644 --- a/packages/flutter/lib/src/widgets/_window_win32.dart +++ b/packages/flutter/lib/src/widgets/_window_win32.dart @@ -139,14 +139,14 @@ class WindowingOwnerWin32 extends WindowingOwner { @internal @override - RegularWindowController createRegularWindowController({ + WindowController createWindowController({ Size? size, BoxConstraints? constraints, required bool resizable, String? title, - required RegularWindowControllerDelegate delegate, + required WindowControllerDelegate delegate, }) { - return RegularWindowControllerWin32( + return WindowControllerWin32( owner: this, delegate: delegate, size: size, @@ -283,10 +283,10 @@ class WindowingOwnerWin32 extends WindowingOwner { } } -class _RegularWindowMesageHandler implements _WindowsMessageHandler { - _RegularWindowMesageHandler({required this.controller}); +class _WindowMessageHandler implements _WindowsMessageHandler { + _WindowMessageHandler({required this.controller}); - final RegularWindowControllerWin32 controller; + final WindowControllerWin32 controller; @override int? handleWindowsMessage( @@ -304,7 +304,7 @@ class _RegularWindowMesageHandler implements _WindowsMessageHandler { /// /// {@macro flutter.widgets.windowing.experimental} @internal -abstract mixin class WindowControllerWin32 { +abstract mixin class BaseWindowControllerWin32 { /// Returns the underlying HWND for this window. /// /// Using this handle implies the user is aware of any side effects changes may have to Flutter behavior. @@ -317,14 +317,14 @@ abstract mixin class WindowControllerWin32 { HWND get windowHandle; } -/// Implementation of [RegularWindowController] for the Windows platform. +/// Implementation of [WindowController] for the Windows platform. /// /// {@macro flutter.widgets.windowing.experimental} /// /// See also: /// -/// * [RegularWindowController], the base class for regular windows. -class RegularWindowControllerWin32 extends RegularWindowController with WindowControllerWin32 { +/// * [WindowController], the base class for regular windows. +class WindowControllerWin32 extends WindowController with BaseWindowControllerWin32 { /// Creates a new regular window controller for Win32. /// /// When this constructor completes the native window has been created and @@ -334,11 +334,11 @@ class RegularWindowControllerWin32 extends RegularWindowController with WindowCo /// /// See also: /// - /// * [RegularWindowController], the base class for regular windows. + /// * [WindowController], the base class for regular windows. @internal - RegularWindowControllerWin32({ + WindowControllerWin32({ required WindowingOwnerWin32 owner, - required RegularWindowControllerDelegate delegate, + required WindowControllerDelegate delegate, Size? size, BoxConstraints? constraints, String? title, @@ -349,16 +349,16 @@ class RegularWindowControllerWin32 extends RegularWindowController with WindowCo if (!isWindowingEnabled) { throw UnsupportedError(_kWindowingDisabledErrorMessage); } - _handler = _RegularWindowMesageHandler(controller: this); + _handler = _WindowMessageHandler(controller: this); owner._addMessageHandler(_handler); - final sizedToContent = size == null; - final int viewId = _Win32PlatformInterface.createRegularWindow( + final shrinkWrap = size == null; + final int viewId = _Win32PlatformInterface.createWindow( _owner.allocator, WidgetsBinding.instance.platformDispatcher.engineId!, size, constraints, title, - sizedToContent, + shrinkWrap, resizable, ); if (viewId < 0) { @@ -372,8 +372,8 @@ class RegularWindowControllerWin32 extends RegularWindowController with WindowCo } final WindowingOwnerWin32 _owner; - final RegularWindowControllerDelegate _delegate; - late final _RegularWindowMesageHandler _handler; + final WindowControllerDelegate _delegate; + late final _WindowMessageHandler _handler; bool _destroyed = false; @override @@ -570,7 +570,7 @@ class _DialogWindowMesageHandler implements _WindowsMessageHandler { /// See also: /// /// * [DialogWindowController], the base class for dialog windows. -class DialogWindowControllerWin32 extends DialogWindowController with WindowControllerWin32 { +class DialogWindowControllerWin32 extends DialogWindowController with BaseWindowControllerWin32 { /// Creates a new dialog window controller for Win32. /// /// When this constructor completes the native window has been created and @@ -599,7 +599,7 @@ class DialogWindowControllerWin32 extends DialogWindowController with WindowCont } _handler = _DialogWindowMesageHandler(controller: this); owner._addMessageHandler(_handler); - final sizedToContent = size == null; + final shrinkWrap = size == null; final int viewId = _Win32PlatformInterface.createDialogWindow( _owner.allocator, WidgetsBinding.instance.platformDispatcher.engineId!, @@ -612,7 +612,7 @@ class DialogWindowControllerWin32 extends DialogWindowController with WindowCont parent.rootView.viewId, ) : null, - sizedToContent, + shrinkWrap, resizable, ); if (viewId < 0) { @@ -787,7 +787,7 @@ typedef _GetWindowPositionNative = /// /// * [TooltipWindowController], the base class for tooltip windows. class TooltipWindowControllerWin32 extends TooltipWindowController - with WindowControllerWin32 + with BaseWindowControllerWin32 implements _WindowsMessageHandler { /// Creates a new tooltip window controller for Win32. /// @@ -1322,35 +1322,35 @@ class _Win32PlatformInterface { ffi.Pointer<_WindowingInitRequest> request, ); - static int createRegularWindow( + static int createWindow( ffi.Allocator allocator, int engineId, Size? size, BoxConstraints? constraints, String? title, - bool sizedToContent, + bool shrinkWrap, bool resizable, ) { - final ffi.Pointer<_RegularWindowCreationRequest> request = - allocator<_RegularWindowCreationRequest>(); + final ffi.Pointer<_WindowCreationRequest> request = + allocator<_WindowCreationRequest>(); try { request.ref.size.from(size); request.ref.constraints.from(constraints); - request.ref.title = (title ?? 'Regular window').toNativeUtf16(allocator: allocator); - request.ref.sizedToContent = sizedToContent; + request.ref.title = (title ?? 'Window').toNativeUtf16(allocator: allocator); + request.ref.shrinkWrap = shrinkWrap; request.ref.resizable = resizable; - return _createRegularWindow(engineId, request); + return _createWindow(engineId, request); } finally { allocator.free(request); } } - @ffi.Native)>( + @ffi.Native)>( symbol: 'InternalFlutterWindows_WindowManager_CreateRegularWindow', ) - external static int _createRegularWindow( + external static int _createWindow( int engineId, - ffi.Pointer<_RegularWindowCreationRequest> request, + ffi.Pointer<_WindowCreationRequest> request, ); static int createDialogWindow( @@ -1360,7 +1360,7 @@ class _Win32PlatformInterface { BoxConstraints? constraints, String? title, HWND? parent, - bool sizedToContent, + bool shrinkWrap, bool resizable, ) { final ffi.Pointer<_DialogWindowCreationRequest> request = @@ -1370,7 +1370,7 @@ class _Win32PlatformInterface { request.ref.constraints.from(constraints); request.ref.title = (title ?? 'Dialog window').toNativeUtf16(allocator: allocator); request.ref.parentOrNull = parent ?? ffi.Pointer.fromAddress(0); - request.ref.sizedToContent = sizedToContent; + request.ref.shrinkWrap = shrinkWrap; request.ref.resizable = resizable; return _createDialogWindow(engineId, request); } finally { @@ -1637,14 +1637,14 @@ class _Win32PlatformInterface { } } -/// Payload for the creation method used by [_Win32PlatformInterface.createRegularWindow]. -final class _RegularWindowCreationRequest extends ffi.Struct { +/// Payload for the creation method used by [_Win32PlatformInterface.createWindow]. +final class _WindowCreationRequest extends ffi.Struct { external _WindowSizeRequest size; external _WindowConstraintsRequest constraints; external ffi.Pointer<_Utf16> title; @ffi.Bool() - external bool sizedToContent; + external bool shrinkWrap; @ffi.Bool() external bool resizable; @@ -1658,7 +1658,7 @@ final class _DialogWindowCreationRequest extends ffi.Struct { external HWND parentOrNull; @ffi.Bool() - external bool sizedToContent; + external bool shrinkWrap; @ffi.Bool() external bool resizable; @@ -1701,7 +1701,7 @@ final class _WindowingInitRequest extends ffi.Struct { onMessage; } -/// Payload for the size of a window used by [_RegularWindowCreationRequest] and +/// Payload for the size of a window used by [_WindowCreationRequest] and /// [_Win32PlatformInterface.setWindowContentSize]. final class _WindowSizeRequest extends ffi.Struct { @ffi.Bool() @@ -1720,7 +1720,7 @@ final class _WindowSizeRequest extends ffi.Struct { } } -/// Payload for the constraints of a window used by [_RegularWindowCreationRequest] and +/// Payload for the constraints of a window used by [_WindowCreationRequest] and /// [_Win32PlatformInterface.setWindowConstraints]. final class _WindowConstraintsRequest extends ffi.Struct { @ffi.Bool() diff --git a/packages/flutter/lib/src/widgets/app.dart b/packages/flutter/lib/src/widgets/app.dart index c3c670626f78e..948d3f599884a 100644 --- a/packages/flutter/lib/src/widgets/app.dart +++ b/packages/flutter/lib/src/widgets/app.dart @@ -15,8 +15,6 @@ import 'dart:collection' show HashMap; import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; -import '../foundation/_features.dart' show isWindowingEnabled; -import '_window.dart' show WindowManager; import 'actions.dart'; import 'banner.dart'; @@ -1730,10 +1728,6 @@ class _WidgetsAppState extends State with WidgetsBindingObserver { result = routing!; } - if (isWindowingEnabled) { - result = WindowManager(child: result); - } - if (widget.textStyle != null) { result = DefaultTextStyle(style: widget.textStyle!, child: result); } diff --git a/packages/flutter/lib/src/widgets/dialog.dart b/packages/flutter/lib/src/widgets/dialog.dart index 7a2e3cb3b3127..bd05016932872 100644 --- a/packages/flutter/lib/src/widgets/dialog.dart +++ b/packages/flutter/lib/src/widgets/dialog.dart @@ -2,24 +2,10 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter/foundation.dart'; - -import '../foundation/_features.dart' show isWindowingEnabled; - -import '_window.dart' - show - BaseWindowController, - DialogWindowController, - DialogWindowControllerDelegate, - WindowEntry, - WindowRegistry, - WindowScope; - import 'basic.dart'; import 'debug.dart'; import 'framework.dart'; import 'navigator.dart'; -import 'overlay.dart'; import 'routes.dart'; /// A builder for a route that takes the build context and the widget intended @@ -78,27 +64,6 @@ Future showRawDialog({ final NavigatorState navigator = Navigator.of(context, rootNavigator: useRootNavigator); - final WindowRegistry? windowRegistry = WindowRegistry.maybeOf(context); - if (windowRegistry != null && isWindowingEnabled) { - try { - final Size? parentSize = WindowScope.maybeContentSizeOf(context); - return navigator.push( - _DialogWindowRoute( - builder: builder, - parentController: WindowScope.maybeOf(context), - context: context, - settings: routeSettings, - size: fullscreenDialog ? parentSize : null, - ), - ); - } on UnsupportedError catch (error, stacktrace) { - // Fallback to normal dialog route if windowing is not supported. - FlutterError.reportError( - FlutterErrorDetails(exception: error, library: 'widgets library', stack: stacktrace), - ); - } - } - final Route route = routeBuilder?.call(context, builder) ?? RawDialogRoute( @@ -114,86 +79,3 @@ Future showRawDialog({ return navigator.push(route); } - -class _DialogWindowDelegate extends DialogWindowControllerDelegate { - _DialogWindowDelegate(this.route); - - final _DialogWindowRoute route; - - @override - void onWindowCloseRequested(DialogWindowController controller) { - route.navigator?.pop(); - } -} - -class _DialogWindowRoute extends Route { - _DialogWindowRoute({ - required this.builder, - required this.parentController, - required BuildContext context, - super.settings, - Size? size, - }) : _registry = WindowRegistry.maybeOf(context) { - _controller = size != null - ? DialogWindowController( - parent: parentController, - title: 'Dialog', - delegate: _DialogWindowDelegate(this), - size: size, - ) - : DialogWindowController.sizedToContent( - parent: parentController, - title: 'Dialog', - delegate: _DialogWindowDelegate(this), - ); - } - - final WidgetBuilder builder; - final BaseWindowController? parentController; - final WindowRegistry? _registry; - DialogWindowController? _controller; - WindowEntry? _entry; - late final List _overlayEntries; - - @override - List get overlayEntries => _overlayEntries; - - @override - void install() { - super.install(); - - // Create a minimal transparent overlay entry to satisfy Navigator requirements. - // The actual dialog content is rendered through ViewAnchor, not through this overlay. - _overlayEntries = [ - OverlayEntry(builder: (BuildContext context) => const SizedBox.shrink()), - ]; - - final NavigatorState? nav = navigator; - final BuildContext? routeContext = nav?.context; - if (routeContext != null && nav != null) { - _entry = WindowEntry(controller: _controller!, builder: builder); - _registry?.register(_entry!); - } - } - - @override - TickerFuture didPush() { - return super.didPush(); - } - - @override - bool didPop(T? result) { - if (_entry != null) { - _registry?.unregister(_entry!); - } - _controller?.destroy(); - return super.didPop(result); - } - - @override - void dispose() { - _controller?.dispose(); - _controller = null; - super.dispose(); - } -} diff --git a/packages/flutter/lib/src/widgets/focus_traversal.dart b/packages/flutter/lib/src/widgets/focus_traversal.dart index 137aa549b36a3..e54eb5fbcd3b8 100644 --- a/packages/flutter/lib/src/widgets/focus_traversal.dart +++ b/packages/flutter/lib/src/widgets/focus_traversal.dart @@ -1123,6 +1123,7 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy { TraversalDirection direction, FocusScopeNode nearestScope, FocusNode focusedChild, + _FocusTraversalGroupNode? groupNode, ) { final _DirectionalPolicyData? policyData = _policyData[nearestScope]; if (policyData != null && @@ -1154,7 +1155,7 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy { case TraversalDirection.down: alignmentPolicy = ScrollPositionAlignmentPolicy.keepVisibleAtEnd; } - requestFocusCallback(lastNode, alignmentPolicy: alignmentPolicy); + _requestFocus(lastNode, alignmentPolicy: alignmentPolicy, groupNode); return true; } @@ -1214,23 +1215,32 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy { FocusNode node, FocusScopeNode nearestScope, TraversalDirection direction, + _FocusTraversalGroupNode? groupNode, ) { if (node is FocusScopeNode) { if (node.focusedChild != null) { - return _requestTraversalFocusInDirection(currentNode, node.focusedChild!, node, direction); + return _requestTraversalFocusInDirection( + currentNode, + node.focusedChild!, + node, + direction, + groupNode, + ); } final FocusNode firstNode = findFirstFocusInDirection(node, direction) ?? currentNode; switch (direction) { case TraversalDirection.up: case TraversalDirection.left: - requestFocusCallback( + _requestFocus( firstNode, + groupNode, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart, ); case TraversalDirection.right: case TraversalDirection.down: - requestFocusCallback( + _requestFocus( firstNode, + groupNode, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd, ); } @@ -1240,20 +1250,44 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy { switch (direction) { case TraversalDirection.up: case TraversalDirection.left: - requestFocusCallback( + _requestFocus( node, + groupNode, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart, ); case TraversalDirection.right: case TraversalDirection.down: - requestFocusCallback(node, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd); + _requestFocus( + node, + groupNode, + alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd, + ); } return !nodeHadPrimaryFocus; } + void _requestFocus( + FocusNode node, + _FocusTraversalGroupNode? groupNode, { + ScrollPositionAlignmentPolicy? alignmentPolicy, + double? alignment, + Duration? duration, + Curve? curve, + }) { + groupNode?.lastRequestedFocus = node; + requestFocusCallback( + node, + alignmentPolicy: alignmentPolicy, + alignment: alignment, + duration: duration, + curve: curve, + ); + } + bool _onEdgeForDirection( FocusNode currentNode, FocusNode focusedChild, + _FocusTraversalGroupNode? groupNode, TraversalDirection direction, { FocusScopeNode? scope, }) { @@ -1275,7 +1309,13 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy { direction, ); if (found == null) { - return _onEdgeForDirection(currentNode, focusedChild, direction, scope: nearestScope); + return _onEdgeForDirection( + currentNode, + focusedChild, + groupNode, + direction, + scope: nearestScope, + ); } } else { found = _findNextFocusInDirection( @@ -1296,7 +1336,13 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy { return false; } if (found != null) { - return _requestTraversalFocusInDirection(currentNode, found, nearestScope, direction); + return _requestTraversalFocusInDirection( + currentNode, + found, + nearestScope, + direction, + groupNode, + ); } return false; } @@ -1321,27 +1367,31 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy { @mustCallSuper @override bool inDirection(FocusNode currentNode, TraversalDirection direction) { + final _FocusTraversalGroupNode? groupNode = FocusTraversalGroup._getGroupNode(currentNode); final FocusScopeNode nearestScope = currentNode.nearestScope!; final FocusNode? focusedChild = nearestScope.focusedChild; if (focusedChild == null) { - final FocusNode firstFocus = findFirstFocusInDirection(currentNode, direction) ?? currentNode; + final FocusNode firstFocus = + findFirstFocusInDirection(currentNode, direction) ?? currentNode; switch (direction) { case TraversalDirection.up: case TraversalDirection.left: - requestFocusCallback( + _requestFocus( firstFocus, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtStart, + groupNode, ); case TraversalDirection.right: case TraversalDirection.down: - requestFocusCallback( + _requestFocus( firstFocus, alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd, + groupNode, ); } return true; } - if (_popPolicyDataIfNeeded(direction, nearestScope, focusedChild)) { + if (_popPolicyDataIfNeeded(direction, nearestScope, focusedChild, groupNode)) { return true; } final FocusNode? found = _findNextFocusInDirection( @@ -1351,9 +1401,15 @@ mixin DirectionalFocusTraversalPolicyMixin on FocusTraversalPolicy { ); if (found != null) { _pushPolicyData(direction, nearestScope, focusedChild); - return _requestTraversalFocusInDirection(currentNode, found, nearestScope, direction); + return _requestTraversalFocusInDirection( + currentNode, + found, + nearestScope, + direction, + groupNode, + ); } - return _onEdgeForDirection(currentNode, focusedChild, direction); + return _onEdgeForDirection(currentNode, focusedChild, groupNode, direction); } } @@ -2206,8 +2262,11 @@ class FocusTraversalGroup extends StatefulWidget { } // A special focus node subclass that only FocusTraversalGroup uses so that it -// can be used to cache the policy in the focus tree, and so that the traversal -// code can find groups in the focus tree. +// can be used to cache the following in the focus tree: +// - the focus traversal policy - this allows traversal code to find groups in +// the focus tree. +// - the last focused node - this allows the policy to be invalidated when a +// focus change was not triggered via a traversal request. class _FocusTraversalGroupNode extends FocusNode { _FocusTraversalGroupNode({super.debugLabel, required this.policy}) { if (kFlutterMemoryAllocationsEnabled) { @@ -2216,6 +2275,8 @@ class _FocusTraversalGroupNode extends FocusNode { } FocusTraversalPolicy policy; + + FocusNode? lastRequestedFocus; } class _FocusTraversalGroupState extends State { @@ -2232,11 +2293,13 @@ class _FocusTraversalGroupState extends State { @override void initState() { super.initState(); + FocusManager.instance.addListener(_handleFocusChanged); widget.onFocusNodeCreated?.call(focusNode); } @override void dispose() { + FocusManager.instance.removeListener(_handleFocusChanged); focusNode.dispose(); super.dispose(); } @@ -2262,6 +2325,24 @@ class _FocusTraversalGroupState extends State { child: widget.child, ); } + + void _handleFocusChanged() { + final FocusNode? primaryFocus = FocusManager.instance.primaryFocus; + final FocusNode? lastRequestedFocus = focusNode.lastRequestedFocus; + + if (lastRequestedFocus == null) { + return; + } + + if (primaryFocus != lastRequestedFocus) { + FocusScopeNode? scope = primaryFocus?.nearestScope; + while (scope != null) { + widget.policy.invalidateScopeData(scope); + scope = scope.enclosingScope; + } + focusNode.lastRequestedFocus = null; + } + } } /// An intent for use with the [RequestFocusAction], which supplies the diff --git a/packages/flutter/lib/src/widgets/framework.dart b/packages/flutter/lib/src/widgets/framework.dart index 4ef22ee6b6d8d..2bc2e3f3012b7 100644 --- a/packages/flutter/lib/src/widgets/framework.dart +++ b/packages/flutter/lib/src/widgets/framework.dart @@ -309,6 +309,7 @@ class GlobalObjectKey> extends GlobalKey { /// * [StatelessWidget], for widgets that always build the same way given a /// particular configuration and ambient state. @immutable +@pragma('track-creation-locations') abstract class Widget extends DiagnosticableTree { /// Initializes [key] for subclasses. const Widget({this.key}); diff --git a/packages/flutter/lib/src/widgets/media_query.dart b/packages/flutter/lib/src/widgets/media_query.dart index 03ed8f4f5bb02..47392bd517dc1 100644 --- a/packages/flutter/lib/src/widgets/media_query.dart +++ b/packages/flutter/lib/src/widgets/media_query.dart @@ -101,6 +101,9 @@ enum _MediaQueryAspect { /// Specifies the aspect corresponding to [MediaQueryData.disableAnimations]. disableAnimations, + /// Specifies the aspect corresponding to [MediaQueryData.reduceMotion]. + reduceMotion, + /// Specifies the aspect corresponding to [MediaQueryData.boldText]. boldText, @@ -228,6 +231,7 @@ class MediaQueryData { this.highContrast = false, this.onOffSwitchLabels = false, this.disableAnimations = false, + this.reduceMotion = false, this.boldText = false, this.supportsAnnounce = false, this.navigationMode = NavigationMode.traditional, @@ -319,6 +323,8 @@ class MediaQueryData { disableAnimations = platformData?.disableAnimations ?? view.platformDispatcher.accessibilityFeatures.disableAnimations, + reduceMotion = + platformData?.reduceMotion ?? view.platformDispatcher.accessibilityFeatures.reduceMotion, boldText = platformData?.boldText ?? view.platformDispatcher.accessibilityFeatures.boldText, supportsAnnounce = platformData?.supportsAnnounce ?? @@ -692,6 +698,29 @@ class MediaQueryData { /// originates. final bool disableAnimations; + /// Whether the platform is requesting that animations be reduced or replaced + /// with cross-fades in preference to motion effects. + /// + /// This corresponds to the iOS "Reduce Motion" accessibility setting. + /// + /// Unlike [disableAnimations], this flag does not automatically alter + /// framework animations such as those controlled via [AnimationController]. + /// Instead, it is intended to be read by widgets that want to tone down or + /// replace non-essential motion, for example by substituting a cross-fade + /// for a slide transition. + /// + /// When implementing custom animations, you should check this property and + /// adjust behavior accordingly; for example, by preferring a fade over + /// movement when it is true. + /// + /// See also: + /// + /// * [dart:ui.AccessibilityFeatures.reduceMotion], the underlying primitive + /// flag provided by the platform. + /// * [dart:ui.PlatformDispatcher.accessibilityFeatures], where the setting + /// originates. + final bool reduceMotion; + /// Whether the platform is requesting that text be drawn with a bold font /// weight. /// @@ -852,6 +881,7 @@ class MediaQueryData { bool? highContrast, bool? onOffSwitchLabels, bool? disableAnimations, + bool? reduceMotion, bool? invertColors, bool? accessibleNavigation, bool? boldText, @@ -879,6 +909,7 @@ class MediaQueryData { highContrast: highContrast ?? this.highContrast, onOffSwitchLabels: onOffSwitchLabels ?? this.onOffSwitchLabels, disableAnimations: disableAnimations ?? this.disableAnimations, + reduceMotion: reduceMotion ?? this.reduceMotion, accessibleNavigation: accessibleNavigation ?? this.accessibleNavigation, boldText: boldText ?? this.boldText, supportsAnnounce: supportsAnnounce ?? this.supportsAnnounce, @@ -927,6 +958,7 @@ class MediaQueryData { highContrast: highContrast, onOffSwitchLabels: onOffSwitchLabels, disableAnimations: disableAnimations, + reduceMotion: reduceMotion, accessibleNavigation: accessibleNavigation, boldText: boldText, supportsAnnounce: supportsAnnounce, @@ -962,6 +994,7 @@ class MediaQueryData { highContrast: highContrast, onOffSwitchLabels: onOffSwitchLabels, disableAnimations: disableAnimations, + reduceMotion: reduceMotion, accessibleNavigation: accessibleNavigation, boldText: boldText, supportsAnnounce: supportsAnnounce, @@ -1165,6 +1198,7 @@ class MediaQueryData { other.highContrast == highContrast && other.onOffSwitchLabels == onOffSwitchLabels && other.disableAnimations == disableAnimations && + other.reduceMotion == reduceMotion && other.invertColors == invertColors && other.accessibleNavigation == accessibleNavigation && other.boldText == boldText && @@ -1193,6 +1227,7 @@ class MediaQueryData { highContrast, onOffSwitchLabels, disableAnimations, + reduceMotion, invertColors, accessibleNavigation, boldText, @@ -1225,6 +1260,7 @@ class MediaQueryData { 'highContrast: $highContrast', 'onOffSwitchLabels: $onOffSwitchLabels', 'disableAnimations: $disableAnimations', + 'reduceMotion: $reduceMotion', 'invertColors: $invertColors', 'boldText: $boldText', 'navigationMode: ${navigationMode.name}', @@ -2026,6 +2062,28 @@ class MediaQuery extends InheritedModel<_MediaQueryAspect> { static bool? maybeDisableAnimationsOf(BuildContext context) => _maybeOf(context, _MediaQueryAspect.disableAnimations)?.disableAnimations; + /// Returns [MediaQueryData.reduceMotion] for the nearest [MediaQuery] + /// ancestor or false, if no such ancestor exists. + /// + /// Use of this method will cause the given [context] to rebuild any time that + /// the [MediaQueryData.reduceMotion] property of the ancestor + /// [MediaQuery] changes. + /// + /// {@macro flutter.widgets.media_query.MediaQuery.dontUseOf} + static bool reduceMotionOf(BuildContext context) => + _of(context, _MediaQueryAspect.reduceMotion).reduceMotion; + + /// Returns [MediaQueryData.reduceMotion] for the nearest [MediaQuery] + /// ancestor or null, if no such ancestor exists. + /// + /// Use of this method will cause the given [context] to rebuild any time that + /// the [MediaQueryData.reduceMotion] property of the ancestor + /// [MediaQuery] changes. + /// + /// {@macro flutter.widgets.media_query.MediaQuery.dontUseMaybeOf} + static bool? maybeReduceMotionOf(BuildContext context) => + _maybeOf(context, _MediaQueryAspect.reduceMotion)?.reduceMotion; + /// Returns the [MediaQueryData.boldText] accessibility setting for the /// nearest [MediaQuery] ancestor or false, if no such ancestor exists. /// @@ -2273,6 +2331,7 @@ class MediaQuery extends InheritedModel<_MediaQueryAspect> { data.onOffSwitchLabels != oldWidget.data.onOffSwitchLabels, _MediaQueryAspect.disableAnimations => data.disableAnimations != oldWidget.data.disableAnimations, + _MediaQueryAspect.reduceMotion => data.reduceMotion != oldWidget.data.reduceMotion, _MediaQueryAspect.boldText => data.boldText != oldWidget.data.boldText, _MediaQueryAspect.supportsAnnounce => data.supportsAnnounce != oldWidget.data.supportsAnnounce, diff --git a/packages/flutter/lib/src/widgets/routes.dart b/packages/flutter/lib/src/widgets/routes.dart index 5c0b9b127fa4c..ce419f0780f4c 100644 --- a/packages/flutter/lib/src/widgets/routes.dart +++ b/packages/flutter/lib/src/widgets/routes.dart @@ -2599,6 +2599,7 @@ class RawDialogRoute extends PopupRoute { String? barrierLabel, Duration transitionDuration = const Duration(milliseconds: 200), RouteTransitionsBuilder? transitionBuilder, + this.barrierBuilder, super.settings, super.requestFocus, this.anchorPoint, @@ -2632,6 +2633,11 @@ class RawDialogRoute extends PopupRoute { final RouteTransitionsBuilder? _transitionBuilder; + /// The [barrierBuilder] argument is used to define how the route's modal + /// barrier is built. If not null, this builder is used to wrap or replace + /// the default [ModalBarrier]. + final RouteBarrierBuilder? barrierBuilder; + /// {@macro flutter.widgets.DisplayFeatureSubScreen.anchorPoint} final Offset? anchorPoint; @@ -2675,6 +2681,26 @@ class RawDialogRoute extends PopupRoute { } return _transitionBuilder(context, animation, secondaryAnimation, child); } + + @override + Widget buildModalBarrier() { + final Widget barrier = super.buildModalBarrier(); + if (barrierBuilder != null) { + return Builder( + builder: (BuildContext context) => barrierBuilder!( + context, + RouteBarrierDetails( + animation: animation!, + barrierColor: barrierColor, + barrierLabel: barrierLabel, + barrierDismissible: barrierDismissible, + ), + barrier, + ), + ); + } + return barrier; + } } /// Displays a dialog above the current contents of the app. @@ -2720,6 +2746,10 @@ class RawDialogRoute extends PopupRoute { /// and leaves off the screen. By default, the transition is a linear fade of /// the page's contents. /// +/// The `barrierBuilder` argument is used to define how the route's modal +/// barrier is built. If not null, this builder is used to wrap or replace +/// the default [ModalBarrier]. +/// /// The `routeSettings` will be used in the construction of the dialog's route. /// See [RouteSettings] for more details. /// @@ -2765,6 +2795,7 @@ Future showGeneralDialog({ Color barrierColor = const Color(0x80000000), Duration transitionDuration = const Duration(milliseconds: 200), RouteTransitionsBuilder? transitionBuilder, + RouteBarrierBuilder? barrierBuilder, bool useRootNavigator = true, bool fullscreenDialog = false, RouteSettings? routeSettings, @@ -2780,6 +2811,7 @@ Future showGeneralDialog({ barrierColor: barrierColor, transitionDuration: transitionDuration, transitionBuilder: transitionBuilder, + barrierBuilder: barrierBuilder, settings: routeSettings, anchorPoint: anchorPoint, requestFocus: requestFocus, @@ -2822,6 +2854,55 @@ typedef RouteTransitionsBuilder = Widget child, ); +/// Configuration details for a custom modal barrier. +/// +/// Passed to a [RouteBarrierBuilder] by the routing framework to provide +/// the ambient variables associated with the route's modal barrier. +class RouteBarrierDetails { + /// Creates an object that contains the configuration for a modal barrier. + const RouteBarrierDetails({ + required this.animation, + this.barrierColor, + this.barrierLabel, + required this.barrierDismissible, + }); + + /// An animation that drives the route's transition. + /// + /// Typically used to animate the barrier's opacity from 0.0 to 1.0 when the + /// route is pushed. + final Animation animation; + + /// The color to paint behind the route. + /// + /// If null, the barrier will be transparent. + final Color? barrierColor; + + /// The semantic label used for the barrier. + /// + /// This is read out by accessibility tools (like TalkBack or VoiceOver) + /// when the barrier is focused to indicate what will happen when it is interacted with. + final String? barrierLabel; + + /// Whether touching the barrier will pop the current route off the [Navigator]. + /// + /// If true, the route will be dismissed when the barrier is tapped. + final bool barrierDismissible; +} + +/// Signature for the function that builds a custom modal barrier for a route. +/// +/// Used by [RawDialogRoute.barrierBuilder] and [showGeneralDialog] to wrap or +/// replace the default modal barrier. +/// +/// The `barrier` parameter is the default [ModalBarrier] (or [AnimatedModalBarrier]) +/// constructed by the framework. Custom builders should typically return a widget +/// that wraps this `barrier` (for instance, with a [Padding] or a [BackdropFilter]), +/// rather than replacing it entirely, to preserve the built-in semantics and +/// gestures. +typedef RouteBarrierBuilder = + Widget Function(BuildContext context, RouteBarrierDetails details, Widget barrier); + /// A callback type for informing that a navigation pop has been invoked, /// whether or not it was handled successfully. /// diff --git a/packages/flutter/lib/src/widgets/scroll_view.dart b/packages/flutter/lib/src/widgets/scroll_view.dart index fbe1415080821..8d8f6548c1896 100644 --- a/packages/flutter/lib/src/widgets/scroll_view.dart +++ b/packages/flutter/lib/src/widgets/scroll_view.dart @@ -587,13 +587,15 @@ abstract class ScrollView extends StatelessWidget { } } -/// A [ScrollView] that creates custom scroll effects using [slivers]. -/// -/// A [CustomScrollView] lets you supply [slivers] directly to create various -/// scrolling effects, such as lists, grids, and expanding headers. For example, -/// to create a scroll view that contains an expanding app bar followed by a -/// list and a grid, use a list of three slivers: [SliverAppBar], [SliverList], -/// and [SliverGrid]. +/// A [ScrollView] that combines multiple [slivers] in one scrollable view. +/// +/// A [CustomScrollView] lets you combine lists, grids, and other widgets in a +/// single scrollable view by supplying [slivers] directly. Slivers can +/// represent lists, grids, and expanding headers. Widgets that use the box +/// layout model can be included using a [SliverToBoxAdapter]. For example, to +/// create a scroll view that contains an expanding app bar followed by a list +/// and a grid, use a list of three slivers: [SliverAppBar], [SliverList], and +/// [SliverGrid]. /// /// [Widget]s in these [slivers] must produce [RenderSliver] objects. /// @@ -716,7 +718,8 @@ abstract class ScrollView extends StatelessWidget { /// * [IndexedSemantics], which allows annotating child lists with an index /// for scroll announcements. class CustomScrollView extends ScrollView { - /// Creates a [ScrollView] that creates custom scroll effects using slivers. + /// Creates a [ScrollView] that combines multiple slivers in one scrollable + /// view. /// /// See the [ScrollView] constructor for more details on these arguments. const CustomScrollView({ diff --git a/packages/flutter/lib/src/widgets/selectable_region.dart b/packages/flutter/lib/src/widgets/selectable_region.dart index be97aa3518951..bb34e8a722e30 100644 --- a/packages/flutter/lib/src/widgets/selectable_region.dart +++ b/packages/flutter/lib/src/widgets/selectable_region.dart @@ -525,7 +525,11 @@ class SelectableRegionState extends State void _handleFocusChanged() { if (!_focusNode.hasFocus) { - if (_webContextMenuEnabled) { + if (kIsWeb) { + // Detach regardless of the current (dynamic) _webContextMenuEnabled + // value: the browser context menu may have been disabled after this + // delegate attached, and detach is a no-op for a client that was + // never the active one. PlatformSelectableRegionContextMenu.detach(_selectionDelegate); } if (SchedulerBinding.instance.lifecycleState == AppLifecycleState.resumed) { @@ -540,8 +544,7 @@ class SelectableRegionState extends State _selectionStatusNotifier.value = SelectableRegionSelectionStatus.changing; _finalizeSelectableRegionStatus(); } - } - if (_webContextMenuEnabled) { + } else if (_webContextMenuEnabled) { PlatformSelectableRegionContextMenu.attach(_selectionDelegate); } } @@ -1935,6 +1938,9 @@ class SelectableRegionState extends State void dispose() { _selectable?.removeListener(_updateSelectionStatus); _selectable?.pushHandleLayers(null, null); + if (kIsWeb) { + PlatformSelectableRegionContextMenu.detach(_selectionDelegate); + } _selectionDelegate.dispose(); _selectionStatusNotifier.dispose(); // In case dispose was triggered before gesture end, remove the magnifier diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index 05f696b37ce29..8daf1d9e54fc4 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -548,22 +548,58 @@ class TextSelectionOverlay { } void _updateSelectionOverlay() { + final List endpoints = renderObject.getEndpointsForSelection(_selection); + assert(endpoints.isNotEmpty); + + final TextSelectionHandleType startHandleType; + final TextSelectionHandleType endHandleType; + if (_selection.isCollapsed) { + startHandleType = TextSelectionHandleType.collapsed; + endHandleType = TextSelectionHandleType.collapsed; + } else { + final TextDirection textDirection = renderObject.textDirection; + // UIKit keeps selection handles aligned with the field direction. + final preferRenderObjectDirectionForSelectionHandles = + defaultTargetPlatform == TargetPlatform.iOS; + final TextDirection startHandleDirection; + final TextDirection endHandleDirection; + // A non-collapsed selection might return fewer than two endpoints if the + // text layout lacks boxes for the selected range. This typically happens when: + // + // * Render lag: The overlay updated with a new editing value before the + // render object laid out the new text (selection offsets are out of bounds). + // * Split graphemes: A selection boundary falls inside a multi-code-unit + // cluster (like an emoji or combining character). + // * Degenerate layout: The layout is temporarily squashed (e.g., + // preferredLineHeight is 0 during a fold transition). + // + // In these cases, we fall back to the field's textDirection. + if (preferRenderObjectDirectionForSelectionHandles || endpoints.length < 2) { + startHandleDirection = textDirection; + endHandleDirection = textDirection; + } else { + startHandleDirection = endpoints.first.direction ?? textDirection; + endHandleDirection = endpoints.last.direction ?? textDirection; + } + + startHandleType = switch (startHandleDirection) { + TextDirection.ltr => TextSelectionHandleType.left, + TextDirection.rtl => TextSelectionHandleType.right, + }; + endHandleType = switch (endHandleDirection) { + TextDirection.ltr => TextSelectionHandleType.right, + TextDirection.rtl => TextSelectionHandleType.left, + }; + } + _selectionOverlay // Update selection handle metrics. - ..startHandleType = _chooseType( - renderObject.textDirection, - TextSelectionHandleType.left, - TextSelectionHandleType.right, - ) + ..startHandleType = startHandleType ..lineHeightAtStart = _getStartGlyphHeight() - ..endHandleType = _chooseType( - renderObject.textDirection, - TextSelectionHandleType.right, - TextSelectionHandleType.left, - ) + ..endHandleType = endHandleType ..lineHeightAtEnd = _getEndGlyphHeight() // Update selection toolbar metrics. - ..selectionEndpoints = renderObject.getEndpointsForSelection(_selection) + ..selectionEndpoints = endpoints ..toolbarLocation = renderObject.lastSecondaryTapDownPosition; } @@ -1046,21 +1082,6 @@ class TextSelectionOverlay { SelectionChangedCause.drag, ); } - - TextSelectionHandleType _chooseType( - TextDirection textDirection, - TextSelectionHandleType ltrType, - TextSelectionHandleType rtlType, - ) { - if (_selection.isCollapsed) { - return TextSelectionHandleType.collapsed; - } - - return switch (textDirection) { - TextDirection.ltr => ltrType, - TextDirection.rtl => rtlType, - }; - } } /// An object that manages a pair of selection handles and a toolbar. diff --git a/packages/flutter/lib/src/widgets/widget_inspector.dart b/packages/flutter/lib/src/widgets/widget_inspector.dart index a27db16b35e3c..a81057ca949d3 100644 --- a/packages/flutter/lib/src/widgets/widget_inspector.dart +++ b/packages/flutter/lib/src/widgets/widget_inspector.dart @@ -27,7 +27,6 @@ import 'dart:ui' import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; -import 'package:meta/meta_meta.dart'; import 'basic.dart'; import 'binding.dart'; @@ -1640,7 +1639,7 @@ mixin WidgetInspectorService { void _notifyToolsOfSelection(Object? object, {bool restrictToProjectFiles = false}) { inspect(object); - final _Location? location = _getSelectedWidgetLocation( + final developer.CreationLocation? location = _getSelectedWidgetLocation( restrictToSummaryTree: restrictToProjectFiles, ); if (location != null) { @@ -1795,7 +1794,7 @@ mixin WidgetInspectorService { } bool _isValueCreatedByLocalProject(Object? value) { - final _Location? creationLocation = _getCreationLocation(value); + final developer.CreationLocation? creationLocation = _getCreationLocation(value); if (creationLocation == null) { return false; } @@ -2481,7 +2480,7 @@ mixin WidgetInspectorService { /// not in the summary tree (i.e. not created by the current project), this /// method will instead return the location of its nearest ancestor widget /// that is in the summary tree. - _Location? _getSelectedWidgetLocation({bool restrictToSummaryTree = false}) { + developer.CreationLocation? _getSelectedWidgetLocation({bool restrictToSummaryTree = false}) { final DiagnosticsNode? selectedNode = restrictToSummaryTree ? _getSelectedSummaryDiagnosticsNode(null) : _getSelectedWidgetDiagnosticsNode(null); @@ -2519,7 +2518,7 @@ mixin WidgetInspectorService { /// /// {@macro flutter.widgets.WidgetInspectorService.getChildrenSummaryTree} bool isWidgetCreationTracked() { - _widgetCreationTracked ??= const _WidgetForTypeTests() is _HasCreationLocation; + _widgetCreationTracked ??= (developer.CreationLocation.of(const _WidgetForTypeTests()) != null); return _widgetCreationTracked!; } @@ -2642,7 +2641,7 @@ class _LocationCount { /// Whether the location is local to the current project. final bool local; - final _Location location; + final developer.CreationLocation location; int get count => _count; int _count = 0; @@ -2694,11 +2693,7 @@ class _ElementLocationStatsTracker { /// the creation location is local to the current project. void add(Element element) { final Object widget = element.widget; - if (widget is! _HasCreationLocation) { - return; - } - final _HasCreationLocation creationLocationSource = widget; - final _Location? location = creationLocationSource._location; + final developer.CreationLocation? location = developer.CreationLocation.of(widget); if (location == null) { return; } @@ -2772,7 +2767,7 @@ class _ElementLocationStatsTracker { // Add all newly used location ids to the JSON. final locationsJson = >{}; for (final _LocationCount entry in newLocations) { - final _Location location = entry.location; + final developer.CreationLocation location = entry.location; final List jsonForFile = locationsJson.putIfAbsent(location.file, () => []); jsonForFile ..add(entry.id) @@ -2786,7 +2781,7 @@ class _ElementLocationStatsTracker { if (newLocations.isNotEmpty) { final fileLocationsMap = >>{}; for (final _LocationCount entry in newLocations) { - final _Location location = entry.location; + final developer.CreationLocation location = entry.location; final Map> locations = fileLocationsMap.putIfAbsent( location.file, () => >{ @@ -4171,45 +4166,6 @@ class _ExitWidgetSelectionTooltipPainter extends CustomPainter { } } -/// Interface for classes that track the source code location the their -/// constructor was called from. -/// -/// {@macro flutter.widgets.WidgetInspectorService.getChildrenSummaryTree} -// ignore: unused_element -abstract class _HasCreationLocation { - _Location? get _location; -} - -/// A tuple with file, line, and column number, for displaying human-readable -/// file locations. -class _Location { - const _Location({ - required this.file, - required this.line, - required this.column, - this.name, // ignore: unused_element_parameter - }); - - /// File path of the location. - final String file; - - /// 1-based line number. - final int line; - - /// 1-based column number. - final int column; - - /// Optional name of the parameter or function at this location. - final String? name; - - Map toJsonMap() { - return {'file': file, 'line': line, 'column': column, 'name': ?name}; - } - - @override - String toString() => [?name, file, '$line', '$column'].join(':'); -} - bool _isDebugCreator(DiagnosticsNode node) => node is DiagnosticsDebugCreator; /// Transformer to parse and gather information about [DiagnosticsDebugCreator]. @@ -4369,7 +4325,7 @@ class DevToolsDeepLinkProperty extends DiagnosticsProperty { bool debugIsLocalCreationLocation(Object object) { var isLocal = false; assert(() { - final _Location? location = _getCreationLocation(object); + final developer.CreationLocation? location = _getCreationLocation(object); if (location != null) { isLocal = WidgetInspectorService.instance._isLocalCreationLocation(location.file); } @@ -4383,7 +4339,7 @@ bool debugIsLocalCreationLocation(Object object) { /// This is a faster variant of `debugIsLocalCreationLocation` that is available /// in debug and profile builds but only works for [Widget]. bool debugIsWidgetLocalCreation(Widget widget) { - final _Location? location = _getObjectCreationLocation(widget); + final developer.CreationLocation? location = developer.CreationLocation.of(widget); return location != null && WidgetInspectorService.instance._isLocalCreationLocation(location.file); } @@ -4396,31 +4352,27 @@ bool debugIsWidgetLocalCreation(Widget widget) { /// /// Currently creation locations are only available for [Widget] and [Element]. String? _describeCreationLocation(Object object) { - final _Location? location = _getCreationLocation(object); + final developer.CreationLocation? location = _getCreationLocation(object); return location?.toString(); } -_Location? _getObjectCreationLocation(Object object) { - return object is _HasCreationLocation ? object._location : null; -} - /// Returns the creation location of an object if one is available. /// /// {@macro flutter.widgets.WidgetInspectorService.getChildrenSummaryTree} /// /// Currently creation locations are only available for [Widget] and [Element]. -_Location? _getCreationLocation(Object? object) { +developer.CreationLocation? _getCreationLocation(Object? object) { final Object? candidate = object is Element && !object.debugIsDefunct ? object.widget : object; - return candidate == null ? null : _getObjectCreationLocation(candidate); + return candidate == null ? null : developer.CreationLocation.of(candidate); } -// _Location objects are always const so we don't need to worry about the GC +// CreationLocation objects are always const so we don't need to worry about the GC // issues that are a concern for other object ids tracked by // [WidgetInspectorService]. -final Map<_Location, int> _locationToId = <_Location, int>{}; -final List<_Location> _locations = <_Location>[]; +final Map _locationToId = {}; +final List _locations = []; -int _toLocationId(_Location location) { +int _toLocationId(developer.CreationLocation location) { int? id = _locationToId[location]; if (id != null) { return id; @@ -4438,8 +4390,8 @@ Map _locationIdMapToJson() { const namesKey = 'names'; final fileLocationsMap = >>{}; - for (final MapEntry<_Location, int> entry in _locationToId.entries) { - final _Location location = entry.key; + for (final MapEntry entry in _locationToId.entries) { + final developer.CreationLocation location = entry.key; final Map> locations = fileLocationsMap.putIfAbsent( location.file, () => >{ @@ -4527,7 +4479,7 @@ class InspectorSerializationDelegate implements DiagnosticsSerializationDelegate if (_interactive) { result['valueId'] = service.toId(value, groupName!); } - final _Location? creationLocation = _getCreationLocation(value); + final developer.CreationLocation? creationLocation = _getCreationLocation(value); if (creationLocation != null) { if (fullDetails) { result['locationId'] = _toLocationId(creationLocation); @@ -4601,11 +4553,6 @@ class InspectorSerializationDelegate implements DiagnosticsSerializationDelegate } } -@Target({TargetKind.method}) -class _WidgetFactory { - const _WidgetFactory(); -} - /// Annotation which marks a function as a widget factory for the purpose of /// widget creation tracking. /// @@ -4660,11 +4607,7 @@ class _WidgetFactory { /// See also: /// /// * the documentation for [Track widget creation](https://flutter.dev/to/track-widget-creation). -// The below ignore is needed because the static type of the annotation is used -// by the CFE kernel transformer that implements the instrumentation to -// recognize the annotation. -// ignore: library_private_types_in_public_api -const _WidgetFactory widgetFactory = _WidgetFactory(); +const widgetFactory = pragma('track-creation-locations'); /// Does not hold keys from garbage collection. @visibleForTesting diff --git a/packages/flutter/pubspec.yaml b/packages/flutter/pubspec.yaml index 9f1feeea5d19f..cb588fc0192a6 100644 --- a/packages/flutter/pubspec.yaml +++ b/packages/flutter/pubspec.yaml @@ -18,7 +18,7 @@ dependencies: # See https://github.com/flutter/flutter/issues/185017 material_color_utilities: 0.13.0 meta: ^1.19.0 - vector_math: ^2.4.0 + vector_math: ^2.4.2 sky_engine: sdk: flutter @@ -42,4 +42,4 @@ dev_dependencies: path: any platform: any -# PUBSPEC CHECKSUM: ufigpi +# PUBSPEC CHECKSUM: sb0dvh diff --git a/packages/flutter/test/painting/box_decoration_test.dart b/packages/flutter/test/painting/box_decoration_test.dart index 5a8a9a5e2312d..c83ba00b8ad37 100644 --- a/packages/flutter/test/painting/box_decoration_test.dart +++ b/packages/flutter/test/painting/box_decoration_test.dart @@ -174,6 +174,16 @@ void main() { expect(clipPath, isLookLikeExpectedPath); }); + test('BoxDecoration.hitTest with shape BoxShape.circle', () { + const decoration = BoxDecoration(shape: BoxShape.circle); + const size = Size(100.0, 20.0); + + expect(decoration.hitTest(size, const Offset(50.0, 0.0)), isTrue); + expect(decoration.hitTest(size, const Offset(40.0, 10.0)), isTrue); + expect(decoration.hitTest(size, const Offset(40.0, 0.0)), isFalse); + expect(decoration.hitTest(size, const Offset(10.0, 10.0)), isFalse); + }); + test('BoxDecorations with different blendModes are not equal', () { // Regression test for https://github.com/flutter/flutter/issues/100754. const one = BoxDecoration(color: Color(0x00000000), backgroundBlendMode: BlendMode.color); diff --git a/packages/flutter/test/painting/shape_decoration_test.dart b/packages/flutter/test/painting/shape_decoration_test.dart index 62575abef04f4..b446cc1b9e52e 100644 --- a/packages/flutter/test/painting/shape_decoration_test.dart +++ b/packages/flutter/test/painting/shape_decoration_test.dart @@ -99,6 +99,92 @@ void main() { expect(b.hitTest(size, const Offset(20.0, 50.0)), isTrue); }); + test('ShapeBorder.hitTest defaults to getOuterPath', () { + _outerPathCount = 0; + const ShapeBorder border = _PathHitTestBorder(); + const rect = Rect.fromLTWH(10.0, 20.0, 100.0, 50.0); + + expect(border.hitTest(rect, const Offset(20.0, 30.0)), isTrue); + expect(border.hitTest(rect, Offset.zero), isFalse); + expect(_outerPathCount, 2); + }); + + test('ShapeDecoration.hitTest delegates to ShapeBorder.hitTest', () { + _hitTestCount = 0; + const decoration = ShapeDecoration(shape: _HitTestBorder()); + + expect(decoration.hitTest(const Size(100.0, 100.0), const Offset(50.0, 50.0)), isFalse); + expect(_hitTestCount, 1); + }); + + test('ShapeBorder.hitTest matches getOuterPath for primitive shapes', () { + const rect = Rect.fromLTWH(0.0, 0.0, 120.0, 80.0); + const TextDirection textDirection = TextDirection.ltr; + const positions = [ + Offset(-10.0, 40.0), // Outside every shape. + Offset(1.0, 1.0), // Distinguishes square and rounded corners. + Offset(0.5, 14.5), // Distinguishes RRect and superellipse corners. + Offset(1.0, 35.0), // Exercises the left edge of wide shapes. + Offset(1.0, 68.0), // Exercises the directional bottom-left corner. + Offset(11.0, 35.0), // Exercises CircleBorder eccentricity. + Offset(14.5, 13.5), // Distinguishes interpolated outer shapes. + Offset(21.0, 35.0), // Inside every shape. + ]; + final borders = [ + Border.all(), + const CircleBorder(), + const CircleBorder(eccentricity: 0.5), + const OvalBorder(), + const RoundedRectangleBorder(), + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(18.0))), + const RoundedRectangleBorder( + borderRadius: BorderRadiusDirectional.only(topStart: Radius.circular(18.0)), + ), + const RoundedSuperellipseBorder(), + const RoundedSuperellipseBorder(borderRadius: BorderRadius.all(Radius.circular(18.0))), + const StadiumBorder(), + ShapeBorder.lerp( + const CircleBorder(), + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(18.0))), + 0.5, + )!, + ShapeBorder.lerp( + const CircleBorder(), + const RoundedSuperellipseBorder(borderRadius: BorderRadius.all(Radius.circular(18.0))), + 0.5, + )!, + ShapeBorder.lerp(const StadiumBorder(), const CircleBorder(), 0.5)!, + ShapeBorder.lerp( + const StadiumBorder(), + const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(18.0))), + 0.5, + )!, + ]; + + // Every position is tested against every border, so these collections are + // intentionally independent rather than paired test cases. + for (final border in borders) { + for (final position in positions) { + expect( + border.hitTest(rect, position, textDirection: textDirection), + border.getOuterPath(rect, textDirection: textDirection).contains(position), + reason: '$border at $position', + ); + } + } + }); + + test('_CompoundBorder.hitTest preserves child hitTest optimizations', () { + _hitTestCount = 0; + final ShapeBorder compoundBorder = const RoundedRectangleBorder() + const _HitTestBorder(); + + expect( + compoundBorder.hitTest(const Rect.fromLTWH(0.0, 0.0, 100.0, 100.0), Offset.zero), + isFalse, + ); + expect(_hitTestCount, 1); + }); + test('ShapeDecoration.lerp between gradient and color is smooth and does not throw', () { // Regression test for https://github.com/flutter/flutter/issues/93953 const colorR = Color(0xffff0000); @@ -224,3 +310,60 @@ class TestImageProvider extends ImageProvider { return OneFrameImageStreamCompleter(SynchronousFuture(ImageInfo(image: image))); } } + +int _outerPathCount = 0; + +class _PathHitTestBorder extends ShapeBorder { + const _PathHitTestBorder(); + + @override + EdgeInsetsGeometry get dimensions => EdgeInsets.zero; + + @override + ShapeBorder scale(double t) => this; + + @override + Path getInnerPath(Rect rect, {TextDirection? textDirection}) { + throw StateError('ShapeBorder.hitTest should not call getInnerPath.'); + } + + @override + Path getOuterPath(Rect rect, {TextDirection? textDirection}) { + _outerPathCount += 1; + return Path()..addRect(rect); + } + + @override + void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) {} +} + +int _hitTestCount = 0; + +class _HitTestBorder extends ShapeBorder { + const _HitTestBorder(); + + @override + EdgeInsetsGeometry get dimensions => EdgeInsets.zero; + + @override + ShapeBorder scale(double t) => this; + + @override + Path getInnerPath(Rect rect, {TextDirection? textDirection}) { + throw StateError('ShapeDecoration.hitTest should not call getInnerPath.'); + } + + @override + Path getOuterPath(Rect rect, {TextDirection? textDirection}) { + throw StateError('hitTest should not call getOuterPath directly.'); + } + + @override + bool hitTest(Rect rect, Offset position, {TextDirection? textDirection}) { + _hitTestCount += 1; + return false; + } + + @override + void paint(Canvas canvas, Rect rect, {TextDirection? textDirection}) {} +} diff --git a/packages/flutter/test/rendering/proxy_box_test.dart b/packages/flutter/test/rendering/proxy_box_test.dart index c0c8340aff1f2..86e7c888082e3 100644 --- a/packages/flutter/test/rendering/proxy_box_test.dart +++ b/packages/flutter/test/rendering/proxy_box_test.dart @@ -78,6 +78,25 @@ void main() { expect(root.needsCompositing, isFalse); }); + test('RenderPhysicalModel paints shadow only for non-zero elevation', () { + final root = RenderPhysicalModel( + color: const Color(0xffff00ff), + child: RenderSizedBox(const Size(10.0, 10.0)), + ); + layout(root, phase: EnginePhase.paint); + expect( + (PaintingContext context, Offset offset) => root.paint(context, offset), + paintsExactlyCountTimes(#drawShadow, 0), + ); + + root.elevation = 1.0; + pumpFrame(phase: EnginePhase.paint); + expect( + (PaintingContext context, Offset offset) => root.paint(context, offset), + paintsExactlyCountTimes(#drawShadow, 1), + ); + }); + test('RenderSemanticsGestureHandler adds/removes correct semantic actions', () { final renderObj = RenderSemanticsGestureHandler( onTap: () {}, @@ -1070,6 +1089,23 @@ void main() { expect(backdropFilter.filterConfig, equals(filterConfig1)); expect(() => backdropFilter.filter, throwsAssertionError); }); + + test('RenderProxyBoxMixin.computeDryBaseline returns null when the child has no baseline', () { + // Regression test for https://github.com/flutter/flutter/issues/189711 + final child = _RenderNoBaseline(); + final proxy = RenderSemanticsAnnotations( + child: child, + properties: const SemanticsProperties(), + ); + layout(proxy); + expect( + proxy.getDryBaseline( + const BoxConstraints.tightFor(width: 40.0, height: 20.0), + TextBaseline.alphabetic, + ), + isNull, + ); + }); } class _TestRectClipper extends CustomClipper { @@ -1206,3 +1242,20 @@ class RenderBoxWithTestConstraints extends RenderProxyBox { return constraints.constrain(Size.square(constraints.testValue)); } } + +class _RenderNoBaseline extends RenderBox { + @override + void performLayout() { + size = constraints.constrain(const Size(40.0, 20.0)); + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + return constraints.constrain(const Size(40.0, 20.0)); + } + + @override + double? computeDryBaseline(BoxConstraints constraints, TextBaseline baseline) { + return null; + } +} diff --git a/packages/flutter/test/widgets/editable_text_tester.dart b/packages/flutter/test/widgets/editable_text_tester.dart index c631f4b240d41..61c41298bd185 100644 --- a/packages/flutter/test/widgets/editable_text_tester.dart +++ b/packages/flutter/test/widgets/editable_text_tester.dart @@ -41,6 +41,7 @@ class TestTextField extends StatefulWidget { this.controller, this.onSubmitted, this.showSelectionHandles = false, + this.selectAllOnFocus, }); final Iterable? autofillHints; @@ -60,6 +61,11 @@ class TestTextField extends StatefulWidget { final ValueChanged? onSubmitted; final bool showSelectionHandles; + /// Controls whether all text is selected when the field receives focus. + /// + /// When null, [EditableText] platform defaults apply. + final bool? selectAllOnFocus; + @override State createState() => _TestTextFieldState(); } @@ -127,6 +133,7 @@ class _TestTextFieldState extends State style: widget.style ?? const TextStyle(), // required by editable text. controller: _effectiveController, // required by editable text. showSelectionHandles: widget.showSelectionHandles, + selectAllOnFocus: widget.selectAllOnFocus, ), ), ); diff --git a/packages/flutter/test/widgets/focus_traversal_test.dart b/packages/flutter/test/widgets/focus_traversal_test.dart index e16e5247082be..0d5697b8e571c 100644 --- a/packages/flutter/test/widgets/focus_traversal_test.dart +++ b/packages/flutter/test/widgets/focus_traversal_test.dart @@ -1805,7 +1805,7 @@ void main() { }); testWidgets('Directional focus avoids hysteresis.', (WidgetTester tester) async { - var focus = List.generate(6, (int _) => null); + List focus = _createFocusTracker(6); final nodes = List.generate( 6, (int index) => FocusNode(debugLabel: 'Node $index'), @@ -1858,7 +1858,7 @@ void main() { ); void clear() { - focus = List.generate(focus.length, (int _) => null); + focus = _createFocusTracker(focus.length); } final FocusNode scope = nodes[0].enclosingScope!; @@ -1922,12 +1922,106 @@ void main() { clear(); }); + // Regression test for https://github.com/flutter/flutter/issues/85941. + testWidgets('Directional focus history is cleared on explicit focus request', ( + WidgetTester tester, + ) async { + List focus = _createFocusTracker(5); + final nodes = List.generate( + 5, + (int index) => FocusNode(debugLabel: 'Node $index'), + ); + addTearDown(() { + for (final node in nodes) { + node.dispose(); + } + }); + + Widget makeFocus(int index) { + return Focus( + debugLabel: '[$index]', + focusNode: nodes[index], + onFocusChange: (bool isFocused) => focus[index] = isFocused, + child: const SizedBox(width: 100, height: 100), + ); + } + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: FocusTraversalGroup( + policy: WidgetOrderTraversalPolicy(), + child: FocusScope( + debugLabel: 'Scope', + child: Column( + children: [ + makeFocus(0), + makeFocus(1), + makeFocus(2), + makeFocus(3), + makeFocus(4), + ], + ), + ), + ), + ), + ); + + void clear() { + focus = _createFocusTracker(focus.length); + } + + final FocusNode scope = nodes[0].enclosingScope!; + nodes[0].requestFocus(); + await tester.pump(); + clear(); + + // Move down three times. + expect(scope.focusInDirection(TraversalDirection.down), isTrue); + await tester.pump(); + expect(focus, orderedEquals([false, true, null, null, null])); + clear(); + + expect(scope.focusInDirection(TraversalDirection.down), isTrue); + await tester.pump(); + expect(focus, orderedEquals([null, false, true, null, null])); + clear(); + + expect(scope.focusInDirection(TraversalDirection.down), isTrue); + await tester.pump(); + expect(focus, orderedEquals([null, null, false, true, null])); + clear(); + + // Shift focus externally back to [1]. + nodes[1].requestFocus(); + await tester.pump(); + expect(focus, orderedEquals([null, true, null, false, null])); + clear(); + + // Move down once. + expect(scope.focusInDirection(TraversalDirection.down), isTrue); + await tester.pump(); + expect(focus, orderedEquals([null, false, true, null, null])); + clear(); + + // Move up twice. + expect(scope.focusInDirection(TraversalDirection.up), isTrue); + await tester.pump(); + expect(focus, orderedEquals([null, true, false, null, null])); + clear(); + + expect(scope.focusInDirection(TraversalDirection.up), isTrue); + await tester.pump(); + expect(focus, orderedEquals([true, false, null, null, null])); + clear(); + }); + testWidgets('Directional prefers the closest node even on irregular grids', ( WidgetTester tester, ) async { const cols = 3; const rows = 3; - var focus = List.generate(rows * cols, (int _) => null); + List focus = _createFocusTracker(rows * cols); final nodes = List.generate( rows * cols, (int index) => FocusNode(debugLabel: 'Node $index'), @@ -1989,7 +2083,7 @@ void main() { ); void clear() { - focus = List.generate(focus.length, (int _) => null); + focus = _createFocusTracker(focus.length); } final FocusNode scope = nodes[0].enclosingScope!; @@ -2060,7 +2154,7 @@ void main() { WidgetTester tester, ) async { const rows = 4; - var focus = List.generate(rows, (int _) => null); + List focus = _createFocusTracker(rows); final nodes = List.generate( rows, (int index) => FocusNode(debugLabel: 'Node $index'), @@ -2114,7 +2208,7 @@ void main() { ); void clear() { - focus = List.generate(focus.length, (int _) => null); + focus = _createFocusTracker(focus.length); } final FocusNode scope = nodes[0].enclosingScope!; @@ -2151,7 +2245,7 @@ void main() { WidgetTester tester, ) async { const cols = 4; - var focus = List.generate(cols, (int _) => null); + List focus = _createFocusTracker(cols); final nodes = List.generate( cols, (int index) => FocusNode(debugLabel: 'Node $index'), @@ -2205,7 +2299,7 @@ void main() { ); void clear() { - focus = List.generate(focus.length, (int _) => null); + focus = _createFocusTracker(focus.length); } final FocusNode scope = nodes[0].enclosingScope!; @@ -3881,7 +3975,7 @@ void main() { }); testWidgets('Edge cases for inDirection', (WidgetTester tester) async { - var focus = List.generate(6, (int _) => null); + List focus = _createFocusTracker(6); final nodes = List.generate(6, (int index) => FocusNode(debugLabel: 'Node $index')); final childScope = FocusScopeNode(debugLabel: 'Child Scope'); addTearDown(() { @@ -3952,7 +4046,7 @@ void main() { await pumpApp(); void clear() { - focus = List.generate(focus.length, (int _) => null); + focus = _createFocusTracker(focus.length); } Future resetTo(int index) async { @@ -4148,3 +4242,6 @@ class SkipAllButFirstAndLastPolicy extends FocusTraversalPolicy ]; } } + +/// Creates a list of [length] to track focus changes during the test suite. +List _createFocusTracker(int length) => List.generate(length, (int index) => null); diff --git a/packages/flutter/test/widgets/media_query_test.dart b/packages/flutter/test/widgets/media_query_test.dart index 97f9c0a06f085..355539e526765 100644 --- a/packages/flutter/test/widgets/media_query_test.dart +++ b/packages/flutter/test/widgets/media_query_test.dart @@ -155,6 +155,7 @@ void main() { expect(data.accessibleNavigation, false); expect(data.invertColors, false); expect(data.disableAnimations, false); + expect(data.reduceMotion, false); expect(data.boldText, false); expect(data.highContrast, false); expect(data.onOffSwitchLabels, false); @@ -172,6 +173,7 @@ void main() { accessibleNavigation: true, invertColors: true, disableAnimations: true, + reduceMotion: true, boldText: true, highContrast: true, onOffSwitchLabels: true, @@ -206,6 +208,7 @@ void main() { expect(data.accessibleNavigation, platformData.accessibleNavigation); expect(data.invertColors, platformData.invertColors); expect(data.disableAnimations, platformData.disableAnimations); + expect(data.reduceMotion, platformData.reduceMotion); expect(data.boldText, platformData.boldText); expect(data.highContrast, platformData.highContrast); expect(data.onOffSwitchLabels, platformData.onOffSwitchLabels); @@ -257,6 +260,7 @@ void main() { data.disableAnimations, tester.platformDispatcher.accessibilityFeatures.disableAnimations, ); + expect(data.reduceMotion, tester.platformDispatcher.accessibilityFeatures.reduceMotion); expect(data.boldText, tester.platformDispatcher.accessibilityFeatures.boldText); expect(data.highContrast, tester.platformDispatcher.accessibilityFeatures.highContrast); expect( @@ -283,6 +287,7 @@ void main() { accessibleNavigation: true, invertColors: true, disableAnimations: true, + reduceMotion: true, boldText: true, highContrast: true, onOffSwitchLabels: true, @@ -331,6 +336,7 @@ void main() { expect(data.accessibleNavigation, platformData.accessibleNavigation); expect(data.invertColors, platformData.invertColors); expect(data.disableAnimations, platformData.disableAnimations); + expect(data.reduceMotion, platformData.reduceMotion); expect(data.boldText, platformData.boldText); expect(data.highContrast, platformData.highContrast); expect(data.onOffSwitchLabels, platformData.onOffSwitchLabels); @@ -401,6 +407,7 @@ void main() { data.disableAnimations, tester.platformDispatcher.accessibilityFeatures.disableAnimations, ); + expect(data.reduceMotion, tester.platformDispatcher.accessibilityFeatures.reduceMotion); expect(data.boldText, tester.platformDispatcher.accessibilityFeatures.boldText); expect(data.highContrast, tester.platformDispatcher.accessibilityFeatures.highContrast); expect( @@ -592,6 +599,7 @@ void main() { expect(copied.accessibleNavigation, data.accessibleNavigation); expect(copied.invertColors, data.invertColors); expect(copied.disableAnimations, data.disableAnimations); + expect(copied.reduceMotion, data.reduceMotion); expect(copied.boldText, data.boldText); expect(copied.highContrast, data.highContrast); expect(copied.onOffSwitchLabels, data.onOffSwitchLabels); @@ -634,6 +642,7 @@ void main() { accessibleNavigation: true, invertColors: true, disableAnimations: true, + reduceMotion: true, boldText: true, highContrast: true, onOffSwitchLabels: true, @@ -654,6 +663,7 @@ void main() { expect(copied.accessibleNavigation, true); expect(copied.invertColors, true); expect(copied.disableAnimations, true); + expect(copied.reduceMotion, true); expect(copied.boldText, true); expect(copied.highContrast, true); expect(copied.onOffSwitchLabels, true); @@ -703,6 +713,7 @@ void main() { expect(updatedData.accessibleNavigation, data.accessibleNavigation); expect(updatedData.invertColors, data.invertColors); expect(updatedData.disableAnimations, data.disableAnimations); + expect(updatedData.reduceMotion, data.reduceMotion); expect(updatedData.boldText, data.boldText); expect(updatedData.highContrast, data.highContrast); expect(updatedData.onOffSwitchLabels, data.onOffSwitchLabels); @@ -747,6 +758,7 @@ void main() { expect(updatedData.accessibleNavigation, data.accessibleNavigation); expect(updatedData.invertColors, data.invertColors); expect(updatedData.disableAnimations, data.disableAnimations); + expect(updatedData.reduceMotion, data.reduceMotion); expect(updatedData.boldText, data.boldText); expect(updatedData.highContrast, data.highContrast); expect(updatedData.onOffSwitchLabels, data.onOffSwitchLabels); @@ -791,6 +803,7 @@ void main() { accessibleNavigation: true, invertColors: true, disableAnimations: true, + reduceMotion: true, boldText: true, highContrast: true, onOffSwitchLabels: true, @@ -828,6 +841,7 @@ void main() { expect(unpadded.accessibleNavigation, true); expect(unpadded.invertColors, true); expect(unpadded.disableAnimations, true); + expect(unpadded.reduceMotion, true); expect(unpadded.boldText, true); expect(unpadded.highContrast, true); expect(unpadded.onOffSwitchLabels, true); @@ -867,6 +881,7 @@ void main() { accessibleNavigation: true, invertColors: true, disableAnimations: true, + reduceMotion: true, boldText: true, highContrast: true, onOffSwitchLabels: true, @@ -901,6 +916,7 @@ void main() { expect(unpadded.accessibleNavigation, true); expect(unpadded.invertColors, true); expect(unpadded.disableAnimations, true); + expect(unpadded.reduceMotion, true); expect(unpadded.boldText, true); expect(unpadded.highContrast, true); expect(unpadded.onOffSwitchLabels, true); @@ -940,6 +956,7 @@ void main() { accessibleNavigation: true, invertColors: true, disableAnimations: true, + reduceMotion: true, boldText: true, highContrast: true, onOffSwitchLabels: true, @@ -977,6 +994,7 @@ void main() { expect(unpadded.accessibleNavigation, true); expect(unpadded.invertColors, true); expect(unpadded.disableAnimations, true); + expect(unpadded.reduceMotion, true); expect(unpadded.boldText, true); expect(unpadded.highContrast, true); expect(unpadded.onOffSwitchLabels, true); @@ -1016,6 +1034,7 @@ void main() { accessibleNavigation: true, invertColors: true, disableAnimations: true, + reduceMotion: true, boldText: true, highContrast: true, onOffSwitchLabels: true, @@ -1050,6 +1069,7 @@ void main() { expect(unpadded.accessibleNavigation, true); expect(unpadded.invertColors, true); expect(unpadded.disableAnimations, true); + expect(unpadded.reduceMotion, true); expect(unpadded.boldText, true); expect(unpadded.highContrast, true); expect(unpadded.onOffSwitchLabels, true); @@ -1089,6 +1109,7 @@ void main() { accessibleNavigation: true, invertColors: true, disableAnimations: true, + reduceMotion: true, boldText: true, highContrast: true, onOffSwitchLabels: true, @@ -1126,6 +1147,7 @@ void main() { expect(unpadded.accessibleNavigation, true); expect(unpadded.invertColors, true); expect(unpadded.disableAnimations, true); + expect(unpadded.reduceMotion, true); expect(unpadded.boldText, true); expect(unpadded.highContrast, true); expect(unpadded.onOffSwitchLabels, true); @@ -1165,6 +1187,7 @@ void main() { accessibleNavigation: true, invertColors: true, disableAnimations: true, + reduceMotion: true, boldText: true, highContrast: true, onOffSwitchLabels: true, @@ -1199,6 +1222,7 @@ void main() { expect(unpadded.accessibleNavigation, true); expect(unpadded.invertColors, true); expect(unpadded.disableAnimations, true); + expect(unpadded.reduceMotion, true); expect(unpadded.boldText, true); expect(unpadded.highContrast, true); expect(unpadded.onOffSwitchLabels, true); @@ -1531,6 +1555,7 @@ void main() { accessibleNavigation: true, invertColors: true, disableAnimations: true, + reduceMotion: true, boldText: true, highContrast: true, onOffSwitchLabels: true, @@ -1563,6 +1588,7 @@ void main() { expect(subScreenMediaQuery.accessibleNavigation, true); expect(subScreenMediaQuery.invertColors, true); expect(subScreenMediaQuery.disableAnimations, true); + expect(subScreenMediaQuery.reduceMotion, true); expect(subScreenMediaQuery.boldText, true); expect(subScreenMediaQuery.highContrast, true); expect(subScreenMediaQuery.onOffSwitchLabels, true); @@ -1610,6 +1636,7 @@ void main() { accessibleNavigation: true, invertColors: true, disableAnimations: true, + reduceMotion: true, boldText: true, highContrast: true, onOffSwitchLabels: true, @@ -1648,6 +1675,7 @@ void main() { expect(subScreenMediaQuery.accessibleNavigation, true); expect(subScreenMediaQuery.invertColors, true); expect(subScreenMediaQuery.disableAnimations, true); + expect(subScreenMediaQuery.reduceMotion, true); expect(subScreenMediaQuery.boldText, true); expect(subScreenMediaQuery.highContrast, true); expect(subScreenMediaQuery.onOffSwitchLabels, true); @@ -1929,6 +1957,11 @@ void main() { MediaQuery.maybeDisableAnimationsOf, MediaQueryData(disableAnimations: true), ), + const _MediaQueryAspectCase(MediaQuery.reduceMotionOf, MediaQueryData(reduceMotion: true)), + const _MediaQueryAspectCase( + MediaQuery.maybeReduceMotionOf, + MediaQueryData(reduceMotion: true), + ), const _MediaQueryAspectCase(MediaQuery.boldTextOf, MediaQueryData(boldText: true)), const _MediaQueryAspectCase(MediaQuery.maybeBoldTextOf, MediaQueryData(boldText: true)), const _MediaQueryAspectCase( diff --git a/packages/flutter/test/widgets/routes_test.dart b/packages/flutter/test/widgets/routes_test.dart index 81a2aedae03e8..4378907005679 100644 --- a/packages/flutter/test/widgets/routes_test.dart +++ b/packages/flutter/test/widgets/routes_test.dart @@ -1846,81 +1846,77 @@ void main() { expect(modalBarrierAnimation.value, _white); }); - testWidgets( - 'modal route semantics order', - (WidgetTester tester) async { - // Regression test for https://github.com/flutter/flutter/issues/46625. - final semantics = SemanticsTester(tester); - await tester.pumpWidget( - TestWidgetsApp( - home: Builder( - builder: (BuildContext context) { - return Center( - child: TestButton( - child: const Text('X'), - onPressed: () { - Navigator.of(context).push( - _TestDialogRouteWithCustomBarrierCurve( - child: const Text('Hello World'), - barrierLabel: 'test label', - barrierCurve: Curves.linear, - ), - ); - }, - ), - ); - }, - ), + testWidgets('modal route semantics order', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/46625. + final semantics = SemanticsTester(tester); + await tester.pumpWidget( + TestWidgetsApp( + home: Builder( + builder: (BuildContext context) { + return Center( + child: TestButton( + child: const Text('X'), + onPressed: () { + Navigator.of(context).push( + _TestDialogRouteWithCustomBarrierCurve( + child: const Text('Hello World'), + barrierLabel: 'test label', + barrierCurve: Curves.linear, + ), + ); + }, + ), + ); + }, ), - ); + ), + ); - await tester.tap(find.text('X')); - await tester.pumpAndSettle(); - expect(find.text('Hello World'), findsOneWidget); - - final expectedSemantics = TestSemantics.root( - children: [ - TestSemantics.rootChild( - id: 1, - rect: TestSemantics.fullScreen, - children: [ - TestSemantics( - id: 6, - rect: TestSemantics.fullScreen, - children: [ - TestSemantics( - id: 7, - rect: TestSemantics.fullScreen, - flags: [SemanticsFlag.scopesRoute], - children: [ - TestSemantics( - id: 8, - label: 'Hello World', - rect: TestSemantics.fullScreen, - textDirection: TextDirection.ltr, - ), - ], - ), - ], - ), - // Modal barrier is put after modal scope - TestSemantics( - id: 5, - rect: TestSemantics.fullScreen, - actions: [SemanticsAction.tap, SemanticsAction.dismiss], - label: 'test label', - textDirection: TextDirection.ltr, - ), - ], - ), - ], - ); + await tester.tap(find.text('X')); + await tester.pumpAndSettle(); + expect(find.text('Hello World'), findsOneWidget); + + final expectedSemantics = TestSemantics.root( + children: [ + TestSemantics.rootChild( + id: 1, + rect: TestSemantics.fullScreen, + children: [ + TestSemantics( + id: 6, + rect: TestSemantics.fullScreen, + children: [ + TestSemantics( + id: 7, + rect: TestSemantics.fullScreen, + flags: [SemanticsFlag.scopesRoute], + children: [ + TestSemantics( + id: 8, + label: 'Hello World', + rect: TestSemantics.fullScreen, + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ), + // Modal barrier is put after modal scope + TestSemantics( + id: 5, + rect: TestSemantics.fullScreen, + actions: [SemanticsAction.tap, SemanticsAction.dismiss], + label: 'test label', + textDirection: TextDirection.ltr, + ), + ], + ), + ], + ); - expect(semantics, hasSemantics(expectedSemantics)); - semantics.dispose(); - }, - variant: const TargetPlatformVariant({TargetPlatform.iOS}), - ); + expect(semantics, hasSemantics(expectedSemantics)); + semantics.dispose(); + }, variant: const TargetPlatformVariant({TargetPlatform.iOS})); testWidgets('focus traversal is correct when popping multiple pages simultaneously', ( WidgetTester tester, @@ -2823,6 +2819,65 @@ void main() { expect(FocusScope.of(tester.element(find.text('dialog'))).hasFocus, false); expect(focusNode.hasFocus, true); }); + + testWidgets('showGeneralDialog applies custom barrierBuilder', (WidgetTester tester) async { + const expectedPadding = 12.0; + const barrierKey = ValueKey('custom-barrier-padding'); + RouteBarrierDetails? capturedDetails; + + await tester.pumpWidget( + TestWidgetsApp( + home: Builder( + builder: (BuildContext context) { + return TestButton( + onPressed: () { + showGeneralDialog( + context: context, + barrierDismissible: true, + barrierLabel: 'barrier_label', + transitionDuration: Duration.zero, + barrierColor: _green, + barrierBuilder: + (BuildContext context, RouteBarrierDetails details, Widget barrier) { + capturedDetails = details; + return Padding( + key: barrierKey, + padding: const EdgeInsets.all(expectedPadding), + child: barrier, + ); + }, + pageBuilder: + ( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) => const SizedBox(), + ); + }, + child: const Text('Show Dialog'), + ); + }, + ), + ), + ); + + // Open the dialog. + await tester.tap(find.byType(TestButton)); + await tester.pumpAndSettle(); + + final Padding paddingWidget = tester.widget(find.byKey(barrierKey)); + expect(paddingWidget.padding, const EdgeInsets.all(expectedPadding)); + + final ModalBarrier barrierWidget = tester.widget( + find.descendant(of: find.byKey(barrierKey), matching: find.byType(ModalBarrier)), + ); + expect(barrierWidget.color, _green); + + expect(capturedDetails, isNotNull); + expect(capturedDetails!.barrierColor, _green); + expect(capturedDetails!.barrierDismissible, true); + expect(capturedDetails!.barrierLabel, 'barrier_label'); + }); } double _getOpacity(GlobalKey key, WidgetTester tester) { diff --git a/packages/flutter/test/widgets/scrollable_semantics_test.dart b/packages/flutter/test/widgets/scrollable_semantics_test.dart index 3617b7119176a..0bf5c643835da 100644 --- a/packages/flutter/test/widgets/scrollable_semantics_test.dart +++ b/packages/flutter/test/widgets/scrollable_semantics_test.dart @@ -35,14 +35,12 @@ class _PinnedHeaderDelegate extends SliverPersistentHeaderDelegate { const double _kToolbarHeight = 56.0; void main() { - SemanticsTester semantics; - setUp(() { debugResetSemanticsIdCounter(); }); testWidgets('scrollable exposes the correct semantic actions', (WidgetTester tester) async { - semantics = SemanticsTester(tester); + final semantics = SemanticsTester(tester); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, @@ -101,7 +99,7 @@ void main() { }); testWidgets('Vertical scrollable responds to scrollToOffset', (WidgetTester tester) async { - semantics = SemanticsTester(tester); + final semantics = SemanticsTester(tester); final controller = ScrollController(); await tester.pumpWidget( Directionality( @@ -132,7 +130,7 @@ void main() { }); testWidgets('Horizontal scrollable responds to scrollToOffset', (WidgetTester tester) async { - semantics = SemanticsTester(tester); + final semantics = SemanticsTester(tester); final controller = ScrollController(); await tester.pumpWidget( Directionality( @@ -166,7 +164,7 @@ void main() { testWidgets('Unscrollable scrollable does not respond to scrollToOffset', ( WidgetTester tester, ) async { - semantics = SemanticsTester(tester); + final semantics = SemanticsTester(tester); await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, @@ -185,7 +183,7 @@ void main() { testWidgets('Scrollable exposes implicit scrolling before dimensions are available', ( WidgetTester tester, ) async { - semantics = SemanticsTester(tester); + final semantics = SemanticsTester(tester); final controller = _NoDimensionsDuringSemanticsScrollController(); addTearDown(controller.dispose); @@ -212,7 +210,7 @@ void main() { testWidgets('scrollToOffset respects implicit scrolling configuration', ( WidgetTester tester, ) async { - semantics = SemanticsTester(tester); + final semantics = SemanticsTester(tester); final ScrollPhysics physics = _NoImplicitScrollingScrollPhysics(); await tester.pumpWidget( Directionality( @@ -233,7 +231,7 @@ void main() { }); testWidgets('showOnScreen works in scrollable', (WidgetTester tester) async { - semantics = SemanticsTester(tester); // enables semantics tree generation + final semantics = SemanticsTester(tester); // enables semantics tree generation const kItemHeight = 40.0; @@ -280,7 +278,7 @@ void main() { testWidgets('showOnScreen works with pinned app bar and sliver list', ( WidgetTester tester, ) async { - semantics = SemanticsTester(tester); // enables semantics tree generation + final semantics = SemanticsTester(tester); // enables semantics tree generation const kItemHeight = 100.0; const kExpandedAppBarHeight = 56.0; @@ -343,7 +341,7 @@ void main() { testWidgets('showOnScreen works with pinned app bar and individual slivers', ( WidgetTester tester, ) async { - semantics = SemanticsTester(tester); // enables semantics tree generation + final semantics = SemanticsTester(tester); // enables semantics tree generation const kItemHeight = 100.0; const kExpandedAppBarHeight = 256.0; @@ -401,7 +399,7 @@ void main() { }); testWidgets('correct scrollProgress', (WidgetTester tester) async { - semantics = SemanticsTester(tester); + final semantics = SemanticsTester(tester); await tester.pumpWidget( Directionality( @@ -452,7 +450,7 @@ void main() { }); testWidgets('correct scrollProgress for unbound', (WidgetTester tester) async { - semantics = SemanticsTester(tester); + final semantics = SemanticsTester(tester); await tester.pumpWidget( Directionality( @@ -513,7 +511,7 @@ void main() { }); testWidgets('Semantics tree is populated mid-scroll', (WidgetTester tester) async { - semantics = SemanticsTester(tester); + final semantics = SemanticsTester(tester); final children = List.generate( 80, @@ -571,7 +569,7 @@ void main() { expect(tester.binding.pipelineOwner.semanticsOwner, isNull); // Semantics on - semantics = SemanticsTester(tester); + var semantics = SemanticsTester(tester); await tester.pumpAndSettle(); expect(tester.binding.pipelineOwner.semanticsOwner, isNotNull); expect( @@ -624,7 +622,7 @@ void main() { }); testWidgets('brings item above leading edge to leading edge', (WidgetTester tester) async { - semantics = SemanticsTester(tester); // enables semantics tree generation + final semantics = SemanticsTester(tester); // enables semantics tree generation await tester.pumpWidget(widgetUnderTest); @@ -646,7 +644,7 @@ void main() { }); testWidgets('brings item below trailing edge to trailing edge', (WidgetTester tester) async { - semantics = SemanticsTester(tester); // enables semantics tree generation + final semantics = SemanticsTester(tester); // enables semantics tree generation await tester.pumpWidget(widgetUnderTest); @@ -670,7 +668,7 @@ void main() { testWidgets('does not change position of items already fully on-screen', ( WidgetTester tester, ) async { - semantics = SemanticsTester(tester); // enables semantics tree generation + final semantics = SemanticsTester(tester); // enables semantics tree generation await tester.pumpWidget(widgetUnderTest); @@ -747,7 +745,7 @@ void main() { }); testWidgets('brings item above leading edge to leading edge', (WidgetTester tester) async { - semantics = SemanticsTester(tester); // enables semantics tree generation + final semantics = SemanticsTester(tester); // enables semantics tree generation await tester.pumpWidget(widgetUnderTest); @@ -769,7 +767,7 @@ void main() { }); testWidgets('brings item below trailing edge to trailing edge', (WidgetTester tester) async { - semantics = SemanticsTester(tester); // enables semantics tree generation + final semantics = SemanticsTester(tester); // enables semantics tree generation await tester.pumpWidget(widgetUnderTest); @@ -793,7 +791,7 @@ void main() { testWidgets('does not change position of items already fully on-screen', ( WidgetTester tester, ) async { - semantics = SemanticsTester(tester); // enables semantics tree generation + final semantics = SemanticsTester(tester); // enables semantics tree generation await tester.pumpWidget(widgetUnderTest); @@ -818,7 +816,7 @@ void main() { testWidgets( 'transform of inner node from useTwoPaneSemantics scrolls correctly with nested scrollables', (WidgetTester tester) async { - semantics = SemanticsTester(tester); // enables semantics tree generation + final semantics = SemanticsTester(tester); // enables semantics tree generation // Context: https://github.com/flutter/flutter/issues/61631 await tester.pumpWidget( diff --git a/packages/flutter/test/widgets/selectable_region_context_menu_test.dart b/packages/flutter/test/widgets/selectable_region_context_menu_test.dart index 51248d7f7f2c6..bc4fccc21c1c9 100644 --- a/packages/flutter/test/widgets/selectable_region_context_menu_test.dart +++ b/packages/flutter/test/widgets/selectable_region_context_menu_test.dart @@ -5,6 +5,8 @@ @TestOn('browser') // This file contains web-only library. library; +import 'dart:js_interop'; + import 'package:flutter/foundation.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/services.dart'; @@ -142,6 +144,266 @@ void main() { expect((selectWordEvent.globalPosition.dy - 300).abs() < precisionErrorTolerance, isTrue); }, variant: _browserContextMenuEnabledVariants); + // Regression test for https://github.com/flutter/flutter/issues/189575. + testWidgets('right click does not dispatch event to previous stale client after losing focus', ( + WidgetTester tester, + ) async { + final int currentViewId = platformViewsRegistry.getNextPlatformViewId(); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + final spy = UniqueKey(); + await tester.pumpWidget( + TestWidgetsApp( + home: SelectableRegion( + focusNode: focusNode, + selectionControls: emptyTextSelectionControls, + child: SelectionSpy(key: spy), + ), + ), + ); + final element = fakePlatformViewRegistry.getViewById(currentViewId + 1) as web.HTMLElement; + expect(element, isNotNull); + focusNode.requestFocus(); + await tester.pump(); + focusNode.unfocus(); + await tester.pump(); + final RenderSelectionSpy renderSelectionSpy = tester.renderObject( + find.byKey(spy), + ); + renderSelectionSpy.events.clear(); + // Before the fix, losing focus re-attached the client instead of + // detaching it, so the right click below dispatched a + // SelectWordSelectionEvent to the stale, no-longer-focused client. + element.dispatchEvent( + web.MouseEvent('mousedown', web.MouseEventInit(button: 2, clientX: 200, clientY: 300)), + ); + expect(renderSelectionSpy.events, isEmpty); + }, variant: _browserContextMenuEnabledVariants); + + testWidgets('right click after the SelectableRegion is disposed does not crash', ( + WidgetTester tester, + ) async { + final int currentViewId = platformViewsRegistry.getNextPlatformViewId(); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + await tester.pumpWidget( + TestWidgetsApp( + home: SelectableRegion( + focusNode: focusNode, + selectionControls: emptyTextSelectionControls, + child: const SelectionSpy(), + ), + ), + ); + final element = fakePlatformViewRegistry.getViewById(currentViewId + 1) as web.HTMLElement; + expect(element, isNotNull); + focusNode.requestFocus(); + await tester.pump(); + expect(PlatformSelectableRegionContextMenu.debugActiveClient, isNotNull); + + // Removing the SelectableRegion disposes its state without ever + // losing focus on the externally-owned focus node, so only the + // dispose-time detach can clear the static reference. + await tester.pumpWidget(const TestWidgetsApp(home: SizedBox.shrink())); + + // Before the fix, the static active-client pointer outlived the + // disposed delegate, so this right click reached into its defunct + // render context and crashed instead of being a no-op. + web.Event? capturedError; + final JSExportedDartFunction onWindowError = (web.Event event) { + capturedError = event; + }.toJS; + web.window.addEventListener('error', onWindowError); + addTearDown(() => web.window.removeEventListener('error', onWindowError)); + element.dispatchEvent( + web.MouseEvent('mousedown', web.MouseEventInit(button: 2, clientX: 200, clientY: 300)), + ); + expect(tester.takeException(), isNull); + expect(capturedError, isNull, reason: 'window reported an uncaught error: $capturedError'); + }, variant: _browserContextMenuEnabledVariants); + + testWidgets('detach only clears the active client when detaching the active client', ( + WidgetTester tester, + ) async { + final focusNodeA = FocusNode(); + final focusNodeB = FocusNode(); + addTearDown(focusNodeA.dispose); + addTearDown(focusNodeB.dispose); + + await tester.pumpWidget( + TestWidgetsApp( + home: Column( + children: [ + SizedBox( + height: 100, + child: SelectableRegion( + focusNode: focusNodeA, + selectionControls: emptyTextSelectionControls, + child: const SelectionSpy(), + ), + ), + SizedBox( + height: 100, + child: SelectableRegion( + focusNode: focusNodeB, + selectionControls: emptyTextSelectionControls, + child: const SelectionSpy(), + ), + ), + ], + ), + ), + ); + + focusNodeA.requestFocus(); + await tester.pump(); + final SelectionContainerDelegate delegateA = + PlatformSelectableRegionContextMenu.debugActiveClient!; + + focusNodeB.requestFocus(); + await tester.pump(); + final SelectionContainerDelegate delegateB = + PlatformSelectableRegionContextMenu.debugActiveClient!; + expect(delegateB, isNot(same(delegateA))); + + // Detaching a client that is not the active client must not clear + // the active client. + PlatformSelectableRegionContextMenu.detach(delegateA); + expect(PlatformSelectableRegionContextMenu.debugActiveClient, same(delegateB)); + + // Detaching the active client must clear it. + PlatformSelectableRegionContextMenu.detach(delegateB); + expect(PlatformSelectableRegionContextMenu.debugActiveClient, isNull); + }, variant: _browserContextMenuEnabledVariants); + + testWidgets('losing focus detaches the client and does not reattach it', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + TestWidgetsApp( + home: SelectableRegion( + focusNode: focusNode, + selectionControls: emptyTextSelectionControls, + child: const SelectionSpy(), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pump(); + expect(PlatformSelectableRegionContextMenu.debugActiveClient, isNotNull); + + focusNode.unfocus(); + await tester.pump(); + + expect(PlatformSelectableRegionContextMenu.debugActiveClient, isNull); + }, variant: _browserContextMenuEnabledVariants); + + testWidgets('disposing a SelectableRegion detaches its client from the context menu', ( + WidgetTester tester, + ) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + TestWidgetsApp( + home: SelectableRegion( + focusNode: focusNode, + selectionControls: emptyTextSelectionControls, + child: const SelectionSpy(), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pump(); + expect(PlatformSelectableRegionContextMenu.debugActiveClient, isNotNull); + + // Removing the SelectableRegion disposes its state without ever + // losing focus on the externally-owned focus node, so only the + // dispose-time detach can clear the static reference. + await tester.pumpWidget(const TestWidgetsApp(home: SizedBox.shrink())); + + expect(PlatformSelectableRegionContextMenu.debugActiveClient, isNull); + }, variant: _browserContextMenuEnabledVariants); + + group('when the browser context menu is disabled after attaching', () { + setUp(() async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.contextMenu, + (MethodCall call) => Future.value(), + ); + }); + + tearDown(() async { + await BrowserContextMenu.enableContextMenu(); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.contextMenu, + null, + ); + }); + + testWidgets('losing focus still detaches the client', (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + TestWidgetsApp( + home: SelectableRegion( + focusNode: focusNode, + selectionControls: emptyTextSelectionControls, + child: const SelectionSpy(), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pump(); + expect(PlatformSelectableRegionContextMenu.debugActiveClient, isNotNull); + + // Disabling the browser context menu after the delegate attached + // must not prevent the eventual detach: _webContextMenuEnabled is + // re-evaluated dynamically and would otherwise report false here. + await BrowserContextMenu.disableContextMenu(); + + focusNode.unfocus(); + await tester.pump(); + + expect(PlatformSelectableRegionContextMenu.debugActiveClient, isNull); + }, variant: _browserContextMenuEnabledVariants); + + testWidgets('disposing still detaches the client', (WidgetTester tester) async { + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + TestWidgetsApp( + home: SelectableRegion( + focusNode: focusNode, + selectionControls: emptyTextSelectionControls, + child: const SelectionSpy(), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pump(); + expect(PlatformSelectableRegionContextMenu.debugActiveClient, isNotNull); + + await BrowserContextMenu.disableContextMenu(); + + // Removing the SelectableRegion disposes its state without ever + // losing focus on the externally-owned focus node, so only the + // dispose-time detach can clear the static reference. + await tester.pumpWidget(const TestWidgetsApp(home: SizedBox.shrink())); + + expect(PlatformSelectableRegionContextMenu.debugActiveClient, isNull); + }, variant: _browserContextMenuEnabledVariants); + }); + // Regression test for https://github.com/flutter/flutter/issues/157579 testWidgets('prevents default action of mousedown events', (WidgetTester tester) async { final int currentViewId = platformViewsRegistry.getNextPlatformViewId(); diff --git a/packages/flutter/test/widgets/sliver_tree_test.dart b/packages/flutter/test/widgets/sliver_tree_test.dart index 7b97a4d9eee6f..a48a373e5707f 100644 --- a/packages/flutter/test/widgets/sliver_tree_test.dart +++ b/packages/flutter/test/widgets/sliver_tree_test.dart @@ -1006,4 +1006,110 @@ void main() { await expectLater(find.byKey(key), matchesGoldenFile('sliver_tree.scrolling.1.png')); expect(tester.getTopLeft(find.byType(ColoredBox)), const Offset(0, -5)); }); + + group('Animating node clips children below its trailing edge', () { + // Regression test for https://github.com/flutter/flutter/issues/188305. + // While a node expands, its children must be clipped beneath the node (at the + // node's trailing edge) - including the first node, which used to clip at its + // leading edge and let its children paint over it. + const rowExtent = 40.0; + const animationDuration = Duration(seconds: 1); + + // The top edge of every clip rect the sliver paints. + List recordedClipTops(RenderObject renderObject) { + final tops = []; + final canvas = TestRecordingCanvas(); + final context = TestRecordingPaintingContext(canvas); + renderObject.paint(context, Offset.zero); + for (final RecordedInvocation recorded in canvas.invocations) { + if (recorded.invocation.memberName == #clipRect) { + tops.add((recorded.invocation.positionalArguments[0] as Rect).top); + } + } + return tops; + } + + Widget buildTree(TreeSliverController controller) { + final tree = >[ + TreeSliverNode( + 'First', + children: >[ + TreeSliverNode('First:0'), + TreeSliverNode('First:1'), + ], + ), + TreeSliverNode( + 'Second', + children: >[ + TreeSliverNode('Second:0'), + TreeSliverNode('Second:1'), + ], + ), + ]; + return Directionality( + textDirection: TextDirection.ltr, + child: CustomScrollView( + slivers: [ + TreeSliver( + tree: tree, + controller: controller, + treeRowExtentBuilder: (_, _) => rowExtent, + toggleAnimationStyle: const AnimationStyle( + curve: Curves.linear, + duration: animationDuration, + ), + treeNodeBuilder: + ( + BuildContext context, + TreeSliverNode node, + AnimationStyle animationStyle, + ) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => controller.toggleNode(node), + child: TreeSliver.defaultTreeNodeBuilder(context, node, animationStyle), + ); + }, + ), + ], + ), + ); + } + + testWidgets('first node (index 0) clips at its trailing edge', (WidgetTester tester) async { + final controller = TreeSliverController(); + await tester.pumpWidget(buildTree(controller)); + + await tester.tap(find.text('First')); + await tester.pump(); + await tester.pump(animationDuration ~/ 2); // Mid-animation. + + final RenderTreeSliver renderTree = tester.renderObject( + find.byType(TreeSliver), + ); + final List clipTops = recordedClipTops(renderTree); + // One row down (the node's trailing edge), not at the top (0.0). + expect(clipTops, isNotEmpty); + expect(clipTops, contains(rowExtent)); + expect(clipTops, isNot(contains(0.0))); + }); + + testWidgets('non-first node (index 1) clips at its trailing edge', (WidgetTester tester) async { + final controller = TreeSliverController(); + await tester.pumpWidget(buildTree(controller)); + + await tester.tap(find.text('Second')); + await tester.pump(); + await tester.pump(animationDuration ~/ 2); // Mid-animation. + + final RenderTreeSliver renderTree = tester.renderObject( + find.byType(TreeSliver), + ); + final List clipTops = recordedClipTops(renderTree); + // The second node is at index 1, so its trailing edge is two rows down. + expect(clipTops, isNotEmpty); + expect(clipTops, contains(rowExtent * 2)); + expect(clipTops, isNot(contains(0.0))); + }); + }); } diff --git a/packages/flutter/test/widgets/text_selection_test.dart b/packages/flutter/test/widgets/text_selection_test.dart index 193398bf24d69..4c33ff6fadd05 100644 --- a/packages/flutter/test/widgets/text_selection_test.dart +++ b/packages/flutter/test/widgets/text_selection_test.dart @@ -2087,6 +2087,179 @@ void main() { await tester.pump(); expect(find.byType(Placeholder), findsOneWidget); }, skip: kIsWeb); // [intended] On web, we use native context menus for text fields. + + const androidDirectionalityTestCases = <_DirectionalityTestCase>[ + _DirectionalityTestCase( + description: 'ambient LTR, English text', + ambientDirection: TextDirection.ltr, + text: 'Hello World', + selectionBase: 0, + selectionExtent: 5, + expectedStartEndpointDirection: TextDirection.ltr, + expectedEndEndpointDirection: TextDirection.ltr, + expectedStartHandleType: TextSelectionHandleType.left, + expectedEndHandleType: TextSelectionHandleType.right, + ), + _DirectionalityTestCase( + description: 'ambient RTL, English text', + ambientDirection: TextDirection.rtl, + text: 'Hello World', + selectionBase: 0, + selectionExtent: 5, + expectedStartEndpointDirection: TextDirection.ltr, + expectedEndEndpointDirection: TextDirection.ltr, + expectedStartHandleType: TextSelectionHandleType.left, + expectedEndHandleType: TextSelectionHandleType.right, + ), + _DirectionalityTestCase( + description: 'ambient RTL, Arabic text', + ambientDirection: TextDirection.rtl, + text: 'مرحبا بالعالم', + selectionBase: 0, + selectionExtent: 5, + expectedStartEndpointDirection: TextDirection.rtl, + expectedEndEndpointDirection: TextDirection.rtl, + expectedStartHandleType: TextSelectionHandleType.right, + expectedEndHandleType: TextSelectionHandleType.left, + ), + _DirectionalityTestCase( + description: 'ambient LTR, Arabic text', + ambientDirection: TextDirection.ltr, + text: 'مرحبا بالعالم', + selectionBase: 0, + selectionExtent: 5, + expectedStartEndpointDirection: TextDirection.rtl, + expectedEndEndpointDirection: TextDirection.rtl, + expectedStartHandleType: TextSelectionHandleType.right, + expectedEndHandleType: TextSelectionHandleType.left, + ), + _DirectionalityTestCase( + description: 'ambient LTR, English then Arabic text', + ambientDirection: TextDirection.ltr, + text: 'abc مرحبا', + selectionBase: 0, + selectionExtent: 9, + expectedStartEndpointDirection: TextDirection.ltr, + expectedEndEndpointDirection: TextDirection.rtl, + expectedStartHandleType: TextSelectionHandleType.left, + expectedEndHandleType: TextSelectionHandleType.left, + ), + _DirectionalityTestCase( + description: 'ambient RTL, English then Arabic text', + ambientDirection: TextDirection.rtl, + text: 'abc مرحبا', + selectionBase: 0, + selectionExtent: 9, + expectedStartEndpointDirection: TextDirection.rtl, + expectedEndEndpointDirection: TextDirection.ltr, + expectedStartHandleType: TextSelectionHandleType.right, + expectedEndHandleType: TextSelectionHandleType.right, + ), + ]; + + for (final testCase in androidDirectionalityTestCases) { + testWidgets( + 'Android selection handles match endpoint direction: ${testCase.description}', + (WidgetTester tester) async { + final customControls = _DirectionalitySpyTextSelectionControls(); + final controller = TextEditingController(text: testCase.text); + final focusNode = FocusNode(); + addTearDown(controller.dispose); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + TestWidgetsApp( + home: Directionality( + textDirection: testCase.ambientDirection, + child: TestTextField( + controller: controller, + focusNode: focusNode, + selectionControls: customControls, + // On the web selectAllOnFocus defaults to true, interfering with + // this test's programmatic selection. + selectAllOnFocus: false, + ), + ), + ), + ); + + final RenderEditable renderEditable = tester.allRenderObjects + .whereType() + .first; + expect(renderEditable.textDirection, testCase.ambientDirection); + + focusNode.requestFocus(); + await tester.pump(); + + customControls.clearBuiltHandleTypes(); + controller.selection = TextSelection( + baseOffset: testCase.selectionBase, + extentOffset: testCase.selectionExtent, + ); + await tester.pumpAndSettle(); + + final List endpoints = renderEditable.getEndpointsForSelection( + controller.selection, + ); + expect(endpoints, hasLength(2)); + expect(endpoints.first.direction, testCase.expectedStartEndpointDirection); + expect(endpoints.last.direction, testCase.expectedEndEndpointDirection); + expect(customControls.builtHandleTypes, hasLength(2)); + expect(customControls.builtHandleTypes.first, testCase.expectedStartHandleType); + expect(customControls.builtHandleTypes.last, testCase.expectedEndHandleType); + }, + variant: TargetPlatformVariant.only(TargetPlatform.android), + ); + } + + testWidgets( + 'selection handles use text direction for mixed-directionality text on iOS', + (WidgetTester tester) async { + final customControls = _DirectionalitySpyTextSelectionControls(); + final controller = TextEditingController(text: 'abc مرحبا'); + addTearDown(controller.dispose); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + TestWidgetsApp( + home: Directionality( + textDirection: TextDirection.ltr, + child: TestTextField( + controller: controller, + focusNode: focusNode, + selectionControls: customControls, + // On the web selectAllOnFocus defaults to true, interfering with + // this test's programmatic selection. + selectAllOnFocus: false, + ), + ), + ), + ); + + focusNode.requestFocus(); + await tester.pump(); + + customControls.clearBuiltHandleTypes(); + controller.selection = TextSelection(baseOffset: 0, extentOffset: controller.text.length); + await tester.pumpAndSettle(); + + final RenderEditable renderEditable = tester.allRenderObjects + .whereType() + .first; + final List endpoints = renderEditable.getEndpointsForSelection( + controller.selection, + ); + + expect(endpoints, hasLength(2)); + expect(endpoints.first.direction, TextDirection.ltr); + expect(endpoints.last.direction, TextDirection.rtl); + expect(customControls.builtHandleTypes, hasLength(2)); + expect(customControls.builtHandleTypes.first, TextSelectionHandleType.left); + expect(customControls.builtHandleTypes.last, TextSelectionHandleType.right); + }, + variant: TargetPlatformVariant.only(TargetPlatform.iOS), + ); } class FakeTextSelectionGestureDetectorBuilderDelegate @@ -2369,3 +2542,70 @@ class _MockTextSelectionHandleControls extends TextSelectionControls ); } } + +class _DirectionalitySpyTextSelectionControls extends TextSelectionControls { + final List builtHandleTypes = []; + + void clearBuiltHandleTypes() { + builtHandleTypes.clear(); + } + + @override + Widget buildHandle( + BuildContext context, + TextSelectionHandleType type, + double textLineHeight, [ + VoidCallback? onTap, + ]) { + builtHandleTypes.add(type); + return SizedBox.square(dimension: textLineHeight); + } + + @override + Widget buildToolbar( + BuildContext context, + Rect globalEditableRegion, + double textLineHeight, + Offset selectionMidpoint, + List endpoints, + TextSelectionDelegate delegate, + ValueListenable? clipboardStatus, + Offset? lastSecondaryTapDownPosition, + ) { + return const SizedBox.shrink(); + } + + @override + Offset getHandleAnchor(TextSelectionHandleType type, double textLineHeight) { + return Offset.zero; + } + + @override + Size getHandleSize(double textLineHeight) { + return Size.square(textLineHeight); + } +} + +class _DirectionalityTestCase { + const _DirectionalityTestCase({ + required this.description, + required this.ambientDirection, + required this.text, + required this.selectionBase, + required this.selectionExtent, + required this.expectedStartEndpointDirection, + required this.expectedEndEndpointDirection, + required this.expectedStartHandleType, + required this.expectedEndHandleType, + }); + + final String description; + final TextDirection ambientDirection; + final String text; + final int selectionBase; + final int selectionExtent; + final TextDirection expectedStartEndpointDirection; + final TextDirection expectedEndEndpointDirection; + final TextSelectionHandleType expectedStartHandleType; + final TextSelectionHandleType expectedEndHandleType; +} diff --git a/packages/flutter/test/widgets/two_dimensional_utils.dart b/packages/flutter/test/widgets/two_dimensional_utils.dart index d09554ea25fc7..26296362c01de 100644 --- a/packages/flutter/test/widgets/two_dimensional_utils.dart +++ b/packages/flutter/test/widgets/two_dimensional_utils.dart @@ -341,14 +341,14 @@ class SimpleListTableView extends TwoDimensionalScrollView { super.mainAxis = Axis.vertical, super.verticalDetails = const ScrollableDetails.vertical(), super.horizontalDetails = const ScrollableDetails.horizontal(), - required TwoDimensionalChildListDelegate delegate, + required super.delegate, super.cacheExtent, super.cacheExtentStyle, super.diagonalDragBehavior = DiagonalDragBehavior.none, super.dragStartBehavior = DragStartBehavior.start, super.keyboardDismissBehavior = ScrollViewKeyboardDismissBehavior.manual, super.clipBehavior = Clip.hardEdge, - }) : super(delegate: delegate); + }); @override Widget buildViewport( diff --git a/packages/flutter/test/widgets/windowing_test.dart b/packages/flutter/test/widgets/windowing_test.dart index 7e50b9beccd11..b0b96540bed5b 100644 --- a/packages/flutter/test/widgets/windowing_test.dart +++ b/packages/flutter/test/widgets/windowing_test.dart @@ -12,13 +12,13 @@ import 'package:flutter/src/widgets/_window.dart' DialogWindowControllerDelegate, PopupWindow, PopupWindowController, - RegularWindow, - RegularWindowController, - RegularWindowControllerDelegate, SatelliteWindow, SatelliteWindowController, TooltipWindow, TooltipWindowController, + Window, + WindowController, + WindowControllerDelegate, WindowScope, WindowingOwner, createDefaultWindowingOwner; @@ -28,8 +28,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'multi_view_testing.dart'; -class _StubRegularWindowController extends RegularWindowController { - _StubRegularWindowController(WidgetTester tester) : super.empty() { +class _StubWindowController extends WindowController { + _StubWindowController(WidgetTester tester) : super.empty() { rootView = FakeView(tester.view); } @@ -135,7 +135,7 @@ class _StubTooltipWindowController extends TooltipWindowController { final WidgetTester tester; @override - BaseWindowController get parent => _StubRegularWindowController(tester); + BaseWindowController get parent => _StubWindowController(tester); @override Size get contentSize => Size.zero; @@ -164,7 +164,7 @@ class _StubPopupWindowController extends PopupWindowController { final WidgetTester tester; @override - BaseWindowController get parent => _StubRegularWindowController(tester); + BaseWindowController get parent => _StubWindowController(tester); @override Size get contentSize => Size.zero; @@ -196,7 +196,7 @@ class _StubSatelliteWindowController extends SatelliteWindowController { final WidgetTester tester; @override - BaseWindowController get parent => _StubRegularWindowController(tester); + BaseWindowController get parent => _StubWindowController(tester); @override Size get contentSize => Size.zero; @@ -232,11 +232,11 @@ class _StubSatelliteWindowController extends SatelliteWindowController { } } -// A controller that mutates its aspect values and notifies listeners, and whose -// value getters throw once the window is destroyed, mirroring the behavior of -// the real platform controllers. -class _MutableRegularWindowController extends RegularWindowController { - _MutableRegularWindowController(WidgetTester tester) : super.empty() { +// A controller that mutates its aspect values and notifies listeners, used to +// verify that dependents rebuild when the controller notifies even though the +// same controller instance is reused across rebuilds. +class _MutableWindowController extends WindowController { + _MutableWindowController(WidgetTester tester) : super.empty() { rootView = FakeView(tester.view); } @@ -342,13 +342,10 @@ void main() { expect(owner, isA()); }); - test('default WindowingOwner throws when accessing createRegularWindowController', () { + test('default WindowingOwner throws when accessing createWindowController', () { final WindowingOwner owner = createDefaultWindowingOwner(); expect( - () => owner.createRegularWindowController( - delegate: RegularWindowControllerDelegate(), - resizable: true, - ), + () => owner.createWindowController(delegate: WindowControllerDelegate(), resizable: true), throwsUnsupportedError, ); }); @@ -417,12 +414,12 @@ void main() { isWindowingEnabled = true; }); - testWidgets('RegularWindow does not throw', (WidgetTester tester) async { - final controller = _StubRegularWindowController(tester); + testWidgets('Window does not throw', (WidgetTester tester) async { + final controller = _StubWindowController(tester); addTearDown(controller.dispose); await tester.pumpWidget( wrapWithView: false, - RegularWindow(controller: controller, child: Container()), + Window(controller: controller, child: Container()), ); }); @@ -436,12 +433,12 @@ void main() { }); testWidgets('Can access WindowScope.of for regular windows', (WidgetTester tester) async { - final controller = _StubRegularWindowController(tester); + final controller = _StubWindowController(tester); BaseWindowController? scope; addTearDown(controller.dispose); await tester.pumpWidget( wrapWithView: false, - RegularWindow( + Window( controller: controller, child: Builder( builder: (BuildContext context) { @@ -452,7 +449,7 @@ void main() { ), ); - expect(scope, isA()); + expect(scope, isA()); }); testWidgets('Can access WindowScope.of for dialog windows', (WidgetTester tester) async { @@ -538,12 +535,12 @@ void main() { testWidgets('Can access WindowScope.maybeOf for regular windows', ( WidgetTester tester, ) async { - final controller = _StubRegularWindowController(tester); + final controller = _StubWindowController(tester); BaseWindowController? scope; addTearDown(controller.dispose); await tester.pumpWidget( wrapWithView: false, - RegularWindow( + Window( controller: controller, child: Builder( builder: (BuildContext context) { @@ -554,7 +551,7 @@ void main() { ), ); - expect(scope, isA()); + expect(scope, isA()); }); testWidgets('Can access WindowScope.maybeOf for dialog windows', (WidgetTester tester) async { @@ -644,12 +641,12 @@ void main() { testWidgets('Can access WindowScope.contentSizeOf for regular windows', ( WidgetTester tester, ) async { - final controller = _StubRegularWindowController(tester); + final controller = _StubWindowController(tester); Size? size; addTearDown(controller.dispose); await tester.pumpWidget( wrapWithView: false, - RegularWindow( + Window( controller: controller, child: Builder( builder: (BuildContext context) { @@ -754,12 +751,12 @@ void main() { testWidgets('Can access WindowScope.maybeContentSizeOf for regular windows', ( WidgetTester tester, ) async { - final controller = _StubRegularWindowController(tester); + final controller = _StubWindowController(tester); Size? size; addTearDown(controller.dispose); await tester.pumpWidget( wrapWithView: false, - RegularWindow( + Window( controller: controller, child: Builder( builder: (BuildContext context) { @@ -864,12 +861,12 @@ void main() { testWidgets('Can access WindowScope.titleOf for regular windows', ( WidgetTester tester, ) async { - final controller = _StubRegularWindowController(tester); + final controller = _StubWindowController(tester); String? title; addTearDown(controller.dispose); await tester.pumpWidget( wrapWithView: false, - RegularWindow( + Window( controller: controller, child: Builder( builder: (BuildContext context) { @@ -970,12 +967,12 @@ void main() { testWidgets('Can access WindowScope.maybeTitleOf for regular windows', ( WidgetTester tester, ) async { - final controller = _StubRegularWindowController(tester); + final controller = _StubWindowController(tester); String? title; addTearDown(controller.dispose); await tester.pumpWidget( wrapWithView: false, - RegularWindow( + Window( controller: controller, child: Builder( builder: (BuildContext context) { @@ -1080,12 +1077,12 @@ void main() { testWidgets('Can access WindowScope.isActivatedOf for regular windows', ( WidgetTester tester, ) async { - final controller = _StubRegularWindowController(tester); + final controller = _StubWindowController(tester); bool? isActivated; addTearDown(controller.dispose); await tester.pumpWidget( wrapWithView: false, - RegularWindow( + Window( controller: controller, child: Builder( builder: (BuildContext context) { @@ -1190,12 +1187,12 @@ void main() { testWidgets('Can access WindowScope.maybeIsActivatedOf for regular windows', ( WidgetTester tester, ) async { - final controller = _StubRegularWindowController(tester); + final controller = _StubWindowController(tester); bool? isActivated; addTearDown(controller.dispose); await tester.pumpWidget( wrapWithView: false, - RegularWindow( + Window( controller: controller, child: Builder( builder: (BuildContext context) { @@ -1300,12 +1297,12 @@ void main() { testWidgets('Can access WindowScope.isMinimizedOf for regular windows', ( WidgetTester tester, ) async { - final controller = _StubRegularWindowController(tester); + final controller = _StubWindowController(tester); bool? isMinimized; addTearDown(controller.dispose); await tester.pumpWidget( wrapWithView: false, - RegularWindow( + Window( controller: controller, child: Builder( builder: (BuildContext context) { @@ -1388,12 +1385,12 @@ void main() { testWidgets('Can access WindowScope.maybeIsMinimizedOf for regular windows', ( WidgetTester tester, ) async { - final controller = _StubRegularWindowController(tester); + final controller = _StubWindowController(tester); bool? isMinimized; addTearDown(controller.dispose); await tester.pumpWidget( wrapWithView: false, - RegularWindow( + Window( controller: controller, child: Builder( builder: (BuildContext context) { @@ -1476,12 +1473,12 @@ void main() { testWidgets('Can access WindowScope.isMaximizedOf for regular windows', ( WidgetTester tester, ) async { - final controller = _StubRegularWindowController(tester); + final controller = _StubWindowController(tester); bool? isMaximized; addTearDown(controller.dispose); await tester.pumpWidget( wrapWithView: false, - RegularWindow( + Window( controller: controller, child: Builder( builder: (BuildContext context) { @@ -1586,12 +1583,12 @@ void main() { testWidgets('Can access WindowScope.maybeIsMaximizedOf for regular windows', ( WidgetTester tester, ) async { - final controller = _StubRegularWindowController(tester); + final controller = _StubWindowController(tester); bool? isMaximized; addTearDown(controller.dispose); await tester.pumpWidget( wrapWithView: false, - RegularWindow( + Window( controller: controller, child: Builder( builder: (BuildContext context) { @@ -1696,12 +1693,12 @@ void main() { testWidgets('Can access WindowScope.isFullscreenOf for regular windows', ( WidgetTester tester, ) async { - final controller = _StubRegularWindowController(tester); + final controller = _StubWindowController(tester); bool? isFullscreen; addTearDown(controller.dispose); await tester.pumpWidget( wrapWithView: false, - RegularWindow( + Window( controller: controller, child: Builder( builder: (BuildContext context) { @@ -1806,12 +1803,12 @@ void main() { testWidgets('Can access WindowScope.maybeIsFullscreenOf for regular windows', ( WidgetTester tester, ) async { - final controller = _StubRegularWindowController(tester); + final controller = _StubWindowController(tester); bool? isFullscreen; addTearDown(controller.dispose); await tester.pumpWidget( wrapWithView: false, - RegularWindow( + Window( controller: controller, child: Builder( builder: (BuildContext context) { @@ -1916,12 +1913,12 @@ void main() { testWidgets('Dependent rebuilds when an aspect changes and the controller notifies', ( WidgetTester tester, ) async { - final controller = _MutableRegularWindowController(tester); + final controller = _MutableWindowController(tester); addTearDown(controller.dispose); final observed = []; await tester.pumpWidget( wrapWithView: false, - RegularWindow( + Window( controller: controller, child: Builder( builder: (BuildContext context) { @@ -1943,12 +1940,12 @@ void main() { testWidgets('Can access WindowScope.isDestroyedOf for regular windows', ( WidgetTester tester, ) async { - final controller = _StubRegularWindowController(tester); + final controller = _StubWindowController(tester); bool? isDestroyed; addTearDown(controller.dispose); await tester.pumpWidget( wrapWithView: false, - RegularWindow( + Window( controller: controller, child: Builder( builder: (BuildContext context) { @@ -2053,12 +2050,12 @@ void main() { testWidgets('Can access WindowScope.maybeIsDestroyedOf for regular windows', ( WidgetTester tester, ) async { - final controller = _StubRegularWindowController(tester); + final controller = _StubWindowController(tester); bool? isDestroyed; addTearDown(controller.dispose); await tester.pumpWidget( wrapWithView: false, - RegularWindow( + Window( controller: controller, child: Builder( builder: (BuildContext context) { @@ -2180,12 +2177,12 @@ void main() { }); testWidgets('Dependent rebuilds when the window is destroyed', (WidgetTester tester) async { - final controller = _MutableRegularWindowController(tester); + final controller = _MutableWindowController(tester); addTearDown(controller.dispose); final observed = []; await tester.pumpWidget( wrapWithView: false, - RegularWindow( + Window( controller: controller, child: Builder( builder: (BuildContext context) { diff --git a/packages/flutter_driver/test/src/real_tests/extension_test.dart b/packages/flutter_driver/test/src/real_tests/extension_test.dart index efbee43578bd0..10a4c3d64f974 100644 --- a/packages/flutter_driver/test/src/real_tests/extension_test.dart +++ b/packages/flutter_driver/test/src/real_tests/extension_test.dart @@ -2,13 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// TODO(gspencergoog): Remove this tag once this test's state leaks/test -// dependencies have been fixed. -// https://github.com/flutter/flutter/issues/85160 -// Fails with "flutter test --test-randomize-ordering-seed=20210721" -@Tags(['no-shuffle']) -library; - import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' hide TextInputAction; import 'package:flutter/rendering.dart'; @@ -34,6 +27,21 @@ Future silenceDriverLogger(AsyncCallback callback) async { } void main() { + setUpAll(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (MethodCall methodCall) async { + return null; + }, + ); + }); + + tearDownAll(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ); + }); group('waitUntilNoTransientCallbacks', () { late FlutterDriverExtension driverExtension; Map? result; @@ -308,6 +316,9 @@ void main() { () => jsonMessage.encodeMessage(['hello world'])!, ); }); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMessageHandler('helloChannel', null); + }); // ignore: unawaited_futures channel.invokeMethod('sayHello', 'hello'); @@ -355,6 +366,10 @@ void main() { () => jsonMessage.encodeMessage(['hello world'])!, ); }); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMessageHandler('helloChannel1', null); + tester.binding.defaultBinaryMessenger.setMockMessageHandler('helloChannel2', null); + }); // ignore: unawaited_futures channel1.invokeMethod('sayHello', 'hello'); // ignore: unawaited_futures @@ -408,6 +423,10 @@ void main() { () => jsonMessage.encodeMessage(['hello world'])!, ); }); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMessageHandler('helloChannel1', null); + tester.binding.defaultBinaryMessenger.setMockMessageHandler('helloChannel2', null); + }); // ignore: unawaited_futures channel1.invokeMethod('sayHello', 'hello'); @@ -450,7 +469,7 @@ void main() { ) { return Future.delayed( const Duration(milliseconds: 20), - () => jsonMessage.encodeMessage(['hello world'])!, + () => jsonMessage.encodeMessage(const ['hello world'])!, ); }); @@ -461,10 +480,15 @@ void main() { ) { return Future.delayed( const Duration(milliseconds: 10), - () => jsonMessage.encodeMessage(['hello world'])!, + () => jsonMessage.encodeMessage(const ['hello world'])!, ); }); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMessageHandler('helloChannel1', null); + tester.binding.defaultBinaryMessenger.setMockMessageHandler('helloChannel2', null); + }); + // ignore: unawaited_futures channel1.invokeMethod('sayHello', 'hello'); @@ -490,7 +514,7 @@ void main() { // Now we receive the result. await tester.pump(const Duration(milliseconds: 5)); - expect(result, {'isError': false, 'response': {}}); + expect(result, const {'isError': false, 'response': {}}); }, ); }); @@ -581,6 +605,16 @@ void main() { }); testWidgets('getText', (WidgetTester tester) async { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.processText, + (_) async => null, + ); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.processText, + null, + ); + }); await silenceDriverLogger(() async { final driverExtension = FlutterDriverExtension((String? arg) async => '', true, true); @@ -596,6 +630,16 @@ void main() { return GetTextResult.fromJson(result['response'] as Map).text; } + final controller3 = TextEditingController(text: 'Hello3'); + final controller4 = TextEditingController(text: 'Hello4'); + final controller5 = TextEditingController(text: 'Hello5'); + final focusNode3 = FocusNode(); + addTearDown(() { + controller3.dispose(); + controller4.dispose(); + controller5.dispose(); + focusNode3.dispose(); + }); await tester.pumpWidget( MaterialApp( home: Scaffold( @@ -614,8 +658,8 @@ void main() { height: 25.0, child: EditableText( key: const ValueKey('text3'), - controller: TextEditingController(text: 'Hello3'), - focusNode: FocusNode(), + controller: controller3, + focusNode: focusNode3, style: const TextStyle(), cursorColor: Colors.red, backgroundCursorColor: Colors.black, @@ -623,16 +667,13 @@ void main() { ), SizedBox( height: 25.0, - child: TextField( - key: const ValueKey('text4'), - controller: TextEditingController(text: 'Hello4'), - ), + child: TextField(key: const ValueKey('text4'), controller: controller4), ), SizedBox( height: 25.0, child: TextFormField( key: const ValueKey('text5'), - controller: TextEditingController(text: 'Hello5'), + controller: controller5, ), ), SizedBox( @@ -975,6 +1016,16 @@ void main() { ); testWidgets('enableTextEntryEmulation false', (WidgetTester tester) async { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.processText, + (_) async => null, + ); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.processText, + null, + ); + }); driverExtension = FlutterDriverExtension((String? arg) async => '', true, false); await tester.pumpWidget(testWidget); @@ -984,6 +1035,16 @@ void main() { }); testWidgets('enableTextEntryEmulation true', (WidgetTester tester) async { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.processText, + (_) async => null, + ); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.processText, + null, + ); + }); driverExtension = FlutterDriverExtension((String? arg) async => '', true, true); await tester.pumpWidget(testWidget); @@ -1335,9 +1396,20 @@ void main() { ); testWidgets('press done trigger onSubmitted and change value', (WidgetTester tester) async { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.processText, + (_) async => null, + ); + addTearDown(() { + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.processText, + null, + ); + }); driverExtension = FlutterDriverExtension((String? arg) async => '', true, true); final controller = TextEditingController(text: 'foo'); + addTearDown(controller.dispose); await tester.pumpWidget(testWidget(controller)); expect(controller.value.text, 'foo'); diff --git a/packages/flutter_test/lib/src/binding.dart b/packages/flutter_test/lib/src/binding.dart index bd6237e2159fb..caf47ca30d6da 100644 --- a/packages/flutter_test/lib/src/binding.dart +++ b/packages/flutter_test/lib/src/binding.dart @@ -273,12 +273,12 @@ mixin _ChildWindowHierarchyMixin { var foundPopup = false; for (final BaseWindowController child in _children) { switch (child) { - case final RegularWindowController regularChild: + case final WindowController regularChild: if (foundPopup) { // Already found a popup, skip anything else. break; } - activateable = (regularChild as _TestRegularWindowController).getFirstActivatableChild(); + activateable = (regularChild as _TestWindowController).getFirstActivatableChild(); case final DialogWindowController dialogChild: // Always return the first dialog found. return (dialogChild as _TestDialogWindowController).getFirstActivatableChild(); @@ -305,9 +305,9 @@ mixin _ChildWindowHierarchyMixin { } } -class _TestRegularWindowController extends RegularWindowController with _ChildWindowHierarchyMixin { - _TestRegularWindowController({ - required RegularWindowControllerDelegate delegate, +class _TestWindowController extends WindowController with _ChildWindowHierarchyMixin { + _TestWindowController({ + required WindowControllerDelegate delegate, required TestPlatformDispatcher platformDispatcher, required this.windowingOwner, Size? size, @@ -329,7 +329,7 @@ class _TestRegularWindowController extends RegularWindowController with _ChildWi activate(); } - final RegularWindowControllerDelegate _delegate; + final WindowControllerDelegate _delegate; final _TestWindowingOwner windowingOwner; Size _size; BoxConstraints _constraints; @@ -435,8 +435,8 @@ void _addChildToParent(BaseWindowController? parent, BaseWindowController child) switch (parent) { case final DialogWindowController testParent: (testParent as _TestDialogWindowController).addChild(child); - case final RegularWindowController testParent: - (testParent as _TestRegularWindowController).addChild(child); + case final WindowController testParent: + (testParent as _TestWindowController).addChild(child); case final PopupWindowController testParent: (testParent as _TestPopupWindowController).addChild(child); case final SatelliteWindowController testParent: @@ -452,8 +452,8 @@ void _removeChildFromParent(BaseWindowController? parent, BaseWindowController c switch (parent) { case final DialogWindowController testParent: (testParent as _TestDialogWindowController).removeChild(child); - case final RegularWindowController testParent: - (testParent as _TestRegularWindowController).removeChild(child); + case final WindowController testParent: + (testParent as _TestWindowController).removeChild(child); case final PopupWindowController testParent: (testParent as _TestPopupWindowController).removeChild(child); case final SatelliteWindowController testParent: @@ -856,8 +856,8 @@ class _TestWindowingOwner extends WindowingOwner { /// Returns the activated [BaseWindowController]. BaseWindowController activateWindowController(BaseWindowController controller) { switch (controller) { - case final RegularWindowController regularController: - final BaseWindowController leaf = (regularController as _TestRegularWindowController) + case final WindowController regularController: + final BaseWindowController leaf = (regularController as _TestWindowController) .getFirstActivatableChild(); _activeWindowController = leaf; return _activeWindowController!; @@ -887,7 +887,7 @@ class _TestWindowingOwner extends WindowingOwner { } switch (parent) { - case final RegularWindowController regularParent: + case final WindowController regularParent: regularParent.activate(); case final DialogWindowController dialogParent: dialogParent.activate(); @@ -917,7 +917,7 @@ class _TestWindowingOwner extends WindowingOwner { } switch (controller) { - case final RegularWindowController _: + case final WindowController _: _activeWindowController = null; case final DialogWindowController dialogController: if (!_tryActivateParent(dialogController.parent)) { @@ -946,14 +946,14 @@ class _TestWindowingOwner extends WindowingOwner { @internal @override - RegularWindowController createRegularWindowController({ - required RegularWindowControllerDelegate delegate, + WindowController createWindowController({ + required WindowControllerDelegate delegate, Size? size, BoxConstraints? constraints, required bool resizable, String? title, }) { - return _TestRegularWindowController( + return _TestWindowController( delegate: delegate, platformDispatcher: _platformDispatcher, windowingOwner: this, diff --git a/packages/flutter_test/pubspec.yaml b/packages/flutter_test/pubspec.yaml index 8a00396114b44..cf6564aa5d8c8 100644 --- a/packages/flutter_test/pubspec.yaml +++ b/packages/flutter_test/pubspec.yaml @@ -34,7 +34,7 @@ dependencies: stack_trace: ^1.12.1 # Used by globalToLocal et al. - vector_math: ^2.4.0 + vector_math: ^2.4.2 # Used to detect memory leaks with `testWidgets`. leak_tracker_flutter_testing: ^3.0.10 @@ -48,4 +48,4 @@ dev_dependencies: flutter_driver: sdk: flutter -# PUBSPEC CHECKSUM: lu8vkm +# PUBSPEC CHECKSUM: 380igp diff --git a/packages/flutter_test/test/all_elements_test.dart b/packages/flutter_test/test/all_elements_test.dart index 8f43efd2d38d4..11569a93dc4f7 100644 --- a/packages/flutter_test/test/all_elements_test.dart +++ b/packages/flutter_test/test/all_elements_test.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { diff --git a/packages/flutter_test/test/finders_test.dart b/packages/flutter_test/test/finders_test.dart index 311a3cd257b50..34953a6a6fd6b 100644 --- a/packages/flutter_test/test/finders_test.dart +++ b/packages/flutter_test/test/finders_test.dart @@ -8,6 +8,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter_test/flutter_test.dart'; +const _kBlue = Color(0xFF0000FF); + const List fooBarTexts = [ Text('foo', textDirection: TextDirection.ltr), Text('bar', textDirection: TextDirection.ltr), @@ -24,9 +26,9 @@ void main() { testWidgets('finds Button widgets with Image', (WidgetTester tester) async { addTearDown(imageCache.clear); await tester.pumpWidget( - _boilerplate(ElevatedButton(onPressed: null, child: Image(image: FileImage(File('test'))))), + _boilerplate(TestButton(child: Image(image: FileImage(File('test'))))), ); - expect(find.widgetWithImage(ElevatedButton, FileImage(File('test'))), findsOneWidget); + expect(find.widgetWithImage(TestButton, FileImage(File('test'))), findsOneWidget); }); }); @@ -170,9 +172,19 @@ void main() { testWidgets('finds EditableText widgets', (WidgetTester tester) async { final controller = TextEditingController()..text = 'this is test'; addTearDown(controller.dispose); + final focusNode = FocusNode(); + addTearDown(focusNode.dispose); await tester.pumpWidget( - MaterialApp( - home: Scaffold(body: _boilerplate(TextField(controller: controller))), + TestWidgetsApp( + home: _boilerplate( + EditableText( + controller: controller, + focusNode: focusNode, + style: const TextStyle(), + cursorColor: const Color(0xFF000000), + backgroundCursorColor: _kBlue, + ), + ), ), ); @@ -276,7 +288,7 @@ void main() { Semantics( label: 'Add', button: true, - child: const TextButton(onPressed: null, child: Text('+')), + child: const TestButton(child: Text('+')), ), ), ); @@ -308,22 +320,20 @@ void main() { semanticsHandle.dispose(); }); - testWidgets( - 'Throws StateError if semantics are not enabled (bySemanticsIdentifier)', - (WidgetTester tester) async { - expect( - () => find.bySemanticsIdentifier('Add'), - throwsA( - isA().having( - (StateError e) => e.message, - 'message', - contains('Semantics are not enabled'), - ), + testWidgets('Throws StateError if semantics are not enabled (bySemanticsIdentifier)', ( + WidgetTester tester, + ) async { + expect( + () => find.bySemanticsIdentifier('Add'), + throwsA( + isA().having( + (StateError e) => e.message, + 'message', + contains('Semantics are not enabled'), ), - ); - }, - semanticsEnabled: false, - ); + ), + ); + }, semanticsEnabled: false); testWidgets('finds Semantically labeled widgets by identifier', (WidgetTester tester) async { final SemanticsHandle semanticsHandle = tester.ensureSemantics(); @@ -332,7 +342,7 @@ void main() { Semantics( identifier: 'Add', button: true, - child: const TextButton(onPressed: null, child: Text('+')), + child: const TestButton(child: Text('+')), ), ), ); @@ -825,7 +835,7 @@ void main() { (WidgetTester tester) async { var tapCount = 0; await tester.pumpWidget( - MaterialApp( + TestWidgetsApp( home: ListView( children: [ const SizedBox(height: 2000), // Push the target off-screen @@ -854,27 +864,23 @@ void main() { testWidgets('tapping directly on a Sliver produces an error', (WidgetTester tester) async { var sliverToBoxAdapterTapped = 0; await tester.pumpWidget( - MaterialApp( - title: 'Flutter Demo', - theme: ThemeData(primarySwatch: Colors.blue), - home: Scaffold( - body: SafeArea( - child: CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: GestureDetector( - onTap: () { - sliverToBoxAdapterTapped++; - }, - child: Container( - color: Colors.orange, - padding: const EdgeInsets.all(16.0), - child: const Text('Sliver Grid Header', style: TextStyle(fontSize: 28)), - ), + TestWidgetsApp( + home: SafeArea( + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: GestureDetector( + onTap: () { + sliverToBoxAdapterTapped++; + }, + child: Container( + color: _kBlue, + padding: const EdgeInsets.all(16.0), + child: const Text('Sliver Grid Header', style: TextStyle(fontSize: 28)), ), ), - ], - ), + ), + ], ), ), ), @@ -898,27 +904,23 @@ void main() { ) async { var sliverToBoxAdapterTapped = 0; await tester.pumpWidget( - MaterialApp( - title: 'Flutter Demo', - theme: ThemeData(primarySwatch: Colors.blue), - home: Scaffold( - body: SafeArea( - child: CustomScrollView( - slivers: [ - SliverToBoxAdapter( - child: GestureDetector( - onTap: () { - sliverToBoxAdapterTapped++; - }, - child: Container( - color: Colors.orange, - padding: const EdgeInsets.all(16.0), - child: const Text('Sliver Grid Header', style: TextStyle(fontSize: 28)), - ), + TestWidgetsApp( + home: SafeArea( + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: GestureDetector( + onTap: () { + sliverToBoxAdapterTapped++; + }, + child: Container( + color: _kBlue, + padding: const EdgeInsets.all(16.0), + child: const Text('Sliver Grid Header', style: TextStyle(fontSize: 28)), ), ), - ], - ), + ), + ], ), ), ), @@ -1770,7 +1772,7 @@ void main() { final controller = ScrollController(); addTearDown(controller.dispose); await tester.pumpWidget( - MaterialApp( + TestWidgetsApp( home: SingleChildScrollView( controller: controller, child: const SizedBox(width: 100, height: 1000), @@ -1788,7 +1790,7 @@ void main() { final controller = ScrollController(initialScrollOffset: 400); addTearDown(controller.dispose); await tester.pumpWidget( - MaterialApp( + TestWidgetsApp( home: SingleChildScrollView( controller: controller, child: const SizedBox(width: 100, height: 1000), @@ -1806,7 +1808,7 @@ void main() { final controller = ScrollController(); addTearDown(controller.dispose); await tester.pumpWidget( - MaterialApp( + TestWidgetsApp( home: SingleChildScrollView( scrollDirection: Axis.horizontal, controller: controller, @@ -1825,7 +1827,7 @@ void main() { final controller = ScrollController(initialScrollOffset: 200); addTearDown(controller.dispose); await tester.pumpWidget( - MaterialApp( + TestWidgetsApp( home: SingleChildScrollView( scrollDirection: Axis.horizontal, controller: controller, @@ -1844,7 +1846,7 @@ void main() { WidgetTester tester, ) async { await tester.pumpWidget( - const MaterialApp( + const TestWidgetsApp( home: Column( children: [ SingleChildScrollView( @@ -1862,7 +1864,7 @@ void main() { testWidgets('can exclusively find node that scrolls vertically', (WidgetTester tester) async { await tester.pumpWidget( - const MaterialApp( + const TestWidgetsApp( home: Column( children: [ SingleChildScrollView( @@ -2062,7 +2064,7 @@ Widget _boilerplate(Widget child) { textDirection: TextDirection.ltr, child: Navigator( onGenerateRoute: (RouteSettings settings) { - return MaterialPageRoute(builder: (BuildContext context) => child); + return TestRoute(builder: (BuildContext context) => child); }, ), ); @@ -2119,6 +2121,106 @@ Widget _deepWidgetTree({required int depth, required Widget child}) { return tree; } +class TestRoute extends PageRoute { + TestRoute({ + this.child, + this.builder, + RouteSettings super.settings = const RouteSettings(), + this.barrierColor, + this.maintainState = false, + this.transitionDuration = Duration.zero, + this.reverseTransitionDuration = Duration.zero, + this.transitionsBuilder, + super.fullscreenDialog, + super.allowSnapshotting, + }) : assert(child != null || builder != null, 'Either child or builder must be provided.'); + + final Widget? child; + final WidgetBuilder? builder; + final PageTransitionsBuilder? transitionsBuilder; + + @override + final Duration transitionDuration; + + @override + final Duration reverseTransitionDuration; + + @override + final Color? barrierColor; + + @override + String? get barrierLabel => null; + + @override + final bool maintainState; + + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + return child ?? builder?.call(context) ?? const SizedBox.shrink(); + } + + @override + Widget buildTransitions( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + if (transitionsBuilder == null) { + return child; + } + + return transitionsBuilder!.buildTransitions( + this, + context, + animation, + secondaryAnimation, + child, + ); + } +} + +class TestButton extends StatelessWidget { + const TestButton({ + required this.child, + this.focusNode, + this.autofocus = false, + this.onPressed, + this.behavior, + super.key, + }); + + final bool autofocus; + final FocusNode? focusNode; + final VoidCallback? onPressed; + final Widget child; + final HitTestBehavior? behavior; + + void _onFocus() => focusNode?.requestFocus(); + + @override + Widget build(BuildContext context) { + return Semantics( + label: 'button', + button: true, + enabled: onPressed != null, + onTap: onPressed, + onFocus: _onFocus, + focusable: true, + child: FocusableActionDetector( + enabled: onPressed != null, + focusNode: focusNode, + autofocus: autofocus, + child: GestureDetector(behavior: behavior, onTap: onPressed, child: child), + ), + ); + } +} + class _FakeFinder extends FinderBase { _FakeFinder({ this.allCandidatesCallback, diff --git a/packages/flutter_test/test/live_binding_test.dart b/packages/flutter_test/test/live_binding_test.dart index 6f4f58863a57e..71a6deb39f726 100644 --- a/packages/flutter_test/test/live_binding_test.dart +++ b/packages/flutter_test/test/live_binding_test.dart @@ -9,36 +9,9 @@ import 'package:flutter_test/flutter_test.dart'; // This file is for testings that require a `LiveTestWidgetsFlutterBinding` void main() { - PageRoute defaultPageRouteBuilder(RouteSettings settings, WidgetBuilder builder) { - return PageRouteBuilder( - settings: settings, - pageBuilder: - ( - BuildContext context, - Animation animation, - Animation secondaryAnimation, - ) => builder(context), - transitionsBuilder: - ( - BuildContext context, - Animation animation, - Animation secondaryAnimation, - Widget child, - ) => child, - ); - } - - Widget buildTestApp({required Widget child}) { - return WidgetsApp( - color: const Color(0xFFFFFFFF), - pageRouteBuilder: defaultPageRouteBuilder, - home: SizedBox.expand(child: Center(child: child)), - ); - } - final binding = LiveTestWidgetsFlutterBinding(); testWidgets('Input PointerAddedEvent', (WidgetTester tester) async { - await tester.pumpWidget(buildTestApp(child: const Text('Test'))); + await tester.pumpWidget(const TestWidgetsApp(home: Text('Test'))); await tester.pump(); final TestGesture gesture = await tester.createGesture(); // This mimics the start of a gesture as seen on a device, where inputs @@ -50,8 +23,8 @@ void main() { testWidgets('Input PointerHoverEvent', (WidgetTester tester) async { PointerHoverEvent? hoverEvent; await tester.pumpWidget( - buildTestApp( - child: MouseRegion( + TestWidgetsApp( + home: MouseRegion( child: const Text('Test'), onHover: (PointerHoverEvent event) { hoverEvent = event; @@ -71,8 +44,8 @@ void main() { testWidgets('hitTesting works when using setSurfaceSize', (WidgetTester tester) async { var invocations = 0; await tester.pumpWidget( - buildTestApp( - child: GestureDetector( + TestWidgetsApp( + home: GestureDetector( onTap: () { invocations++; }, @@ -100,7 +73,7 @@ void main() { testWidgets('setSurfaceSize works', (WidgetTester tester) async { addTearDown(binding.resetLayers); - await tester.pumpWidget(buildTestApp(child: const Text('Test'))); + await tester.pumpWidget(const TestWidgetsApp(home: Center(child: Text('Test')))); final Size windowCenter = tester.view.physicalSize / tester.view.devicePixelRatio / 2; final double windowCenterX = windowCenter.width; diff --git a/packages/flutter_test/test/live_widget_controller_test.dart b/packages/flutter_test/test/live_widget_controller_test.dart index ac354b942d8bd..01bd2048e9a28 100644 --- a/packages/flutter_test/test/live_widget_controller_test.dart +++ b/packages/flutter_test/test/live_widget_controller_test.dart @@ -4,8 +4,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/gestures.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; // This test is very fragile and bypasses some zone-related checks. @@ -47,9 +47,9 @@ class _CountButtonState extends State { int counter = 0; @override Widget build(BuildContext context) { - return ElevatedButton( + return GestureDetector( child: Text('Counter $counter'), - onPressed: () { + onTap: () { setState(() { counter += 1; }); @@ -93,7 +93,7 @@ void main() { TestBinding.ensureInitialized(); test('Test pump on LiveWidgetController', () async { - runApp(const MaterialApp(home: Center(child: CountButton()))); + runApp(const TestWidgetsApp(home: CountButton())); await SchedulerBinding.instance.endOfFrame; final WidgetController controller = LiveWidgetController(WidgetsBinding.instance); @@ -106,7 +106,7 @@ void main() { }); test('Test pumpAndSettle on LiveWidgetController', () async { - runApp(const MaterialApp(home: Center(child: AnimateSample()))); + runApp(const TestWidgetsApp(home: AnimateSample())); await SchedulerBinding.instance.endOfFrame; final WidgetController controller = LiveWidgetController(WidgetsBinding.instance); expect(find.text('Value: 1.0'), findsNothing); @@ -117,7 +117,7 @@ void main() { test('Input event array on LiveWidgetController', () async { final logs = []; runApp( - MaterialApp( + TestWidgetsApp( home: Listener( onPointerDown: (PointerDownEvent event) => logs.add('down ${event.buttons}'), onPointerMove: (PointerMoveEvent event) => logs.add('move ${event.buttons}'), diff --git a/packages/flutter_test/test/mock_canvas_test.dart b/packages/flutter_test/test/mock_canvas_test.dart index 92362c9871eba..4afb3e844b62d 100644 --- a/packages/flutter_test/test/mock_canvas_test.dart +++ b/packages/flutter_test/test/mock_canvas_test.dart @@ -4,9 +4,12 @@ import 'dart:math' as math; -import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; +const _kTransparent = Color(0x00000000); +const _kBlue = Color(0xFF0000FF); + class MyPainter extends CustomPainter { const MyPainter({required this.color}); @@ -55,7 +58,7 @@ void main() { testWidgets('matches when the predicate returns true', (WidgetTester tester) async { await tester.pumpWidget( const CustomPaint( - painter: MyPainter(color: Colors.transparent), + painter: MyPainter(color: _kTransparent), child: SizedBox(width: 50, height: 50), ), ); @@ -72,7 +75,7 @@ void main() { expect(methodsAndArguments, [ const MethodAndArguments(#save, []), - const MethodAndArguments(#drawColor, [Colors.transparent, BlendMode.color]), + const MethodAndArguments(#drawColor, [_kTransparent, BlendMode.color]), // The #restore call is never evaluated ]); }); @@ -80,7 +83,7 @@ void main() { testWidgets('fails when the predicate always returns false', (WidgetTester tester) async { await tester.pumpWidget( const CustomPaint( - painter: MyPainter(color: Colors.transparent), + painter: MyPainter(color: _kTransparent), child: SizedBox(width: 50, height: 50), ), ); @@ -99,7 +102,7 @@ void main() { expect(methodsAndArguments, [ const MethodAndArguments(#save, []), - const MethodAndArguments(#drawColor, [Colors.transparent, BlendMode.color]), + const MethodAndArguments(#drawColor, [_kTransparent, BlendMode.color]), const MethodAndArguments(#restore, []), ]); }); @@ -107,7 +110,7 @@ void main() { testWidgets('fails when the predicate throws', (WidgetTester tester) async { await tester.pumpWidget( const CustomPaint( - painter: MyPainter(color: Colors.transparent), + painter: MyPainter(color: _kTransparent), child: SizedBox(width: 50, height: 50), ), ); @@ -132,7 +135,7 @@ void main() { expect(methodsAndArguments, [ const MethodAndArguments(#save, []), - const MethodAndArguments(#drawColor, [Colors.transparent, BlendMode.color]), + const MethodAndArguments(#drawColor, [_kTransparent, BlendMode.color]), // The #restore call is never evaluated ]); }); @@ -142,7 +145,7 @@ void main() { testWidgets('matches when the predicate always returns true', (WidgetTester tester) async { await tester.pumpWidget( const CustomPaint( - painter: MyPainter(color: Colors.transparent), + painter: MyPainter(color: _kTransparent), child: SizedBox(width: 50, height: 50), ), ); @@ -159,7 +162,7 @@ void main() { expect(methodsAndArguments, [ const MethodAndArguments(#save, []), - const MethodAndArguments(#drawColor, [Colors.transparent, BlendMode.color]), + const MethodAndArguments(#drawColor, [_kTransparent, BlendMode.color]), const MethodAndArguments(#restore, []), ]); }); @@ -167,7 +170,7 @@ void main() { testWidgets('fails when the predicate returns false', (WidgetTester tester) async { await tester.pumpWidget( const CustomPaint( - painter: MyPainter(color: Colors.transparent), + painter: MyPainter(color: _kTransparent), child: SizedBox(width: 50, height: 50), ), ); @@ -187,7 +190,7 @@ void main() { expect(methodsAndArguments, [ const MethodAndArguments(#save, []), - const MethodAndArguments(#drawColor, [Colors.transparent, BlendMode.color]), + const MethodAndArguments(#drawColor, [_kTransparent, BlendMode.color]), // The #restore call is never evaluated ]); }); @@ -195,7 +198,7 @@ void main() { testWidgets('fails if the predicate ever throws', (WidgetTester tester) async { await tester.pumpWidget( const CustomPaint( - painter: MyPainter(color: Colors.transparent), + painter: MyPainter(color: _kTransparent), child: SizedBox(width: 50, height: 50), ), ); @@ -217,7 +220,7 @@ void main() { expect(methodsAndArguments, [ const MethodAndArguments(#save, []), - const MethodAndArguments(#drawColor, [Colors.transparent, BlendMode.color]), + const MethodAndArguments(#drawColor, [_kTransparent, BlendMode.color]), // The #restore call is never evaluated ]); }); @@ -228,7 +231,7 @@ void main() { const double startAngle = math.pi / 4; const double sweepAngle = math.pi / 2; const useCenter = false; - final paint = Paint()..color = Colors.blue; + final paint = Paint()..color = _kBlue; Future pumpPainter(WidgetTester tester) async { await tester.pumpWidget( @@ -357,7 +360,7 @@ void main() { Offset.zero & const Size.square(50), const Radius.circular(5), ); - final paint = Paint()..color = Colors.blue; + final paint = Paint()..color = _kBlue; Future pumpPainter(WidgetTester tester) async { await tester.pumpWidget( diff --git a/packages/flutter_test/test/navigator_test.dart b/packages/flutter_test/test/navigator_test.dart index 63a355e5fd6ec..6040390a387e9 100644 --- a/packages/flutter_test/test/navigator_test.dart +++ b/packages/flutter_test/test/navigator_test.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { @@ -12,106 +12,95 @@ void main() { final observer = TransitionDurationObserver(); await tester.pumpWidget( - MaterialApp( + WidgetsApp( + color: const Color(0xFFFFFFFF), navigatorObservers: [observer], onGenerateRoute: (RouteSettings settings) { return switch (settings.name) { - // A route that uses FadeForwardsPageTransitionsBuilder. '/' => _TestTransitionRoute( - pageTransitionsBuilder: const FadeForwardsPageTransitionsBuilder(), + pageTransitionsBuilder: const _TestSlideUpPageTransitionsBuilder(), builder: (BuildContext context) { - return Scaffold( - body: Center( - child: Column( - children: [ - const Text('Page 1'), - TextButton( - onPressed: () { - Navigator.pushNamed(context, '/2'); - }, - child: const Text('Next'), - ), - ], - ), + return Center( + child: Column( + children: [ + const Text('Page 1'), + GestureDetector( + onTap: () { + Navigator.pushNamed(context, '/2'); + }, + child: const Text('Next'), + ), + ], ), ); }, ), - // A route that uses ZoomPageTransitionsBuilder with custom durations. '/2' => _TestTransitionRoute( - pageTransitionsBuilder: const ZoomPageTransitionsBuilder(), + pageTransitionsBuilder: const _TestSlightRightPageTransitionsBuilder(), transitionDurationOverride: const Duration(milliseconds: 456), reverseTransitionDurationOverride: const Duration(milliseconds: 567), builder: (BuildContext context) { - return Scaffold( - body: Center( - child: Column( - children: [ - const Text('Page 2'), - TextButton( - onPressed: () { - Navigator.pushNamed(context, '/3'); - }, - child: const Text('Next'), - ), - TextButton( - onPressed: () { - Navigator.pop(context); - }, - child: const Text('Back'), - ), - ], - ), + return Center( + child: Column( + children: [ + const Text('Page 2'), + GestureDetector( + onTap: () { + Navigator.pushNamed(context, '/3'); + }, + child: const Text('Next'), + ), + GestureDetector( + onTap: () { + Navigator.pop(context); + }, + child: const Text('Back'), + ), + ], ), ); }, ), - // A route that uses FadeForwardsPageTransitionsBuilder with custom durations. '/3' => _TestTransitionRoute( - pageTransitionsBuilder: const FadeForwardsPageTransitionsBuilder(), + pageTransitionsBuilder: const _TestSlideUpPageTransitionsBuilder(), transitionDurationOverride: const Duration(milliseconds: 678), reverseTransitionDurationOverride: const Duration(milliseconds: 789), builder: (BuildContext context) { - return Scaffold( - body: Center( - child: Column( - children: [ - const Text('Page 3'), - TextButton( - onPressed: () { - Navigator.pushNamed(context, '/4'); - }, - child: const Text('Next'), - ), - TextButton( - onPressed: () { - Navigator.pop(context); - }, - child: const Text('Back'), - ), - ], - ), + return Center( + child: Column( + children: [ + const Text('Page 3'), + GestureDetector( + onTap: () { + Navigator.pushNamed(context, '/4'); + }, + child: const Text('Next'), + ), + GestureDetector( + onTap: () { + Navigator.pop(context); + }, + child: const Text('Back'), + ), + ], ), ); }, ), - // A route that uses ZoomPageTransitionsBuilder. '/4' => _TestTransitionRoute( - pageTransitionsBuilder: const ZoomPageTransitionsBuilder(), + pageTransitionsBuilder: const _TestSlightRightPageTransitionsBuilder(), builder: (BuildContext context) { - return Scaffold( - body: Center( - child: Column( - children: [ - const Text('Page 4'), - TextButton( - onPressed: () { - Navigator.pop(context); - }, - child: const Text('Back'), - ), - ], - ), + return Center( + child: Column( + children: [ + const Text('Page 4'), + GestureDetector( + onTap: () { + Navigator.pop(context); + }, + child: const Text('Back'), + ), + ], ), ); }, @@ -129,7 +118,7 @@ void main() { expect( observer.transitionDuration, - const FadeForwardsPageTransitionsBuilder().transitionDuration, + const _TestSlideUpPageTransitionsBuilder().transitionDuration, ); await tester.tap(find.text('Next')); @@ -153,7 +142,10 @@ void main() { expect(find.text('Page 4'), findsNothing); await tester.tap(find.text('Next')); - expect(observer.transitionDuration, const ZoomPageTransitionsBuilder().transitionDuration); + expect( + observer.transitionDuration, + const _TestSlightRightPageTransitionsBuilder().transitionDuration, + ); await observer.pumpPastTransition(tester); @@ -165,7 +157,7 @@ void main() { await tester.tap(find.text('Back')); expect( observer.transitionDuration, - const ZoomPageTransitionsBuilder().reverseTransitionDuration, + const _TestSlightRightPageTransitionsBuilder().reverseTransitionDuration, ); await observer.pumpPastTransition(tester); @@ -202,16 +194,15 @@ void main() { final observer = TransitionDurationObserver(); await tester.pumpWidget( - MaterialApp( + WidgetsApp( + color: const Color(0xFFFFFFFF), navigatorObservers: [observer], onGenerateRoute: (RouteSettings settings) { return switch (settings.name) { // A route with no transition. '/' => _TestOverlayRoute( builder: (BuildContext context) { - return const Scaffold( - body: Center(child: Column(children: [Text('Page 1')])), - ); + return const Center(child: Column(children: [Text('Page 1')])); }, ), _ => throw Exception('Invalid route.'), @@ -225,18 +216,78 @@ void main() { }); } -class _TestTransitionRoute extends MaterialPageRoute { +class _TestSlightRightPageTransitionsBuilder extends PageTransitionsBuilder { + const _TestSlightRightPageTransitionsBuilder(); + + @override + Widget buildTransitions( + PageRoute route, + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + const begin = Offset(1.0, 0.0); + const Offset end = .zero; + final Animatable tween = Tween( + begin: begin, + end: end, + ).chain(CurveTween(curve: Curves.ease)); + + return SlideTransition( + position: animation.drive(tween), + child: FadeTransition(opacity: animation, child: child), + ); + } +} + +class _TestSlideUpPageTransitionsBuilder extends PageTransitionsBuilder { + const _TestSlideUpPageTransitionsBuilder(); + + @override + Widget buildTransitions( + PageRoute route, + BuildContext context, + Animation animation, + Animation secondaryAnimation, + Widget child, + ) { + const begin = Offset(0.0, 1.0); + const Offset end = .zero; + final Animatable tween = Tween( + begin: begin, + end: end, + ).chain(CurveTween(curve: Curves.ease)); + + return SlideTransition( + position: animation.drive(tween), + child: FadeTransition(opacity: animation, child: child), + ); + } +} + +class _TestTransitionRoute extends PageRoute { _TestTransitionRoute({ - required super.builder, + required this.builder, required this.pageTransitionsBuilder, this.transitionDurationOverride, this.reverseTransitionDurationOverride, }); + final WidgetBuilder builder; final PageTransitionsBuilder pageTransitionsBuilder; final Duration? transitionDurationOverride; final Duration? reverseTransitionDurationOverride; + @override + Widget buildPage( + BuildContext context, + Animation animation, + Animation secondaryAnimation, + ) { + return builder(context); + } + @override Widget buildTransitions( BuildContext context, @@ -253,6 +304,15 @@ class _TestTransitionRoute extends MaterialPageRoute { ); } + @override + Color? get barrierColor => null; + + @override + String? get barrierLabel => null; + + @override + bool get maintainState => true; + @override Duration get transitionDuration => transitionDurationOverride ?? pageTransitionsBuilder.transitionDuration; diff --git a/packages/flutter_test/test/semantics_finder_test.dart b/packages/flutter_test/test/semantics_finder_test.dart index d64b619b00ad8..06253b4386c55 100644 --- a/packages/flutter_test/test/semantics_finder_test.dart +++ b/packages/flutter_test/test/semantics_finder_test.dart @@ -4,7 +4,7 @@ import 'dart:ui'; -import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'multi_view_testing.dart'; diff --git a/packages/flutter_test/test/test_text_input_test.dart b/packages/flutter_test/test/test_text_input_test.dart index 4223d18fb380c..56778e40724ee 100644 --- a/packages/flutter_test/test/test_text_input_test.dart +++ b/packages/flutter_test/test/test_text_input_test.dart @@ -8,29 +8,8 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { - PageRoute defaultPageRouteBuilder(RouteSettings settings, WidgetBuilder builder) { - return PageRouteBuilder( - settings: settings, - pageBuilder: - ( - BuildContext context, - Animation animation, - Animation secondaryAnimation, - ) => builder(context), - transitionsBuilder: - ( - BuildContext context, - Animation animation, - Animation secondaryAnimation, - Widget child, - ) => child, - ); - } - Widget buildTestApp({required TextEditingController controller, required FocusNode focusNode}) { - return WidgetsApp( - color: const Color(0xFFFFFFFF), - pageRouteBuilder: defaultPageRouteBuilder, + return TestWidgetsApp( home: SizedBox.expand( child: Center( child: EditableText( diff --git a/packages/flutter_test/test/utils/memory_leak_tests.dart b/packages/flutter_test/test/utils/memory_leak_tests.dart index 055304e8b7ae1..024795ba83899 100644 --- a/packages/flutter_test/test/utils/memory_leak_tests.dart +++ b/packages/flutter_test/test/utils/memory_leak_tests.dart @@ -2,8 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:leak_tracker_flutter_testing/leak_tracker_flutter_testing.dart'; diff --git a/packages/flutter_test/test/widget_tester_live_device_test.dart b/packages/flutter_test/test/widget_tester_live_device_test.dart index 936b541a478bf..937f18e1b3c7d 100644 --- a/packages/flutter_test/test/widget_tester_live_device_test.dart +++ b/packages/flutter_test/test/widget_tester_live_device_test.dart @@ -3,8 +3,8 @@ // found in the LICENSE file. import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; // Only check the initial lines of the message, since the message walks the diff --git a/packages/flutter_test/test_fixes/flutter_test/animation_sheet_builder.dart b/packages/flutter_test/test_fixes/flutter_test/animation_sheet_builder.dart index 27710c6e4f17a..c929745d52609 100644 --- a/packages/flutter_test/test_fixes/flutter_test/animation_sheet_builder.dart +++ b/packages/flutter_test/test_fixes/flutter_test/animation_sheet_builder.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { diff --git a/packages/flutter_test/test_fixes/flutter_test/animation_sheet_builder.dart.expect b/packages/flutter_test/test_fixes/flutter_test/animation_sheet_builder.dart.expect index 5375732f1b272..b4c36e9d1cadd 100644 --- a/packages/flutter_test/test_fixes/flutter_test/animation_sheet_builder.dart.expect +++ b/packages/flutter_test/test_fixes/flutter_test/animation_sheet_builder.dart.expect @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { diff --git a/packages/flutter_test/test_fixes/flutter_test/matchers.dart b/packages/flutter_test/test_fixes/flutter_test/matchers.dart index faafd100d74af..9c197c7e2d507 100644 --- a/packages/flutter_test/test_fixes/flutter_test/matchers.dart +++ b/packages/flutter_test/test_fixes/flutter_test/matchers.dart @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { diff --git a/packages/flutter_test/test_fixes/flutter_test/matchers.dart.expect b/packages/flutter_test/test_fixes/flutter_test/matchers.dart.expect index 6ee96f10d9e0e..495dbbacefb25 100644 --- a/packages/flutter_test/test_fixes/flutter_test/matchers.dart.expect +++ b/packages/flutter_test/test_fixes/flutter_test/matchers.dart.expect @@ -2,7 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { diff --git a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt index f69259cd66d9b..50779e6d0b8d0 100644 --- a/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt +++ b/packages/flutter_tools/gradle/src/main/kotlin/FlutterPluginUtils.kt @@ -795,8 +795,10 @@ object FlutterPluginUtils { // If the project is already configuring a native build, we don't need to do anything. val gradleProjectAndroidExtension = getLegacyAndroidExtension(gradleProject) + val externalNativeBuild = gradleProjectAndroidExtension.externalNativeBuild val forcingNotRequired: Boolean = - gradleProjectAndroidExtension.externalNativeBuild.cmake.path != null + externalNativeBuild?.cmake?.path != null || + externalNativeBuild?.ndkBuild?.path != null if (forcingNotRequired) { return } diff --git a/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt b/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt index c3b736726d692..21fc5d8786f9d 100644 --- a/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt +++ b/packages/flutter_tools/gradle/src/test/kotlin/FlutterPluginUtilsTest.kt @@ -1875,6 +1875,7 @@ class FlutterPluginUtilsTest { fakeCmakeFile.createNewFile() val project = mockk() val mockCmakeOptions = mockk() + val mockNdkBuildOptions = mockk() val mockDefaultConfig = mockk() every { project.extensions.findByType(ApplicationExtension::class.java) } returns null every { @@ -1882,9 +1883,15 @@ class FlutterPluginUtilsTest { .findByType(BaseExtension::class.java)!! .externalNativeBuild.cmake } returns mockCmakeOptions + every { + project.extensions + .findByType(BaseExtension::class.java)!! + .externalNativeBuild.ndkBuild + } returns mockNdkBuildOptions every { project.extensions.findByType(BaseExtension::class.java)!!.defaultConfig } returns mockDefaultConfig every { mockCmakeOptions.path } returns fakeCmakeFile + every { mockNdkBuildOptions.path } returns null FlutterPluginUtils.forceNdkDownload(project, "ignored") @@ -1896,6 +1903,44 @@ class FlutterPluginUtilsTest { } @Test + fun `forceNdkDownload skips projects which are already configuring an ndk-build`( + @TempDir tempDir: Path + ) { + val fakeAndroidMkFile = tempDir.resolve("Android.mk").toFile() + fakeAndroidMkFile.createNewFile() + val project = mockk() + val mockCmakeOptions = mockk() + val mockNdkBuildOptions = mockk() + val mockDefaultConfig = mockk() + every { project.extensions.findByType(ApplicationExtension::class.java) } returns null + every { + project.extensions + .findByType(BaseExtension::class.java)!! + .externalNativeBuild.cmake + } returns mockCmakeOptions + every { + project.extensions + .findByType(BaseExtension::class.java)!! + .externalNativeBuild.ndkBuild + } returns mockNdkBuildOptions + every { project.extensions.findByType(BaseExtension::class.java)!!.defaultConfig } returns mockDefaultConfig + + every { mockCmakeOptions.path } returns null + every { mockNdkBuildOptions.path } returns fakeAndroidMkFile + + FlutterPluginUtils.forceNdkDownload(project, "ignored") + + verify(exactly = 1) { + mockCmakeOptions.path + } + verify(exactly = 1) { + mockNdkBuildOptions.path + } + verify(exactly = 0) { mockCmakeOptions.path(any()) } + verify(exactly = 0) { mockCmakeOptions.buildStagingDirectory(any()) } + verify { mockDefaultConfig wasNot called } + } + fun `forceNdkDownload installs a missing ndk when tool properties are provided`( @TempDir tempDir: Path ) { @@ -1906,10 +1951,13 @@ class FlutterPluginUtilsTest { val mockExecResult = mockk() val mockExecOperations = mockk() val mockCmakeOptions = mockk() + val mockNdkBuildOptions = mockk() val mockDefaultConfig = mockk() val mockBaseExtension = mockk() every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension every { mockBaseExtension.externalNativeBuild.cmake } returns mockCmakeOptions + every { mockBaseExtension.externalNativeBuild.ndkBuild } returns mockNdkBuildOptions + every { mockNdkBuildOptions.path } returns null every { mockBaseExtension.defaultConfig } returns mockDefaultConfig every { mockBaseExtension.ndkVersion } returns "29.0.13846066" every { mockCmakeOptions.path } returns null @@ -1954,10 +2002,13 @@ class FlutterPluginUtilsTest { val project = mockk() val finalizeDslSlot = captureFinalizeDslAction(project) val mockCmakeOptions = mockk() + val mockNdkBuildOptions = mockk() val mockDefaultConfig = mockk() val mockBaseExtension = mockk() every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension every { mockBaseExtension.externalNativeBuild.cmake } returns mockCmakeOptions + every { mockBaseExtension.externalNativeBuild.ndkBuild } returns mockNdkBuildOptions + every { mockNdkBuildOptions.path } returns null every { mockBaseExtension.defaultConfig } returns mockDefaultConfig every { mockBaseExtension.ndkVersion } returns "29.0.13846066" every { mockCmakeOptions.path } returns null @@ -1981,6 +2032,7 @@ class FlutterPluginUtilsTest { val project = mockk() val finalizeDslSlot = captureFinalizeDslAction(project) val mockCmakeOptions = mockk() + val mockNdkBuildOptions = mockk() val mockDefaultConfig = mockk() val mockDirectoryProperty = mockk() val mockDirectory = mockk() @@ -1989,6 +2041,8 @@ class FlutterPluginUtilsTest { every { project.extensions.findByType(ApplicationExtension::class.java) } returns null every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension every { mockBaseExtension.externalNativeBuild.cmake } returns mockCmakeOptions + every { mockBaseExtension.externalNativeBuild.ndkBuild } returns mockNdkBuildOptions + every { mockNdkBuildOptions.path } returns null every { mockBaseExtension.defaultConfig } returns mockDefaultConfig every { mockBaseExtension.ndkVersion } returns "29.0.13846066" every { mockCmakeOptions.path } answers { cmakePath } @@ -2032,11 +2086,14 @@ class FlutterPluginUtilsTest { val mockExecResult = mockk() val mockExecOperations = mockk() val mockCmakeOptions = mockk() + val mockNdkBuildOptions = mockk() val mockDefaultConfig = mockk() val mockBaseExtension = mockk() var configuredNdkVersion = "26.3.11579264" every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension every { mockBaseExtension.externalNativeBuild.cmake } returns mockCmakeOptions + every { mockBaseExtension.externalNativeBuild.ndkBuild } returns mockNdkBuildOptions + every { mockNdkBuildOptions.path } returns null every { mockBaseExtension.defaultConfig } returns mockDefaultConfig every { mockBaseExtension.ndkVersion } answers { configuredNdkVersion } every { mockCmakeOptions.path } returns null @@ -2090,6 +2147,7 @@ class FlutterPluginUtilsTest { val mockExecResult = mockk() val mockExecOperations = mockk() val mockCmakeOptions = mockk() + val mockNdkBuildOptions = mockk() val mockDefaultConfig = mockk() val mockBaseExtension = mockk() val mockApplicationExtension = mockk() @@ -2099,6 +2157,8 @@ class FlutterPluginUtilsTest { project.extensions.findByType(ApplicationExtension::class.java) } returns mockApplicationExtension every { mockBaseExtension.externalNativeBuild.cmake } returns mockCmakeOptions + every { mockBaseExtension.externalNativeBuild.ndkBuild } returns mockNdkBuildOptions + every { mockNdkBuildOptions.path } returns null every { mockBaseExtension.defaultConfig } returns mockDefaultConfig every { mockBaseExtension.ndkVersion } answers { throw AssertionError( @@ -2150,11 +2210,14 @@ class FlutterPluginUtilsTest { val project = mockk() val finalizeDslSlot = captureFinalizeDslAction(project) val mockCmakeOptions = mockk() + val mockNdkBuildOptions = mockk() val mockDefaultConfig = mockk() val mockBaseExtension = mockk() every { project.extensions.findByType(ApplicationExtension::class.java) } returns null every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension every { mockBaseExtension.externalNativeBuild.cmake } returns mockCmakeOptions + every { mockBaseExtension.externalNativeBuild.ndkBuild } returns mockNdkBuildOptions + every { mockNdkBuildOptions.path } returns null every { mockBaseExtension.defaultConfig } returns mockDefaultConfig every { mockBaseExtension.ndkVersion } returns "29.0.13846066" every { mockCmakeOptions.path } returns null @@ -2175,12 +2238,15 @@ class FlutterPluginUtilsTest { val project = mockk() val finalizeDslSlot = captureFinalizeDslAction(project) val mockCmakeOptions = mockk() + val mockNdkBuildOptions = mockk() val mockDefaultConfig = mockk() val mockBaseExtension = mockk() val mockApplicationExtension = mockk() every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension every { project.extensions.findByType(ApplicationExtension::class.java) } returns mockApplicationExtension every { mockBaseExtension.externalNativeBuild.cmake } returns mockCmakeOptions + every { mockBaseExtension.externalNativeBuild.ndkBuild } returns mockNdkBuildOptions + every { mockNdkBuildOptions.path } returns null every { mockBaseExtension.defaultConfig } returns mockDefaultConfig every { mockBaseExtension.ndkVersion } answers { throw AssertionError("legacy ndkVersion should not be read when ApplicationExtension is available") @@ -2208,10 +2274,13 @@ class FlutterPluginUtilsTest { val mockExecResult = mockk() val mockExecOperations = mockk() val mockCmakeOptions = mockk() + val mockNdkBuildOptions = mockk() val mockDefaultConfig = mockk() val mockBaseExtension = mockk() every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension every { mockBaseExtension.externalNativeBuild.cmake } returns mockCmakeOptions + every { mockBaseExtension.externalNativeBuild.ndkBuild } returns mockNdkBuildOptions + every { mockNdkBuildOptions.path } returns null every { mockBaseExtension.defaultConfig } returns mockDefaultConfig every { mockBaseExtension.ndkVersion } returns "29.0.13846066" every { mockCmakeOptions.path } returns null @@ -2262,6 +2331,7 @@ class FlutterPluginUtilsTest { val project = mockk() val finalizeDslSlot = captureFinalizeDslAction(project) val mockCmakeOptions = mockk() + val mockNdkBuildOptions = mockk() val mockDefaultConfig = mockk() val mockDirectoryProperty = mockk() val mockDirectory = mockk() @@ -2269,6 +2339,8 @@ class FlutterPluginUtilsTest { every { project.extensions.findByType(ApplicationExtension::class.java) } returns null every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension every { mockBaseExtension.externalNativeBuild.cmake } returns mockCmakeOptions + every { mockBaseExtension.externalNativeBuild.ndkBuild } returns mockNdkBuildOptions + every { mockNdkBuildOptions.path } returns null every { mockBaseExtension.defaultConfig } returns mockDefaultConfig every { mockBaseExtension.ndkVersion } returns "29.0.13846066" every { mockCmakeOptions.path } returns null @@ -2310,6 +2382,7 @@ class FlutterPluginUtilsTest { val project = mockk() val finalizeDslSlot = captureFinalizeDslAction(project) val mockCmakeOptions = mockk() + val mockNdkBuildOptions = mockk() val mockDefaultConfig = mockk() val mockDirectoryProperty = mockk() val mockDirectory = mockk() @@ -2317,6 +2390,8 @@ class FlutterPluginUtilsTest { every { project.extensions.findByType(ApplicationExtension::class.java) } returns null every { project.extensions.findByType(BaseExtension::class.java) } returns mockBaseExtension every { mockBaseExtension.externalNativeBuild.cmake } returns mockCmakeOptions + every { mockBaseExtension.externalNativeBuild.ndkBuild } returns mockNdkBuildOptions + every { mockNdkBuildOptions.path } returns null every { mockBaseExtension.defaultConfig } returns mockDefaultConfig every { mockBaseExtension.ndkVersion } returns "29.0.13846066" every { mockCmakeOptions.path } returns null @@ -2358,6 +2433,7 @@ class FlutterPluginUtilsTest { fun `forceNdkDownload sets externalNativeBuild properties`() { val project = mockk() val mockCmakeOptions = mockk() + val mockNdkBuildOptions = mockk() val mockDefaultConfig = mockk() val mockDirectoryProperty = mockk() val mockDirectory = mockk() @@ -2370,17 +2446,24 @@ class FlutterPluginUtilsTest { .findByType(BaseExtension::class.java)!! .externalNativeBuild.cmake } returns mockCmakeOptions + every { + project.extensions + .findByType(BaseExtension::class.java)!! + .externalNativeBuild.ndkBuild + } returns mockNdkBuildOptions every { project.extensions.findByType(BaseExtension::class.java)!!.defaultConfig } returns mockDefaultConfig val basePath = "/base/path" val fakeBuildPath = "/randomapp/build/app/" every { mockCmakeOptions.path } returns null + every { mockNdkBuildOptions.path } returns null every { mockCmakeOptions.path(any()) } returns Unit every { mockCmakeOptions.buildStagingDirectory(any()) } returns Unit every { project.layout.buildDirectory } returns mockDirectoryProperty every { mockDirectoryProperty.dir(any()) } returns mockDirectoryProperty every { mockDirectoryProperty.get() } returns mockDirectory - every { mockDirectory.asFile.path } returns fakeBuildPath + val realFile = File(fakeBuildPath) + every { mockDirectory.asFile } returns realFile val mockBuildType = mockk() every { diff --git a/packages/flutter_tools/lib/src/android/android_device.dart b/packages/flutter_tools/lib/src/android/android_device.dart index 39410a8b543f7..29cf0aef59a5d 100644 --- a/packages/flutter_tools/lib/src/android/android_device.dart +++ b/packages/flutter_tools/lib/src/android/android_device.dart @@ -549,27 +549,15 @@ class AndroidDevice extends Device { final TargetPlatform devicePlatform = await targetPlatform; var builtPackage = package; - AndroidArch androidArch; - switch (devicePlatform) { - case TargetPlatform.android_arm: - androidArch = AndroidArch.armeabi_v7a; - case TargetPlatform.android_arm64: - androidArch = AndroidArch.arm64_v8a; - case TargetPlatform.android_x64: - androidArch = AndroidArch.x86_64; - case TargetPlatform.android: - case TargetPlatform.darwin: - case TargetPlatform.fuchsia_arm64: - case TargetPlatform.fuchsia_x64: - case TargetPlatform.ios: - case TargetPlatform.linux_arm64: - case TargetPlatform.linux_riscv64: - case TargetPlatform.linux_x64: - case TargetPlatform.tester: - case TargetPlatform.web_javascript: - case TargetPlatform.windows_arm64: - case TargetPlatform.windows_x64: - case TargetPlatform.unsupported: + final CpuArch cpuArch = await this.cpuArch; + switch (cpuArch) { + case CpuArch.armv7: + case CpuArch.arm64: + case CpuArch.x64: + break; + case CpuArch.x86: + case CpuArch.riscv64: + case CpuArch.unknown: _logger.printError('Android platforms are only supported.'); return LaunchResult.failed(); } @@ -583,7 +571,7 @@ class AndroidDevice extends Device { target: mainPath ?? 'lib/main.dart', androidBuildInfo: AndroidBuildInfo( debuggingOptions.buildInfo, - targetArchs: [androidArch], + targetArchs: [cpuArch], ), ); // Package has been built, so we can get the updated application ID and 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 0caa60183534d..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,18 +113,17 @@ 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)"). - // The column separator requires at least two spaces or a tab to prevent - // single spaces within a serial from being mis-matched. + // 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 - // prevents false positive state matching on extra device info/attributes - // or serial name components. + // 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{2,}|\t+)' + r'^(.*)\s+' r'(device|offline|unauthorized|no permissions|bootloader|recovery|sideload|rescue|connecting|authorizing|host|unknown)' r'(?:\s+(.*)|$)', ); @@ -163,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_studio.dart b/packages/flutter_tools/lib/src/android/android_studio.dart index 312384ffb683d..03b611d8f004f 100644 --- a/packages/flutter_tools/lib/src/android/android_studio.dart +++ b/packages/flutter_tools/lib/src/android/android_studio.dart @@ -5,6 +5,8 @@ /// @docImport 'java.dart'; library; +import 'package:meta/meta.dart'; + import '../base/common.dart'; import '../base/file_system.dart'; import '../base/io.dart'; @@ -15,6 +17,17 @@ import '../convert.dart'; import '../globals.dart' as globals; import '../ios/plist_parser.dart'; +@visibleForTesting +const String kSpotlightMdfindCommand = + r'( ' + r' for ((i = 0; i < 30; i++)); do ' + r' sleep .1; ' + r' kill -0 $$ || exit 0; ' + r' done; ' + r' kill -9 $$; ' + r') 2>/dev/null & ' + 'exec mdfind \'kMDItemCFBundleIdentifier="com.google.android.studio*"\''; + const _androidStudioTitle = 'Android Studio'; const _androidStudioId = 'AndroidStudio'; const _androidStudioPreviewTitle = 'Android Studio Preview'; @@ -322,13 +335,22 @@ class AndroidStudio { } // Query Spotlight for unexpected installation locations. + // Spotlight (mds_stores/mdworker) can become unresponsive or hang during heavy indexing on macOS. + // Wrap mdfind in a shell execution with a 3-second timeout to prevent flutter doctor + // and flutter daemon from hanging indefinitely (https://github.com/flutter/flutter/issues/189177). var spotlightQueryResult = ''; try { final ProcessResult spotlightResult = globals.processManager.runSync([ - 'mdfind', + 'sh', + '-c', // com.google.android.studio, com.google.android.studio-EAP - 'kMDItemCFBundleIdentifier="com.google.android.studio*"', + kSpotlightMdfindCommand, ]); + if (spotlightResult.exitCode != 0) { + globals.printTrace( + 'Spotlight mdfind query failed or timed out with exit code ${spotlightResult.exitCode}', + ); + } spotlightQueryResult = spotlightResult.stdout as String; } on ProcessException { // The Spotlight query is a nice-to-have, continue checking known installation locations. diff --git a/packages/flutter_tools/lib/src/android/gradle.dart b/packages/flutter_tools/lib/src/android/gradle.dart index 3468f2dadea8f..be7e0313e6a1b 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'; @@ -148,8 +149,8 @@ Iterable _apkFilesFor(AndroidBuildInfo androidBuildInfo) { final String productFlavor = androidBuildInfo.buildInfo.lowerCasedFlavor ?? ''; final flavorString = productFlavor.isEmpty ? '' : '-$productFlavor'; if (androidBuildInfo.splitPerAbi) { - return androidBuildInfo.targetArchs.map((AndroidArch arch) { - final String abi = arch.archName; + return androidBuildInfo.targetArchs.map((CpuArch arch) { + final String abi = arch.androidArchName; return 'app$flavorString-$abi-$buildType.apk'; }); } @@ -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()]. @@ -525,7 +576,7 @@ class AndroidGradleBuilder implements AndroidBuilder { ); } else if (androidBuildInfo.targetArchs.isNotEmpty) { final String targetPlatforms = androidBuildInfo.targetArchs - .map((AndroidArch e) => e.platformName) + .map((CpuArch e) => e.androidPlatformName) .join(','); options.add('-Ptarget-platform=$targetPlatforms'); } @@ -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, @@ -677,7 +729,7 @@ class AndroidGradleBuilder implements AndroidBuilder { Future _isAabStrippedOfDebugSymbols( FlutterProject project, String aabPath, - Iterable targetArchs, + Iterable targetArchs, ) async { if (_androidSdk == null) { _logger.printTrace( @@ -744,7 +796,7 @@ class AndroidGradleBuilder implements AndroidBuilder { logger: _logger, analytics: _analytics, ); - final String archName = androidBuildInfo.targetArchs.single.archName; + final String archName = androidBuildInfo.targetArchs.single.androidArchName; final BuildInfo buildInfo = androidBuildInfo.buildInfo; final File aotSnapshot = _fileSystem .directory(buildInfo.codeSizeDirectory) @@ -865,7 +917,7 @@ class AndroidGradleBuilder implements AndroidBuilder { ); } else if (androidBuildInfo.targetArchs.isNotEmpty) { final String targetPlatforms = androidBuildInfo.targetArchs - .map((AndroidArch e) => e.platformName) + .map((CpuArch e) => e.androidPlatformName) .join(','); command.add('-Ptarget-platform=$targetPlatforms'); } @@ -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, @@ -1128,7 +1181,25 @@ bool isAppUsingAndroidX(Directory androidDirectory) { if (!properties.existsSync()) { return false; } - return properties.readAsStringSync().contains('android.useAndroidX=true'); + bool? usesAndroidX; + final androidXRegExp = RegExp(r'^android\.useAndroidX(?:\s*[=:]\s*|\s+)(\S+)'); + for (final String rawLine in properties.readAsLinesSync()) { + final String line = rawLine.trimLeft(); + if (line.isEmpty || line.startsWith('#') || line.startsWith('!')) { + continue; + } + final RegExpMatch? match = androidXRegExp.firstMatch(line); + if (match == null) { + continue; + } + final String value = match.group(1)!.toLowerCase(); + if (value == 'true') { + usesAndroidX = true; + } else if (value == 'false') { + usesAndroidX = false; + } + } + return usesAndroidX ?? false; } /// Returns the APK files for a given [FlutterProject] and [AndroidBuildInfo]. @@ -1187,8 +1258,8 @@ Iterable listApkPaths(AndroidBuildInfo androidBuildInfo) { ]; if (androidBuildInfo.splitPerAbi) { return [ - for (final AndroidArch androidArch in androidBuildInfo.targetArchs) - ['app', androidArch.archName, ...apkPartialName].join('-'), + for (final CpuArch cpuArch in androidBuildInfo.targetArchs) + ['app', cpuArch.androidArchName, ...apkPartialName].join('-'), ]; } return [ diff --git a/packages/flutter_tools/lib/src/android/gradle_utils.dart b/packages/flutter_tools/lib/src/android/gradle_utils.dart index 770e287a4d2f4..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')) { diff --git a/packages/flutter_tools/lib/src/base/build.dart b/packages/flutter_tools/lib/src/base/build.dart index 8c724e68b76d3..d836785258489 100644 --- a/packages/flutter_tools/lib/src/base/build.dart +++ b/packages/flutter_tools/lib/src/base/build.dart @@ -57,11 +57,15 @@ class GenSnapshot { Future run({ required SnapshotType snapshotType, - DarwinArch? darwinArch, + // TODO(chingjun): The [CpuArch] parameter is only used for iOS builds (to + // select the correct per-architecture gen_snapshot). This architecture + // information should instead be consolidated into [TargetPlatform] so that + // callers do not need to pass it separately. + CpuArch? cpuArch, Iterable additionalArgs = const [], }) { - assert(darwinArch != DarwinArch.armv7); - assert(snapshotType.platform != TargetPlatform.ios || darwinArch != null); + assert(cpuArch != CpuArch.armv7); + assert(snapshotType.platform != TargetPlatform.ios || cpuArch != null); final args = [...additionalArgs]; // iOS and macOS have separate gen_snapshot binaries for each target @@ -70,7 +74,7 @@ class GenSnapshot { Artifact genSnapshotArtifact; if (snapshotType.platform == TargetPlatform.ios || snapshotType.platform == TargetPlatform.darwin) { - genSnapshotArtifact = darwinArch == DarwinArch.arm64 + genSnapshotArtifact = cpuArch == CpuArch.arm64 ? Artifact.genSnapshotArm64 : Artifact.genSnapshotX64; } else { @@ -113,14 +117,14 @@ class AOTSnapshotter { required BuildMode buildMode, required String mainPath, required String outputPath, - DarwinArch? darwinArch, + CpuArch? cpuArch, String? sdkRoot, List extraGenSnapshotOptions = const [], String? splitDebugInfo, required bool dartObfuscation, bool quiet = false, }) async { - assert(platform != TargetPlatform.ios || darwinArch != null); + assert(platform != TargetPlatform.ios || cpuArch != null); if (!_isValidAotPlatform(platform, buildMode)) { _logger.printError('${platform.getName()} does not support AOT compilation.'); @@ -168,7 +172,7 @@ class AOTSnapshotter { // library that the end-developer can link into their app. const frameworkName = 'App.framework'; if (!quiet) { - final String targetArch = darwinArch!.name; + final String targetArch = cpuArch!.darwinArchName; _logger.printStatus('Building $frameworkName for $targetArch...'); } frameworkPath = _fileSystem.path.join(outputPath, frameworkName); @@ -226,7 +230,7 @@ class AOTSnapshotter { // The name of the debug file must contain additional information about // the architecture, since a single build command may produce // multiple debug files. - final String archName = platform.getName(darwinArch: darwinArch); + final String archName = platform.getName(cpuArch: cpuArch); final debugFilename = 'app.$archName.symbols'; final bool shouldSplitDebugInfo = splitDebugInfo?.isNotEmpty ?? false; if (shouldSplitDebugInfo) { @@ -249,7 +253,7 @@ class AOTSnapshotter { final int genSnapshotExitCode = await _genSnapshot.run( snapshotType: snapshotType, additionalArgs: genSnapshotArgs, - darwinArch: darwinArch, + cpuArch: cpuArch, ); if (genSnapshotExitCode != 0) { _logger.printError('Dart snapshot generator failed with exit code $genSnapshotExitCode'); diff --git a/packages/flutter_tools/lib/src/build_info.dart b/packages/flutter_tools/lib/src/build_info.dart index 8f768c08e1cd7..551da4d7bc108 100644 --- a/packages/flutter_tools/lib/src/build_info.dart +++ b/packages/flutter_tools/lib/src/build_info.dart @@ -433,11 +433,7 @@ class BuildInfo { class AndroidBuildInfo { const AndroidBuildInfo( this.buildInfo, { - this.targetArchs = const [ - AndroidArch.armeabi_v7a, - AndroidArch.arm64_v8a, - AndroidArch.x86_64, - ], + this.targetArchs = const [.armv7, .arm64, .x64], this.splitPerAbi = false, }); @@ -452,7 +448,7 @@ class AndroidBuildInfo { final bool splitPerAbi; /// The target platforms for the build. - final Iterable targetArchs; + final Iterable targetArchs; } /// A summary of the compilation strategy used for Dart. @@ -632,12 +628,12 @@ enum CpuArch { factory CpuArch.fromName(String name) { return switch (name) { - 'unknown' => CpuArch.unknown, - 'armv7' => CpuArch.armv7, - 'arm64' => CpuArch.arm64, - 'x86' => CpuArch.x86, - 'x64' || 'x86_64' => CpuArch.x64, - 'riscv64' => CpuArch.riscv64, + 'unknown' => .unknown, + 'armv7' => .armv7, + 'arm64' => .arm64, + 'x86' => .x86, + 'x64' || 'x86_64' => .x64, + 'riscv64' => .riscv64, _ => throw Exception('Unsupported CPU arch name "$name"'), }; } @@ -645,11 +641,58 @@ enum CpuArch { /// The [CpuArch] of the given [hostPlatform]. factory CpuArch.fromHostPlatform(HostPlatform hostPlatform) { return switch (hostPlatform) { - .darwin_x64 || .linux_x64 || .windows_x64 => CpuArch.x64, - .darwin_arm64 || .linux_arm64 || .windows_arm64 => CpuArch.arm64, - .linux_riscv64 => CpuArch.riscv64, + .darwin_x64 || .linux_x64 || .windows_x64 => .x64, + .darwin_arm64 || .linux_arm64 || .windows_arm64 => .arm64, + .linux_riscv64 => .riscv64, }; } + + /// Returns the Dart SDK's name for the specified target architecture. + /// + /// When building for Darwin platforms, the tool invokes architecture-specific + /// variants of `gen_snapshot`, one for each target architecture. The output + /// instructions are then built into architecture-specific binaries, which are + /// merged into a universal binary using the `lipo` tool. + String get dartName { + return switch (this) { + armv7 => 'armv7', + arm64 => 'arm64', + x86 => 'x86', + x64 => 'x64', + riscv64 => 'riscv64', + unknown => throw UnsupportedError('Unexpected CPU arch $this'), + }; + } + + /// The Apple architecture name for this architecture. + /// + /// This is the name understood by the Darwin toolchain (e.g. `lipo`, `clang`, + /// and the `-arch` flag) and used for architecture-specific build output + /// directories on iOS and macOS. This differs from [dartName] for [x64], + /// which maps to `x86_64` here. + String get darwinArchName => switch (this) { + armv7 => 'armv7', + arm64 => 'arm64', + x64 => 'x86_64', + x86 || riscv64 || unknown => throw UnsupportedError('Unexpected Darwin CPU arch $this'), + }; + + /// The name of the Android ABI (as used in `jniLibs` directories) for this + /// architecture. + String get androidArchName => switch (this) { + armv7 => 'armeabi-v7a', + arm64 => 'arm64-v8a', + x64 => 'x86_64', + x86 || riscv64 || unknown => throw UnsupportedError('Unexpected Android CPU arch $this'), + }; + + /// The `TargetPlatform` name of the Android platform for this architecture. + String get androidPlatformName => switch (this) { + armv7 => 'android-arm', + arm64 => 'android-arm64', + x64 => 'android-x64', + x86 || riscv64 || unknown => throw UnsupportedError('Unexpected Android CPU arch $this'), + }; } enum TargetPlatform { @@ -667,8 +710,7 @@ enum TargetPlatform { web_javascript('web-javascript'), // The arch specific android target platforms are soft-deprecated. // Instead of using TargetPlatform as a combination arch + platform - // the code will be updated to carry arch information in [DarwinArch] - // and [AndroidArch]. + // the code will be updated to carry arch information in [CpuArch]. android_arm('android-arm'), android_arm64('android-arm64'), android_x64('android-x64'), @@ -701,10 +743,10 @@ enum TargetPlatform { final String _defaultName; - String getName({DarwinArch? darwinArch}) { + String getName({CpuArch? cpuArch}) { return switch (this) { - TargetPlatform.ios when darwinArch != null => 'ios-${darwinArch.name}', - TargetPlatform.darwin when darwinArch != null => 'darwin-${darwinArch.name}', + TargetPlatform.ios when cpuArch != null => 'ios-${cpuArch.darwinArchName}', + TargetPlatform.darwin when cpuArch != null => 'darwin-${cpuArch.darwinArchName}', _ => _defaultName, }; } @@ -760,136 +802,74 @@ enum TargetPlatform { throw UnsupportedError('Target platform is unsupported.'); } -/// iOS and macOS target device architecture. -// -// TODO(cbracken): split TargetPlatform.ios into ios_armv7, ios_arm64. -enum DarwinArch { - armv7, // Deprecated. Used to display 32-bit unsupported devices. - arm64, - x86_64; - - /// Returns the Dart SDK's name for the specified target architecture. - /// - /// When building for Darwin platforms, the tool invokes architecture-specific - /// variants of `gen_snapshot`, one for each target architecture. The output - /// instructions are then built into architecture-specific binaries, which are - /// merged into a universal binary using the `lipo` tool. - String get dartName { - return switch (this) { - armv7 => 'armv7', - arm64 => 'arm64', - x86_64 => 'x64', - }; - } -} - -// TODO(zanderso): replace all android TargetPlatform usage with AndroidArch. -enum AndroidArch { - armeabi_v7a, - arm64_v8a, - x86_64; - - String get archName => switch (this) { - armeabi_v7a => 'armeabi-v7a', - arm64_v8a => 'arm64-v8a', - x86_64 => 'x86_64', - }; - - String get platformName => switch (this) { - armeabi_v7a => 'android-arm', - arm64_v8a => 'android-arm64', - x86_64 => 'android-x64', - }; -} - /// The default set of iOS device architectures to build for. -List defaultIOSArchsForEnvironment( - EnvironmentType environmentType, - Artifacts artifacts, -) { +List defaultIOSArchsForEnvironment(EnvironmentType environmentType, Artifacts artifacts) { // Handle single-arch local engines. final LocalEngineInfo? localEngineInfo = artifacts.localEngineInfo; if (localEngineInfo != null) { final String localEngineName = localEngineInfo.localTargetName; if (localEngineName.contains('_arm64')) { - return [DarwinArch.arm64]; + return [.arm64]; } if (localEngineName.contains('_sim')) { - return [DarwinArch.x86_64]; + return [.x64]; } } else if (environmentType == EnvironmentType.simulator) { - return [DarwinArch.x86_64, DarwinArch.arm64]; + return [.x64, .arm64]; } - return [DarwinArch.arm64]; + return [.arm64]; } /// The default set of macOS device architectures to build for. -List defaultMacOSArchsForEnvironment(Artifacts artifacts) { +List defaultMacOSArchsForEnvironment(Artifacts artifacts) { // Handle single-arch local engines. final LocalEngineInfo? localEngineInfo = artifacts.localEngineInfo; if (localEngineInfo != null) { if (localEngineInfo.localTargetName.contains('_arm64')) { - return [DarwinArch.arm64]; + return [.arm64]; } - return [DarwinArch.x86_64]; + return [.x64]; } - return [DarwinArch.x86_64, DarwinArch.arm64]; + return [.x64, .arm64]; } -DarwinArch getIOSArchForName(String arch) { - switch (arch) { - case 'armv7': - case 'armv7f': // iPhone 4S. - case 'armv7s': // iPad 4. - return DarwinArch.armv7; - case 'arm64': - case 'arm64e': // iPhone XS/XS Max/XR and higher. arm64 runs on arm64e devices. - return DarwinArch.arm64; - case 'x86_64': - return DarwinArch.x86_64; - } - throw Exception('Unsupported iOS arch name "$arch"'); -} - -DarwinArch getDarwinArchForName(String arch) { - return switch (arch) { - 'arm64' => DarwinArch.arm64, - 'x86_64' => DarwinArch.x86_64, - _ => throw Exception('Unsupported MacOS arch name "$arch"'), +/// Returns the [CpuArch] for the given architecture or platform [name]. +/// +/// This accepts the various naming conventions used across platforms: +/// * Apple architecture names (e.g. `armv7`, `arm64`, `arm64e`, `x86_64`). +/// * Android target platform names (e.g. `android-arm`, `android-arm64`, +/// `android-x64`). +/// +/// Consolidating these into a single lookup is safe because the accepted names +/// do not overlap, so callers on any platform get the expected result. +CpuArch getCpuArchForName(String name) { + return switch (name) { + 'armv7' || + 'armv7f' || // iPhone 4S. + 'armv7s' || // iPad 4. + 'android-arm' => .armv7, + 'arm64' || + 'arm64e' || // iPhone XS/XS Max/XR and higher. arm64 runs on arm64e devices. + 'android-arm64' => .arm64, + 'x86_64' || 'android-x64' => .x64, + _ => throw Exception('Unsupported CPU arch name "$name"'), }; } -List getDarwinArchsFromEnv(Map defines) { - const defaultDarwinArchitectures = [DarwinArch.x86_64, DarwinArch.arm64]; - return defines[kDarwinArchs]?.split(' ').map(getDarwinArchForName).toList() ?? +/// The set of Darwin (iOS/macOS) architectures configured in [defines], or a +/// default of x86_64 and arm64 if unspecified. +List getCpuArchsFromEnv(Map defines) { + const defaultDarwinArchitectures = [.x64, .arm64]; + return defines[kDarwinArchs]?.split(' ').map(getCpuArchForName).toList() ?? defaultDarwinArchitectures; } -AndroidArch getAndroidArchForName(String platform) { - return switch (platform) { - 'android-arm' => AndroidArch.armeabi_v7a, - 'android-arm64' => AndroidArch.arm64_v8a, - 'android-x64' => AndroidArch.x86_64, - _ => throw Exception('Unsupported Android arch name "$platform"'), - }; -} - -DarwinArch getCurrentDarwinArch() { - return switch (globals.os.hostPlatform) { - HostPlatform.darwin_arm64 => DarwinArch.arm64, - HostPlatform.darwin_x64 => DarwinArch.x86_64, - final HostPlatform unsupported => throw Exception( - 'Unsupported Darwin host platform "$unsupported"', - ), - }; -} - HostPlatform getCurrentHostPlatform() { if (globals.platform.isMacOS) { - return switch (getCurrentDarwinArch()) { - DarwinArch.arm64 => HostPlatform.darwin_arm64, - DarwinArch.x86_64 => HostPlatform.darwin_x64, - DarwinArch.armv7 => throw Exception('Unsupported macOS arch "amv7"'), + return switch (globals.os.hostPlatform) { + HostPlatform.darwin_arm64 => .darwin_arm64, + HostPlatform.darwin_x64 => .darwin_x64, + _ => throw Exception('Unsupported Darwin host platform "${globals.os.hostPlatform}"'), }; } if (globals.platform.isLinux) { @@ -1242,8 +1222,8 @@ String? _uncapitalize(String? s) { // flutter_ignore: deprecation_syntax (see analyze.dart) @Deprecated('Use TargetPlatform.getName() instead') -String getNameForTargetPlatform(TargetPlatform platform, {DarwinArch? darwinArch}) { - return platform.getName(darwinArch: darwinArch); +String getNameForTargetPlatform(TargetPlatform platform, {CpuArch? cpuArch}) { + return platform.getName(cpuArch: cpuArch); } // flutter_ignore: deprecation_syntax (see analyze.dart) diff --git a/packages/flutter_tools/lib/src/build_system/targets/android.dart b/packages/flutter_tools/lib/src/build_system/targets/android.dart index 7aa9f8fb871a1..76c2c31fc15dc 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/android.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/android.dart @@ -177,7 +177,7 @@ class AndroidAot extends AotElfBase { /// The name of the produced Android ABI. String get _androidAbiName { - return getAndroidArchForName(targetPlatform.getName()).archName; + return getCpuArchForName(targetPlatform.getName()).androidArchName; } @override @@ -304,7 +304,7 @@ class AndroidAotBundle extends Target { /// The name of the produced Android ABI. String get _androidAbiName { - return getAndroidArchForName(dependency.targetPlatform.getName()).archName; + return getCpuArchForName(dependency.targetPlatform.getName()).androidArchName; } @override @@ -382,7 +382,7 @@ class AndroidAotDeferredComponentsBundle extends Target { /// The name of the produced Android ABI. String get _androidAbiName { - return getAndroidArchForName(dependency.targetPlatform.getName()).archName; + return getCpuArchForName(dependency.targetPlatform.getName()).androidArchName; } @override diff --git a/packages/flutter_tools/lib/src/build_system/targets/common.dart b/packages/flutter_tools/lib/src/build_system/targets/common.dart index 692bc456838cb..b66db673bcc2e 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/common.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/common.dart @@ -500,7 +500,7 @@ abstract final class Lipo { /// Otherwise, `lipo` would fail if the given paths didn't exist. static Future create( Environment environment, - List darwinArchs, { + List cpuArchs, { required String relativePath, required String inputDir, bool skipMissingInputs = false, @@ -511,9 +511,9 @@ abstract final class Lipo { ); environment.fileSystem.directory(resultPath).parent.createSync(recursive: true); - Iterable inputPaths = darwinArchs.map( - (DarwinArch iosArch) => - environment.fileSystem.path.join(inputDir, iosArch.name, relativePath), + Iterable inputPaths = cpuArchs.map( + (CpuArch cpuArch) => + environment.fileSystem.path.join(inputDir, cpuArch.darwinArchName, relativePath), ); if (skipMissingInputs) { inputPaths = inputPaths.where(environment.fileSystem.isFileSync); diff --git a/packages/flutter_tools/lib/src/build_system/targets/deferred_components.dart b/packages/flutter_tools/lib/src/build_system/targets/deferred_components.dart index 0bd5daac4e10c..8dfdc16884247 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/deferred_components.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/deferred_components.dart @@ -39,7 +39,7 @@ class DeferredComponentsGenSnapshotValidatorTarget extends Target { return [ for (final AndroidAotDeferredComponentsBundle target in deferredComponentsDependencies) if (deferredComponentsTargets.contains(target.name)) - getAndroidArchForName(target.dependency.targetPlatform.getName()).archName, + getCpuArchForName(target.dependency.targetPlatform.getName()).androidArchName, ]; } diff --git a/packages/flutter_tools/lib/src/build_system/targets/ios.dart b/packages/flutter_tools/lib/src/build_system/targets/ios.dart index 82a470f2b721e..9535489b8f46d 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/ios.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/ios.dart @@ -72,9 +72,9 @@ abstract class AotAssemblyBase extends Target { final targetPlatform = TargetPlatform.fromName(environmentTargetPlatform); final String? splitDebugInfo = environment.defines[kSplitDebugInfo]; final dartObfuscation = environment.defines[kDartObfuscation] == 'true'; - final List darwinArchs = - environment.defines[kIosArchs]?.split(' ').map(getIOSArchForName).toList() ?? - [DarwinArch.arm64]; + final List cpuArchs = + environment.defines[kIosArchs]?.split(' ').map(getCpuArchForName).toList() ?? + [CpuArch.arm64]; if (targetPlatform != TargetPlatform.ios) { throw Exception('aot_assembly is only supported for iOS applications.'); } @@ -94,15 +94,15 @@ abstract class AotAssemblyBase extends Target { // If we're building multiple iOS archs the binaries need to be lipo'd // together. final pending = >[]; - for (final darwinArch in darwinArchs) { + for (final cpuArch in cpuArchs) { final archExtraGenSnapshotOptions = List.of(extraGenSnapshotOptions); if (codeSizeDirectory != null) { final File codeSizeFile = environment.fileSystem .directory(codeSizeDirectory) - .childFile('snapshot.${darwinArch.name}.json'); + .childFile('snapshot.${cpuArch.darwinArchName}.json'); final File precompilerTraceFile = environment.fileSystem .directory(codeSizeDirectory) - .childFile('trace.${darwinArch.name}.json'); + .childFile('trace.${cpuArch.darwinArchName}.json'); archExtraGenSnapshotOptions.add('--write-v8-snapshot-profile-to=${codeSizeFile.path}'); archExtraGenSnapshotOptions.add('--trace-precompiler-to=${precompilerTraceFile.path}'); } @@ -111,8 +111,8 @@ abstract class AotAssemblyBase extends Target { platform: targetPlatform, buildMode: buildMode, mainPath: environment.buildDir.childFile('app.dill').path, - outputPath: environment.fileSystem.path.join(buildOutputPath, darwinArch.name), - darwinArch: darwinArch, + outputPath: environment.fileSystem.path.join(buildOutputPath, cpuArch.darwinArchName), + cpuArch: cpuArch, sdkRoot: sdkRoot, quiet: true, splitDebugInfo: splitDebugInfo, @@ -129,7 +129,7 @@ abstract class AotAssemblyBase extends Target { // Combine the app lib into a fat framework. await Lipo.create( environment, - darwinArchs, + cpuArchs, relativePath: 'App.framework/App', inputDir: buildOutputPath, ); @@ -137,7 +137,7 @@ abstract class AotAssemblyBase extends Target { // And combine the dSYM for each architecture too, if it was created. await Lipo.create( environment, - darwinArchs, + cpuArchs, relativePath: 'App.framework.dSYM/Contents/Resources/DWARF/App', inputDir: buildOutputPath, // Don't fail if the dSYM wasn't created (i.e. during a debug build). diff --git a/packages/flutter_tools/lib/src/build_system/targets/macos.dart b/packages/flutter_tools/lib/src/build_system/targets/macos.dart index c0f2923a5938e..e42533a8ddfba 100644 --- a/packages/flutter_tools/lib/src/build_system/targets/macos.dart +++ b/packages/flutter_tools/lib/src/build_system/targets/macos.dart @@ -210,10 +210,10 @@ class DebugMacOSFramework extends Target { environment.fileSystem.path.join(environment.buildDir.path, 'App.framework', 'App'), ); - final Iterable darwinArchs = getDarwinArchsFromEnv(environment.defines); + final Iterable cpuArchs = getCpuArchsFromEnv(environment.defines); - final Iterable darwinArchArguments = darwinArchs.expand( - (DarwinArch arch) => ['-arch', arch.name], + final Iterable darwinArchArguments = cpuArchs.expand( + (CpuArch arch) => ['-arch', arch.darwinArchName], ); outputFile.createSync(recursive: true); @@ -287,7 +287,7 @@ class CompileMacOSFramework extends Target { kExtraGenSnapshotOptions, ); final targetPlatform = TargetPlatform.fromName(targetPlatformEnvironment); - final List darwinArchs = getDarwinArchsFromEnv(environment.defines); + final List cpuArchs = getCpuArchsFromEnv(environment.defines); if (targetPlatform != TargetPlatform.darwin) { throw Exception('compile_macos_framework is only supported for darwin TargetPlatform.'); } @@ -301,14 +301,14 @@ class CompileMacOSFramework extends Target { ); final pending = >[]; - for (final darwinArch in darwinArchs) { + for (final cpuArch in cpuArchs) { if (codeSizeDirectory != null) { final File codeSizeFile = environment.fileSystem .directory(codeSizeDirectory) - .childFile('snapshot.${darwinArch.name}.json'); + .childFile('snapshot.${cpuArch.darwinArchName}.json'); final File precompilerTraceFile = environment.fileSystem .directory(codeSizeDirectory) - .childFile('trace.${darwinArch.name}.json'); + .childFile('trace.${cpuArch.darwinArchName}.json'); extraGenSnapshotOptions.add('--write-v8-snapshot-profile-to=${codeSizeFile.path}'); extraGenSnapshotOptions.add('--trace-precompiler-to=${precompilerTraceFile.path}'); } @@ -320,9 +320,9 @@ class CompileMacOSFramework extends Target { snapshotter.build( buildMode: buildMode, mainPath: environment.buildDir.childFile('app.dill').path, - outputPath: environment.fileSystem.path.join(buildOutputPath, darwinArch.name), + outputPath: environment.fileSystem.path.join(buildOutputPath, cpuArch.darwinArchName), platform: TargetPlatform.darwin, - darwinArch: darwinArch, + cpuArch: cpuArch, splitDebugInfo: splitDebugInfo, dartObfuscation: dartObfuscation, extraGenSnapshotOptions: extraGenSnapshotOptions, @@ -339,7 +339,7 @@ class CompileMacOSFramework extends Target { // Combine the app lib into a fat framework. await Lipo.create( environment, - darwinArchs, + cpuArchs, relativePath: 'App.framework/App', inputDir: buildOutputPath, ); @@ -347,7 +347,7 @@ class CompileMacOSFramework extends Target { // And combine the dSYM for each architecture too, if it was created. await Lipo.create( environment, - darwinArchs, + cpuArchs, relativePath: 'App.framework.dSYM/Contents/Resources/DWARF/App', inputDir: buildOutputPath, // Don't fail if the dSYM wasn't created (i.e. during a debug build). 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 7fdb980d904ed..57775b9065d9d 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 { diff --git a/packages/flutter_tools/lib/src/commands/build_aar.dart b/packages/flutter_tools/lib/src/commands/build_aar.dart index 67561f9ce1049..ef719e6e7271b 100644 --- a/packages/flutter_tools/lib/src/commands/build_aar.dart +++ b/packages/flutter_tools/lib/src/commands/build_aar.dart @@ -118,9 +118,9 @@ class BuildAarCommand extends BuildSubCommand { } final androidBuildInfo = {}; - final Iterable targetArchitectures = stringsArg( + final Iterable targetArchitectures = stringsArg( 'target-platform', - ).map(getAndroidArchForName); + ).map(getCpuArchForName); final String? buildNumberArg = stringArg('build-number'); final String buildNumber = diff --git a/packages/flutter_tools/lib/src/commands/build_apk.dart b/packages/flutter_tools/lib/src/commands/build_apk.dart index 184a68be58d90..824a1008a01a6 100644 --- a/packages/flutter_tools/lib/src/commands/build_apk.dart +++ b/packages/flutter_tools/lib/src/commands/build_apk.dart @@ -124,7 +124,7 @@ class BuildApkCommand extends BuildSubCommand { final androidBuildInfo = AndroidBuildInfo( buildInfo, splitPerAbi: boolArg('split-per-abi'), - targetArchs: _targetArchs.map(getAndroidArchForName), + targetArchs: _targetArchs.map(getCpuArchForName), ); validateBuild(androidBuildInfo); globals.terminal.usesTerminalUi = true; diff --git a/packages/flutter_tools/lib/src/commands/build_appbundle.dart b/packages/flutter_tools/lib/src/commands/build_appbundle.dart index af58cbe68fa11..f96180990545f 100644 --- a/packages/flutter_tools/lib/src/commands/build_appbundle.dart +++ b/packages/flutter_tools/lib/src/commands/build_appbundle.dart @@ -120,7 +120,7 @@ class BuildAppBundleCommand extends BuildSubCommand { } final androidBuildInfo = AndroidBuildInfo( await getBuildInfo(), - targetArchs: stringsArg('target-platform').map(getAndroidArchForName), + targetArchs: stringsArg('target-platform').map(getCpuArchForName), ); // Do all setup verification that doesn't involve loading units. Checks that // require generated loading units are done after gen_snapshot in assemble. diff --git a/packages/flutter_tools/lib/src/commands/build_ios.dart b/packages/flutter_tools/lib/src/commands/build_ios.dart index e783f793b9e7a..5131df96364ac 100644 --- a/packages/flutter_tools/lib/src/commands/build_ios.dart +++ b/packages/flutter_tools/lib/src/commands/build_ios.dart @@ -1022,7 +1022,7 @@ abstract class _BuildIOSSubCommand extends BuildSubCommand { appFilenamePattern: 'App', ); // Only support 64bit iOS code size analysis. - final String arch = DarwinArch.arm64.name; + final String arch = CpuArch.arm64.darwinArchName; final File aotSnapshot = globals.fs .directory(buildInfo.codeSizeDirectory) .childFile('snapshot.$arch.json'); diff --git a/packages/flutter_tools/lib/src/commands/build_ios_framework.dart b/packages/flutter_tools/lib/src/commands/build_ios_framework.dart index 84e6b7a28d870..432e9519afc37 100644 --- a/packages/flutter_tools/lib/src/commands/build_ios_framework.dart +++ b/packages/flutter_tools/lib/src/commands/build_ios_framework.dart @@ -802,7 +802,7 @@ end kIosArchs: defaultIOSArchsForEnvironment( sdkType, globals.artifacts!, - ).map((DarwinArch e) => e.name).join(' '), + ).map((CpuArch e) => e.darwinArchName).join(' '), kSdkRoot: await globals.xcode!.sdkLocation(sdkType), ...buildInfo.toBuildSystemEnvironment(), }, diff --git a/packages/flutter_tools/lib/src/commands/build_macos_framework.dart b/packages/flutter_tools/lib/src/commands/build_macos_framework.dart index e4167b526188f..52cf414c511df 100644 --- a/packages/flutter_tools/lib/src/commands/build_macos_framework.dart +++ b/packages/flutter_tools/lib/src/commands/build_macos_framework.dart @@ -261,7 +261,7 @@ end kTargetPlatform: TargetPlatform.darwin.getName(), kDarwinArchs: defaultMacOSArchsForEnvironment( globals.artifacts!, - ).map((DarwinArch e) => e.name).join(' '), + ).map((CpuArch e) => e.darwinArchName).join(' '), ...buildInfo.toBuildSystemEnvironment(), }, artifacts: globals.artifacts!, diff --git a/packages/flutter_tools/lib/src/commands/build_swift_package.dart b/packages/flutter_tools/lib/src/commands/build_swift_package.dart index 85cdc520a7fbf..ba41167f26291 100644 --- a/packages/flutter_tools/lib/src/commands/build_swift_package.dart +++ b/packages/flutter_tools/lib/src/commands/build_swift_package.dart @@ -1413,14 +1413,14 @@ class AppFrameworkAndNativeAssetsDependencies { kIosArchs: defaultIOSArchsForEnvironment( sdk.sdkType, _utils.artifacts, - ).map((DarwinArch e) => e.name).join(' '), + ).map((CpuArch e) => e.darwinArchName).join(' '), kSdkRoot: await _utils.xcode.sdkLocation(sdk.sdkType), }; case FlutterDarwinPlatform.macos: return { kDarwinArchs: defaultMacOSArchsForEnvironment( _utils.artifacts, - ).map((DarwinArch e) => e.name).join(' '), + ).map((CpuArch e) => e.darwinArchName).join(' '), }; } } diff --git a/packages/flutter_tools/lib/src/compile.dart b/packages/flutter_tools/lib/src/compile.dart index d11863c167c5c..51d2c2797c7d4 100644 --- a/packages/flutter_tools/lib/src/compile.dart +++ b/packages/flutter_tools/lib/src/compile.dart @@ -359,7 +359,7 @@ class KernelCompiler { '--no-print-incremental-dependencies', for (final Object dartDefine in dartDefines) '-D$dartDefine', ...buildModeOptions(buildMode, dartDefines), - if (trackWidgetCreation) '--track-widget-creation', + if (trackWidgetCreation) '--track-creation-locations', if (!linkPlatformKernelIn) '--no-link-platform', if (aot) ...[ '--aot', @@ -974,7 +974,7 @@ class DefaultResidentCompiler implements ResidentCompiler { ], if (packagesPath != null) ...['--packages', packagesPath!], ...buildModeOptions(buildMode, dartDefines), - if (trackWidgetCreation) '--track-widget-creation', + if (trackWidgetCreation) '--track-creation-locations', if (includeUnsupportedPlatformLibraryStubs) '--include-unsupported-platform-library-stubs', for (final String root in fileSystemRoots) ...['--filesystem-root', root], if (fileSystemScheme != null) ...['--filesystem-scheme', fileSystemScheme!], diff --git a/packages/flutter_tools/lib/src/drive/drive_service.dart b/packages/flutter_tools/lib/src/drive/drive_service.dart index b1d546123a08c..8f519d549d74f 100644 --- a/packages/flutter_tools/lib/src/drive/drive_service.dart +++ b/packages/flutter_tools/lib/src/drive/drive_service.dart @@ -124,13 +124,15 @@ class FlutterDriverService extends DriverService { required String dartSdkPath, required DevtoolsLauncher devtoolsLauncher, @visibleForTesting VMServiceConnector vmServiceConnector = connectToVmService, + @visibleForTesting Duration logFlushDelay = const Duration(milliseconds: 500), }) : _applicationPackageFactory = applicationPackageFactory, _logger = logger, _platform = platform, _processUtils = processUtils, _dartSdkPath = dartSdkPath, _vmServiceConnector = vmServiceConnector, - _devtoolsLauncher = devtoolsLauncher; + _devtoolsLauncher = devtoolsLauncher, + _logFlushDelay = logFlushDelay; static const _kLaunchAttempts = 3; @@ -141,6 +143,7 @@ class FlutterDriverService extends DriverService { final String _dartSdkPath; final VMServiceConnector _vmServiceConnector; final DevtoolsLauncher _devtoolsLauncher; + final Duration _logFlushDelay; Device? _device; ApplicationPackage? _applicationPackage; @@ -216,26 +219,34 @@ class FlutterDriverService extends DriverService { } _vmServiceUri = uri.toString(); _device = device; - if (debuggingOptions.enableDds) { - try { - await device.dds.startDartDevelopmentServiceFromDebuggingOptions( - uri, - appName: - 'Kind: Flutter - Device: ${device.displayName} - ' - 'Package: ${_applicationPackage?.name}', - debuggingOptions: debuggingOptions, - ); - _vmServiceUri = device.dds.uri.toString(); - } on DartDevelopmentServiceException { - // If there's another flutter_tools instance still connected to the target - // application, DDS will already be running remotely and this call will fail. - // This can be ignored to continue to use the existing remote DDS instance. - } - } - _vmService = await _vmServiceConnector(uri, device: _device, logger: _logger); + final DeviceLogReader logReader = await device.getLogReader(app: _applicationPackage); logReader.logLines.listen(_logger.printStatus); - await logReader.provideVmService(_vmService); + + try { + if (debuggingOptions.enableDds) { + try { + await device.dds.startDartDevelopmentServiceFromDebuggingOptions( + uri, + appName: + 'Kind: Flutter - Device: ${device.displayName} - ' + 'Package: ${_applicationPackage?.name}', + debuggingOptions: debuggingOptions, + ); + _vmServiceUri = device.dds.uri.toString(); + } on DartDevelopmentServiceException { + // If there's another flutter_tools instance still connected to the target + // application, DDS will already be running remotely and this call will fail. + // This can be ignored to continue to use the existing remote DDS instance. + } + } + _vmService = await _vmServiceConnector(uri, device: _device, logger: _logger); + await logReader.provideVmService(_vmService); + } catch (error) { + // Allow time for buffered/async log messages (e.g. engine crash logs) to arrive and flush. + await Future.delayed(_logFlushDelay); + rethrow; + } } @override diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart index c131edc8a8bdd..48f9fa1ebdb70 100644 --- a/packages/flutter_tools/lib/src/ios/devices.dart +++ b/packages/flutter_tools/lib/src/ios/devices.dart @@ -309,7 +309,7 @@ class IOSDevice extends Device { super.id, { required FileSystem fileSystem, required this.name, - required this.cpuArchitecture, + required CpuArch cpuArch, required this.connectionInterface, required this.isConnected, required this.isPaired, @@ -325,7 +325,8 @@ class IOSDevice extends Device { required IProxy iProxy, required super.logger, required Analytics analytics, - }) : _sdkVersion = sdkVersion, + }) : _cpuArch = cpuArch, + _sdkVersion = sdkVersion, _iosDeploy = iosDeploy, _iMobileDevice = iMobileDevice, _coreDeviceControl = coreDeviceControl, @@ -370,14 +371,10 @@ class IOSDevice extends Device { @override bool supportsRuntimeMode(BuildMode buildMode) => buildMode != BuildMode.jitRelease; - final DarwinArch cpuArchitecture; + final CpuArch _cpuArch; @override - Future get cpuArch async => switch (cpuArchitecture) { - .armv7 => CpuArch.armv7, - .arm64 => CpuArch.arm64, - .x86_64 => CpuArch.x64, - }; + Future get cpuArch async => _cpuArch; @override /// The [connectionInterface] provided from `XCDevice.getAvailableIOSDevices` @@ -502,7 +499,7 @@ class IOSDevice extends Device { @override // 32-bit devices are not supported. - Future isSupported() async => cpuArchitecture == DarwinArch.arm64; + Future isSupported() async => _cpuArch == .arm64; @override Future startApp( @@ -535,7 +532,7 @@ class IOSDevice extends Device { app: package as BuildableIOSApp, buildInfo: debuggingOptions.buildInfo, targetOverride: mainPath, - activeArch: cpuArchitecture, + activeArch: _cpuArch, deviceID: id, disablePortPublication: debuggingOptions.usingCISystem && debuggingOptions.disablePortPublication, diff --git a/packages/flutter_tools/lib/src/ios/mac.dart b/packages/flutter_tools/lib/src/ios/mac.dart index 0a6c0cd66fd05..4a4fa844df7f6 100644 --- a/packages/flutter_tools/lib/src/ios/mac.dart +++ b/packages/flutter_tools/lib/src/ios/mac.dart @@ -138,7 +138,7 @@ Future buildXcodeProject({ required BuildInfo buildInfo, String? targetOverride, EnvironmentType environmentType = EnvironmentType.physical, - DarwinArch? activeArch, + CpuArch? activeArch, bool codesign = true, String? deviceID, bool configOnly = false, @@ -411,16 +411,17 @@ Future buildXcodeProject({ final Directory? workspacePath = app.project.xcodeWorkspace; if (workspacePath != null) { - buildCommands.addAll([ - '-workspace', - workspacePath.basename, - '-scheme', - scheme, - if (buildAction != - XcodeBuildAction.archive) // dSYM files aren't copied to the archive if BUILD_DIR is set. - 'BUILD_DIR=${globals.fs.path.absolute(buildDirectoryPath)}', - ]); + buildCommands.addAll(['-workspace', workspacePath.basename]); + } else { + buildCommands.addAll(['-project', app.project.xcodeProject.basename]); } + buildCommands.addAll([ + '-scheme', + scheme, + if (buildAction != + XcodeBuildAction.archive) // dSYM files aren't copied to the archive if BUILD_DIR is set. + 'BUILD_DIR=${globals.fs.path.absolute(buildDirectoryPath)}', + ]); // Check if the project contains a watchOS companion app. final bool hasWatchCompanion = await app.project.containsWatchCompanion( @@ -462,10 +463,10 @@ Future buildXcodeProject({ if (!hasWatchCompanion) { // ONLY_ACTIVE_ARCH specifies whether the product includes only code for // the native architecture. - final onlyActiveArch = activeArch == getCurrentDarwinArch(); + final onlyActiveArch = activeArch == CpuArch.fromHostPlatform(getCurrentHostPlatform()); buildCommands.add('ONLY_ACTIVE_ARCH=${onlyActiveArch ? 'YES' : 'NO'}'); - buildCommands.add('ARCHS=${activeArch.name}'); + buildCommands.add('ARCHS=${activeArch.darwinArchName}'); } } diff --git a/packages/flutter_tools/lib/src/isolated/native_assets/android/native_assets.dart b/packages/flutter_tools/lib/src/isolated/native_assets/android/native_assets.dart index ae6ffae111847..4d63584ad24a5 100644 --- a/packages/flutter_tools/lib/src/isolated/native_assets/android/native_assets.dart +++ b/packages/flutter_tools/lib/src/isolated/native_assets/android/native_assets.dart @@ -24,7 +24,8 @@ Future> copyNativeCodeAssetsAndroid( assert(assetTargetLocations.isNotEmpty); final installedFiles = []; final jniArchDirs = [ - for (final AndroidArch androidArch in AndroidArch.values) androidArch.archName, + for (final CpuArch cpuArch in [CpuArch.armv7, CpuArch.arm64, CpuArch.x64]) + cpuArch.androidArchName, ]; for (final jniArchDir in jniArchDirs) { final Uri archUri = targetUri.resolve('jniLibs/lib/$jniArchDir/'); @@ -33,8 +34,8 @@ Future> copyNativeCodeAssetsAndroid( for (final MapEntry assetMapping in assetTargetLocations.entries) { final Uri source = assetMapping.key.codeAsset.file!; final Uri target = (assetMapping.value.path as KernelAssetAbsolutePath).uri; - final AndroidArch androidArch = _getAndroidArch(assetMapping.value.target.architecture); - final String jniArchDir = androidArch.archName; + final CpuArch cpuArch = _getAndroidArch(assetMapping.value.target.architecture); + final String jniArchDir = cpuArch.androidArchName; final Uri archUri = targetUri.resolve('jniLibs/lib/$jniArchDir/'); final Uri assetTargetUri = archUri.resolveUri(target); final String targetFullPath = assetTargetUri.toFilePath(); @@ -44,21 +45,24 @@ Future> copyNativeCodeAssetsAndroid( return installedFiles; } -/// Get the [Architecture] for [androidArch]. -Architecture getNativeAndroidArchitecture(AndroidArch androidArch) { - return switch (androidArch) { - AndroidArch.armeabi_v7a => Architecture.arm, - AndroidArch.arm64_v8a => Architecture.arm64, - AndroidArch.x86_64 => Architecture.x64, +/// Get the [Architecture] for [cpuArch]. +Architecture getNativeAndroidArchitecture(CpuArch cpuArch) { + return switch (cpuArch) { + CpuArch.armv7 => Architecture.arm, + CpuArch.arm64 => Architecture.arm64, + CpuArch.x64 => Architecture.x64, + CpuArch.x86 || + CpuArch.riscv64 || + CpuArch.unknown => throwToolExit('Invalid Android arch: $cpuArch.'), }; } -/// Get the [AndroidArch] for [architecture]. -AndroidArch _getAndroidArch(Architecture architecture) { +/// Get the [CpuArch] for [architecture]. +CpuArch _getAndroidArch(Architecture architecture) { return switch (architecture) { - Architecture.arm => AndroidArch.armeabi_v7a, - Architecture.arm64 => AndroidArch.arm64_v8a, - Architecture.x64 => AndroidArch.x86_64, + Architecture.arm => CpuArch.armv7, + Architecture.arm64 => CpuArch.arm64, + Architecture.x64 => CpuArch.x64, Architecture.riscv64 => throwToolExit('Android RISC-V not yet supported.'), _ => throwToolExit('Invalid architecture: $architecture.'), }; 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..68680a9209599 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 @@ -20,12 +20,15 @@ IOSSdk getIOSSdk(EnvironmentType environmentType) { }; } -/// Extract the [Architecture] from a [DarwinArch]. -Architecture getNativeIOSArchitecture(DarwinArch darwinArch) { - return switch (darwinArch) { - DarwinArch.armv7 => Architecture.arm, - DarwinArch.arm64 => Architecture.arm64, - DarwinArch.x86_64 => Architecture.x64, +/// Extract the [Architecture] from a [CpuArch]. +Architecture getNativeIOSArchitecture(CpuArch cpuArch) { + return switch (cpuArch) { + CpuArch.armv7 => Architecture.arm, + CpuArch.arm64 => Architecture.arm64, + CpuArch.x64 => Architecture.x64, + CpuArch.x86 || + CpuArch.riscv64 || + CpuArch.unknown => throw Exception('Unknown iOS CPU arch: $cpuArch.'), }; } diff --git a/packages/flutter_tools/lib/src/isolated/native_assets/macos/native_assets.dart b/packages/flutter_tools/lib/src/isolated/native_assets/macos/native_assets.dart index 2824681aea1f9..4a1d0640a2280 100644 --- a/packages/flutter_tools/lib/src/isolated/native_assets/macos/native_assets.dart +++ b/packages/flutter_tools/lib/src/isolated/native_assets/macos/native_assets.dart @@ -13,12 +13,15 @@ import 'native_assets_host.dart'; // TODO(dcharkes): Fetch minimum MacOS version from somewhere. https://github.com/flutter/flutter/issues/145104 const targetMacOSVersion = 13; -/// Extract the [Architecture] from a [DarwinArch]. -Architecture getNativeMacOSArchitecture(DarwinArch darwinArch) { - return switch (darwinArch) { - DarwinArch.arm64 => Architecture.arm64, - DarwinArch.x86_64 => Architecture.x64, - DarwinArch.armv7 => throw Exception('Unknown DarwinArch: $darwinArch.'), +/// Extract the [Architecture] from a [CpuArch]. +Architecture getNativeMacOSArchitecture(CpuArch cpuArch) { + return switch (cpuArch) { + CpuArch.arm64 => Architecture.arm64, + CpuArch.x64 => Architecture.x64, + CpuArch.armv7 || + CpuArch.x86 || + CpuArch.riscv64 || + CpuArch.unknown => throw Exception('Unknown macOS CPU arch: $cpuArch.'), }; } diff --git a/packages/flutter_tools/lib/src/isolated/native_assets/targets.dart b/packages/flutter_tools/lib/src/isolated/native_assets/targets.dart index 5c8b595fb79c5..afe0ed360744e 100644 --- a/packages/flutter_tools/lib/src/isolated/native_assets/targets.dart +++ b/packages/flutter_tools/lib/src/isolated/native_assets/targets.dart @@ -10,14 +10,12 @@ import 'package:hooks/hooks.dart'; import '../../base/common.dart' show throwToolExit; import '../../build_info.dart' show - AndroidArch, BuildMode, - DarwinArch, + CpuArch, EnvironmentType, TargetPlatform, - getAndroidArchForName, - getDarwinArchsFromEnv, - getIOSArchForName, + getCpuArchForName, + getCpuArchsFromEnv, kAndroidArchs, kIosArchs, kSdkRoot; @@ -136,7 +134,7 @@ sealed class AssetBuildTarget { Map environmentDefines, List supportedAssetTypes, ) { - return getDarwinArchsFromEnv(environmentDefines) + return getCpuArchsFromEnv(environmentDefines) .map(getNativeMacOSArchitecture) .map( (Architecture architecture) => MacOSAssetTarget( @@ -169,10 +167,10 @@ sealed class AssetBuildTarget { FileSystem fileSystem, List supportedAssetTypes, ) { - final List iosArchitectures = - _emptyToNull(environmentDefines[kIosArchs])?.split(' ').map(getIOSArchForName).toList() ?? - [DarwinArch.arm64]; - return iosArchitectures + final List cpuArchs = + _emptyToNull(environmentDefines[kIosArchs])?.split(' ').map(getCpuArchForName).toList() ?? + [CpuArch.arm64]; + return cpuArchs .map(getNativeIOSArchitecture) .map( (Architecture architecture) => IOSAssetTarget( @@ -420,19 +418,19 @@ final class FlutterTesterAssetTarget extends CodeAssetTarget { subtarget.setCCompilerConfig(mustMatchAppBuild: false); } -List _androidArchs(TargetPlatform targetPlatform, String? androidArchsEnvironment) { +List _androidArchs(TargetPlatform targetPlatform, String? androidArchsEnvironment) { switch (targetPlatform) { case TargetPlatform.android_arm: - return [AndroidArch.armeabi_v7a]; + return [CpuArch.armv7]; case TargetPlatform.android_arm64: - return [AndroidArch.arm64_v8a]; + return [CpuArch.arm64]; case TargetPlatform.android_x64: - return [AndroidArch.x86_64]; + return [CpuArch.x64]; case TargetPlatform.android: if (androidArchsEnvironment == null) { throw MissingDefineException(kAndroidArchs, 'native_assets'); } - return androidArchsEnvironment.split(' ').map(getAndroidArchForName).toList(); + return androidArchsEnvironment.split(' ').map(getCpuArchForName).toList(); case TargetPlatform.darwin: case TargetPlatform.fuchsia_arm64: case TargetPlatform.fuchsia_x64: diff --git a/packages/flutter_tools/lib/src/macos/build_macos.dart b/packages/flutter_tools/lib/src/macos/build_macos.dart index 832121498b7e6..23d183d62406b 100644 --- a/packages/flutter_tools/lib/src/macos/build_macos.dart +++ b/packages/flutter_tools/lib/src/macos/build_macos.dart @@ -18,6 +18,7 @@ import '../darwin/darwin.dart'; import '../features.dart'; import '../globals.dart' as globals; import '../ios/migrations/metal_api_validation_migration.dart'; +import '../ios/plist_parser.dart'; import '../ios/xcode_build_settings.dart'; import '../ios/xcodeproj.dart'; import '../migrations/swift_package_manager_gitignore_migration.dart'; @@ -86,14 +87,19 @@ Future buildMacOS({ SizeAnalyzer? sizeAnalyzer, bool usingCISystem = false, }) async { - final Directory? xcodeWorkspace = flutterProject.macos.xcodeWorkspace; - if (xcodeWorkspace == null) { + final Directory xcodeProject = flutterProject.macos.xcodeProject; + if (!xcodeProject.existsSync()) { throwToolExit( 'No macOS desktop project configured. ' 'See https://flutter.dev/to/add-desktop-support ' 'to learn about adding macOS support to a project.', ); } + + // The .xcworkspace may not exist (e.g. a project using Swift Package Manager + // without CocoaPods). When absent, xcodebuild builds the .xcodeproj directly. + final Directory? xcodeWorkspace = flutterProject.macos.xcodeWorkspace; + const FlutterDarwinPlatform darwinPlatform = .macos; final migrators = [ RemoveMacOSFrameworkLinkAndEmbeddingMigration( @@ -140,8 +146,6 @@ Future buildMacOS({ flutterBuildDir.createSync(recursive: true); } - final Directory xcodeProject = flutterProject.macos.xcodeProject; - // If the standard project exists, specify it to getInfo to handle the case where there are // other Xcode projects in the macos/ directory. Otherwise pass no name, which will work // regardless of the project name so long as there is exactly one project. @@ -280,8 +284,10 @@ Future buildMacOS({ [ '/usr/bin/env', ...xcodebuildCommandArgs, - '-workspace', - xcodeWorkspace.path, + if (xcodeWorkspace != null) ...['-workspace', xcodeWorkspace.path] else ...[ + '-project', + xcodeProject.path, + ], '-configuration', configuration, '-scheme', @@ -348,6 +354,22 @@ Future buildMacOS({ 'Built ${globals.fs.path.relative(outputDirectory.path)}$appSize', color: TerminalColor.green, ); + + final File builtInfoPlist = globals.fs.file( + globals.fs.path.join(outputDirectory.path, 'Contents', 'Info.plist'), + ); + final String plistPath = builtInfoPlist.existsSync() + ? builtInfoPlist.path + : flutterProject.macos.defaultHostInfoPlist.path; + final bool? impellerEnabled = globals.plistParser.getValueFromFile( + plistPath, + PlistParser.kFLTEnableImpellerKey, + ); + + final buildLabel = impellerEnabled == false + ? 'plist-impeller-disabled' + : 'plist-impeller-enabled'; + globals.analytics.send(Event.flutterBuildInfo(label: buildLabel, buildType: 'macos')); } await _writeCodeSizeAnalysis(buildInfo, sizeAnalyzer); final Duration elapsedDuration = sw.elapsed; @@ -370,11 +392,11 @@ Future _writeCodeSizeAnalysis(BuildInfo buildInfo, SizeAnalyzer? sizeAnaly if (buildInfo.codeSizeDirectory == null || sizeAnalyzer == null) { return; } - final File? aotSnapshot = DarwinArch.values - .map((DarwinArch arch) { + final File? aotSnapshot = const [CpuArch.armv7, CpuArch.arm64, CpuArch.x64] + .map((CpuArch arch) { return globals.fs .directory(buildInfo.codeSizeDirectory) - .childFile('snapshot.${arch.name}.json'); + .childFile('snapshot.${arch.darwinArchName}.json'); // Pick the first if there are multiple for simplicity }) .firstWhere((File? file) => file!.existsSync(), orElse: () => null); @@ -383,11 +405,11 @@ Future _writeCodeSizeAnalysis(BuildInfo buildInfo, SizeAnalyzer? sizeAnaly 'No code size snapshot file (snapshot..json) found in ${buildInfo.codeSizeDirectory}', ); } - final File? precompilerTrace = DarwinArch.values - .map((DarwinArch arch) { + final File? precompilerTrace = const [CpuArch.armv7, CpuArch.arm64, CpuArch.x64] + .map((CpuArch arch) { return globals.fs .directory(buildInfo.codeSizeDirectory) - .childFile('trace.${arch.name}.json'); + .childFile('trace.${arch.darwinArchName}.json'); }) .firstWhere((File? file) => file!.existsSync(), orElse: () => null); if (precompilerTrace == null) { diff --git a/packages/flutter_tools/lib/src/macos/xcdevice.dart b/packages/flutter_tools/lib/src/macos/xcdevice.dart index b5cbeeaa9b448..403e292a87768 100644 --- a/packages/flutter_tools/lib/src/macos/xcdevice.dart +++ b/packages/flutter_tools/lib/src/macos/xcdevice.dart @@ -632,7 +632,7 @@ class XCDevice { deviceMap[identifier] = IOSDevice( identifier, name: name, - cpuArchitecture: _cpuArchitecture(device), + cpuArch: _cpuArchitecture(device), connectionInterface: connectionInterface, isConnected: isConnected, sdkVersion: sdkVersionString, @@ -724,21 +724,21 @@ class XCDevice { return null; } - DarwinArch _cpuArchitecture(Map deviceProperties) { - DarwinArch? cpuArchitecture; + CpuArch _cpuArchitecture(Map deviceProperties) { + CpuArch? cpuArchitecture; final Object? architecture = deviceProperties['architecture']; if (architecture is String) { try { - cpuArchitecture = getIOSArchForName(architecture); + cpuArchitecture = getCpuArchForName(architecture); } on Exception { // Fallback to default iOS architecture. Future-proof against a // theoretical version of Xcode that changes this string to something // slightly different like "ARM64", or armv7 variations like // armv7s and armv7f. if (architecture.startsWith('armv7')) { - cpuArchitecture = DarwinArch.armv7; + cpuArchitecture = CpuArch.armv7; } else { - cpuArchitecture = DarwinArch.arm64; + cpuArchitecture = CpuArch.arm64; } _logger.printWarning( 'Unknown architecture $architecture, defaulting to ' @@ -746,7 +746,7 @@ class XCDevice { ); } } - return cpuArchitecture ?? DarwinArch.arm64; + return cpuArchitecture ?? CpuArch.arm64; } /// Error message parsed from xcdevice. null if no error. diff --git a/packages/flutter_tools/lib/src/migrations/lldb_init_migration.dart b/packages/flutter_tools/lib/src/migrations/lldb_init_migration.dart index 3751808b96ac7..28e0a08c01eaa 100644 --- a/packages/flutter_tools/lib/src/migrations/lldb_init_migration.dart +++ b/packages/flutter_tools/lib/src/migrations/lldb_init_migration.dart @@ -73,10 +73,6 @@ class LLDBInitMigration extends ProjectMigrator { logger.printTrace('Unable to get Xcode project info.'); throw _exceptionMessage(); } - if (_xcodeProject.xcodeWorkspace == null) { - logger.printTrace('Xcode workspace not found.'); - throw _exceptionMessage(); - } final String? scheme = projectInfo.schemeFor(_buildInfo); if (scheme == null) { projectInfo.reportFlavorNotFoundAndExit(); 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..1e5c8286982c8 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 @@ -273,9 +273,6 @@ class SwiftPackageManagerIntegrationMigration extends ProjectMigrator { if (projectInfo == null) { throw Exception('Unable to get Xcode project info.'); } - if (_xcodeProject.xcodeWorkspace == null) { - throw Exception('Xcode workspace not found.'); - } final String? scheme = projectInfo.schemeFor(_buildInfo); if (scheme == null) { projectInfo.reportFlavorNotFoundAndExit(); diff --git a/packages/flutter_tools/lib/src/resident_runner.dart b/packages/flutter_tools/lib/src/resident_runner.dart index fb85b532ef588..12d366f9f066c 100644 --- a/packages/flutter_tools/lib/src/resident_runner.dart +++ b/packages/flutter_tools/lib/src/resident_runner.dart @@ -48,8 +48,11 @@ class FlutterDevice { required this.generator, required this.developmentShaderCompiler, this.userIdentifier, + @visibleForTesting this.logFlushDelay = const Duration(milliseconds: 500), }); + final Duration logFlushDelay; + /// Create a [FlutterDevice] with optional code generation enabled. static Future create( Device device, { @@ -1287,14 +1290,23 @@ abstract class ResidentRunner extends ResidentHandlers { _finished = Completer(); // Listen for service protocol connection to close. for (final FlutterDevice? device in flutterDevices) { - await device!.connect( - debuggingOptions: debuggingOptions, - reloadSources: reloadSources, - restart: restart, - compileExpression: compileExpression, - hostVmServicePort: debuggingOptions.hostVmServicePort, - printStructuredErrorLogMethod: printStructuredErrorLog, - ); + if (device == null) { + continue; + } + try { + await device.connect( + debuggingOptions: debuggingOptions, + reloadSources: reloadSources, + restart: restart, + compileExpression: compileExpression, + hostVmServicePort: debuggingOptions.hostVmServicePort, + printStructuredErrorLogMethod: printStructuredErrorLog, + ); + } catch (error) { + // Allow time for buffered/async log messages (e.g. engine crash logs) to arrive and flush. + await Future.delayed(device.logFlushDelay); + rethrow; + } await device.vmService!.getFlutterViews(); // This hooks up callbacks for when the connection stops in the future. 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 0049e37ff91fd..5a31e32024443 100644 --- a/packages/flutter_tools/lib/src/test/flutter_web_platform.dart +++ b/packages/flutter_tools/lib/src/test/flutter_web_platform.dart @@ -542,6 +542,7 @@ window.\$dartLoader.loader.nextAttempt(); headers: { HttpHeaders.contentTypeHeader: contentType, HttpHeaders.cacheControlHeader: 'public, max-age=3600', + HttpHeaders.contentLengthHeader: canvasKitFile.lengthSync().toString(), }, ); } diff --git a/packages/flutter_tools/pubspec.yaml b/packages/flutter_tools/pubspec.yaml index c3e8fb41f8e93..40a10800ebc9e 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,5 @@ dartdoc: # Exclude this package from the hosted API docs. nodoc: true -# PUBSPEC CHECKSUM: 8tnhd7 + +# PUBSPEC CHECKSUM: qi12j diff --git a/packages/flutter_tools/test/commands.shard/hermetic/build_ios_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/build_ios_test.dart index 59229b6dee6d6..d474177da93d0 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/build_ios_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/build_ios_test.dart @@ -103,13 +103,15 @@ void main() { } // Sets up the minimal mock project files necessary for iOS builds to succeed. - void createMinimalMockProjectFiles() { + void createMinimalMockProjectFiles({bool createWorkspace = true}) { fileSystem .directory(fileSystem.path.join('ios', 'Runner.xcodeproj')) .createSync(recursive: true); - fileSystem - .directory(fileSystem.path.join('ios', 'Runner.xcworkspace')) - .createSync(recursive: true); + if (createWorkspace) { + fileSystem + .directory(fileSystem.path.join('ios', 'Runner.xcworkspace')) + .createSync(recursive: true); + } fileSystem .file(fileSystem.path.join('ios', 'Runner.xcodeproj', 'project.pbxproj')) .createSync(); @@ -167,6 +169,7 @@ void main() { bool verbose = false, bool simulator = false, bool customNaming = false, + bool hasWorkspace = true, bool disablePortPublication = false, String? deviceId, int exitCode = 0, @@ -182,8 +185,13 @@ void main() { if (verbose) 'VERBOSE_SCRIPT_LOGGING=YES' else '-quiet', '-allowProvisioningUpdates', '-allowProvisioningDeviceRegistration', - '-workspace', - if (customNaming) 'RenamedWorkspace.xcworkspace' else 'Runner.xcworkspace', + if (hasWorkspace) ...[ + '-workspace', + if (customNaming) 'RenamedWorkspace.xcworkspace' else 'Runner.xcworkspace', + ] else ...[ + '-project', + if (customNaming) 'RenamedProj.xcodeproj' else 'Runner.xcodeproj', + ], '-scheme', 'Runner', 'BUILD_DIR=/build/ios', @@ -641,6 +649,55 @@ void main() { }, ); + testUsingContext( + 'ios build invokes xcodebuild with -project when there is no .xcworkspace', + () 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(createWorkspace: false); + + processManager.addCommands([ + setUpFakeXcodeBuildHandler( + hasWorkspace: false, + onRun: (_) { + fileSystem + .directory('build/ios/Release-iphoneos/Runner.app') + .createSync(recursive: true); + }, + ), + ...postBuildCommands(), + ]); + + await createTestCommandRunner(command).run(const ['build', 'ios', '--no-pub']); + expect(testLogger.statusText, contains('build/ios/iphoneos/Runner.app')); + }, + overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => processManager, + Pub: ThrowingPub.new, + Platform: () => macosPlatform, + XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(), + Artifacts: () => Artifacts.test(), + }, + ); + testUsingContext( 'ios build invokes xcode build with device ID', () async { 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..6416ef10b6dcd 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 @@ -20,6 +20,7 @@ import 'package:flutter_tools/src/commands/build.dart'; import 'package:flutter_tools/src/commands/build_macos.dart'; import 'package:flutter_tools/src/dart/pub.dart'; import 'package:flutter_tools/src/features.dart'; +import 'package:flutter_tools/src/ios/plist_parser.dart'; import 'package:flutter_tools/src/ios/xcodeproj.dart'; import 'package:flutter_tools/src/project.dart'; import 'package:unified_analytics/unified_analytics.dart'; @@ -131,13 +132,15 @@ void main() { } // Sets up the minimal mock project files necessary for macOS builds to succeed. - void createMinimalMockProjectFiles() { + void createMinimalMockProjectFiles({bool createWorkspace = true}) { fileSystem .directory(fileSystem.path.join('macos', 'Runner.xcodeproj')) .createSync(recursive: true); - fileSystem - .directory(fileSystem.path.join('macos', 'Runner.xcworkspace')) - .createSync(recursive: true); + if (createWorkspace) { + fileSystem + .directory(fileSystem.path.join('macos', 'Runner.xcworkspace')) + .createSync(recursive: true); + } createCoreMockProjectFiles(); } @@ -146,6 +149,7 @@ void main() { FakeCommand setUpFakeXcodeBuildHandler( String configuration, { bool verbose = false, + bool hasWorkspace = true, void Function(List command)? onRun, List? additionalCommandArguments, String hostPlatformArch = 'x86_64', @@ -160,8 +164,10 @@ void main() { '/usr/bin/env', 'xcrun', 'xcodebuild', - '-workspace', - flutterProject.macos.xcodeWorkspace!.path, + if (hasWorkspace) ...[ + '-workspace', + flutterProject.macos.xcodeWorkspace!.path, + ] else ...['-project', flutterProject.macos.xcodeProject.path], '-configuration', configuration, '-scheme', @@ -305,6 +311,51 @@ STDERR STUFF }, ); + testUsingContext( + 'macOS build invokes xcodebuild with -project when there is no .xcworkspace', + () 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(createWorkspace: false); + + fakeProcessManager.addCommands([ + setUpFakeXcodeBuildHandler('Debug', hasWorkspace: false), + ]); + + await createTestCommandRunner( + command, + ).run(const ['build', 'macos', '--debug', '--no-pub']); + + expect(fakeProcessManager, hasNoRemainingExpectations); + }, + overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => fakeProcessManager, + Pub: ThrowingPub.new, + Platform: () => macosPlatform, + FeatureFlags: () => TestFeatureFlags(isMacOSEnabled: true), + OperatingSystemUtils: () => FakeOperatingSystemUtils(hostPlatform: HostPlatform.darwin_x64), + }, + ); + testUsingContext( 'macOS build fails on non-macOS platform', () async { @@ -1576,4 +1627,178 @@ STDERR STUFF OperatingSystemUtils: () => FakeOperatingSystemUtils(hostPlatform: HostPlatform.darwin_x64), }, ); + + group('Analytics for impeller plist setting', () { + const plistContents = ''' + + + + + FLTEnableImpeller + + + +'''; + + testUsingContext( + 'Sends an analytics event when Impeller is enabled', + () async { + final command = BuildCommand( + androidSdk: FakeAndroidSdk(), + buildSystem: TestBuildSystem.all(BuildResult(success: true)), + fileSystem: fileSystem, + logger: BufferLogger.test(), + 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(); + + await createTestCommandRunner(command).run(const ['build', 'macos', '--no-pub']); + + expect( + fakeAnalytics.sentEvents, + contains(Event.flutterBuildInfo(label: 'plist-impeller-enabled', buildType: 'macos')), + ); + }, + overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => + FakeProcessManager.list([setUpFakeXcodeBuildHandler('Release')]), + Platform: () => macosPlatform, + OperatingSystemUtils: () => FakeOperatingSystemUtils(hostPlatform: HostPlatform.darwin_x64), + XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(), + Pub: ThrowingPub.new, + FeatureFlags: () => TestFeatureFlags(isMacOSEnabled: true), + Analytics: () => fakeAnalytics, + }, + ); + + testUsingContext( + 'Sends an analytics event when Impeller is disabled', + () async { + final command = BuildCommand( + androidSdk: FakeAndroidSdk(), + buildSystem: TestBuildSystem.all(BuildResult(success: true)), + fileSystem: fileSystem, + logger: BufferLogger.test(), + 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(); + + fileSystem.file(fileSystem.path.join('usr', 'bin', 'plutil')).createSync(recursive: true); + + final File infoPlist = fileSystem.file( + fileSystem.path.join('macos', 'Runner', 'Info.plist'), + )..createSync(recursive: true); + + infoPlist.writeAsStringSync(plistContents); + + await createTestCommandRunner(command).run(const ['build', 'macos', '--no-pub']); + + expect( + fakeAnalytics.sentEvents, + contains(Event.flutterBuildInfo(label: 'plist-impeller-disabled', buildType: 'macos')), + ); + }, + overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => + FakeProcessManager.list([setUpFakeXcodeBuildHandler('Release')]), + Platform: () => macosPlatform, + OperatingSystemUtils: () => FakeOperatingSystemUtils(hostPlatform: HostPlatform.darwin_x64), + XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(), + Pub: ThrowingPub.new, + FeatureFlags: () => TestFeatureFlags(isMacOSEnabled: true), + Analytics: () => fakeAnalytics, + PlistParser: () => FakePlistParser({'FLTEnableImpeller': false}), + }, + ); + + testUsingContext( + 'Reads built app bundle Contents/Info.plist when present', + () async { + final command = BuildCommand( + androidSdk: FakeAndroidSdk(), + buildSystem: TestBuildSystem.all(BuildResult(success: true)), + fileSystem: fileSystem, + logger: BufferLogger.test(), + 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(); + + await createTestCommandRunner(command).run(const ['build', 'macos', '--no-pub']); + + expect( + fakeAnalytics.sentEvents, + contains(Event.flutterBuildInfo(label: 'plist-impeller-disabled', buildType: 'macos')), + ); + }, + overrides: { + FileSystem: () => fileSystem, + ProcessManager: () => FakeProcessManager.list([ + setUpFakeXcodeBuildHandler( + 'Release', + onRun: (_) { + fileSystem + .file( + fileSystem.path.join( + 'build', + 'macos', + 'Build', + 'Products', + 'Release', + 'Runner.app', + 'Contents', + 'Info.plist', + ), + ) + .createSync(recursive: true); + }, + ), + ]), + Platform: () => macosPlatform, + OperatingSystemUtils: () => FakeOperatingSystemUtils(hostPlatform: HostPlatform.darwin_x64), + XcodeProjectInterpreter: () => FakeXcodeProjectInterpreterWithBuildSettings(), + Pub: ThrowingPub.new, + FeatureFlags: () => TestFeatureFlags(isMacOSEnabled: true), + Analytics: () => fakeAnalytics, + PlistParser: () => FakePlistParser({'FLTEnableImpeller': false}), + }, + ); + }); } diff --git a/packages/flutter_tools/test/commands.shard/permeable/build_aar_test.dart b/packages/flutter_tools/test/commands.shard/permeable/build_aar_test.dart index 338e160963452..420325ae8e98b 100644 --- a/packages/flutter_tools/test/commands.shard/permeable/build_aar_test.dart +++ b/packages/flutter_tools/test/commands.shard/permeable/build_aar_test.dart @@ -224,10 +224,10 @@ void main() { expect(buildInfo.flavor, isNull); expect(buildInfo.splitDebugInfoPath, isNull); expect(buildInfo.dartObfuscation, isFalse); - expect(androidBuildInfo.targetArchs, [ - AndroidArch.armeabi_v7a, - AndroidArch.arm64_v8a, - AndroidArch.x86_64, + expect(androidBuildInfo.targetArchs, [ + CpuArch.armv7, + CpuArch.arm64, + CpuArch.x64, ]); } expect(buildModes, hasLength(3)); @@ -272,7 +272,7 @@ void main() { final AndroidBuildInfo androidBuildInfo = (buildAarCall.namedArguments[#androidBuildInfo] as Set).single; - expect(androidBuildInfo.targetArchs, [AndroidArch.x86_64]); + expect(androidBuildInfo.targetArchs, [CpuArch.x64]); final BuildInfo buildInfo = androidBuildInfo.buildInfo; expect(buildInfo.mode, BuildMode.release); 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..79c90969f1dfe 100644 --- a/packages/flutter_tools/test/commands.shard/permeable/upgrade_test.dart +++ b/packages/flutter_tools/test/commands.shard/permeable/upgrade_test.dart @@ -24,6 +24,10 @@ import '../../src/fakes.dart'; import '../../src/test_flutter_command_runner.dart'; void main() { + setUpAll(() { + Cache.flutterRoot = getFlutterRoot(); + }); + group('UpgradeCommandRunner', () { final jan12026 = DateTime.utc(2026); 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 158798dc6c65e..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', diff --git a/packages/flutter_tools/test/general.shard/android/android_device_start_test.dart b/packages/flutter_tools/test/general.shard/android/android_device_start_test.dart index 4c67b3e87e943..460cd8e772b98 100644 --- a/packages/flutter_tools/test/general.shard/android/android_device_start_test.dart +++ b/packages/flutter_tools/test/general.shard/android/android_device_start_test.dart @@ -54,7 +54,7 @@ void main() { TargetPlatform.android_x64, ]) { testWithoutContext('AndroidDevice.startApp allows release builds on $targetPlatform', () async { - final String arch = getAndroidArchForName(targetPlatform.getName()).archName; + final String arch = getCpuArchForName(targetPlatform.getName()).androidArchName; final device = AndroidDevice( '1234', modelID: 'TestModel', 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..7c4cd490c7faa 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; @@ -1092,7 +1093,7 @@ void main() { codeSizeDirectory: 'foo', packageConfigPath: '.dart_tool/package_config.json', ), - targetArchs: [AndroidArch.arm64_v8a], + targetArchs: [CpuArch.arm64], ), target: 'lib/main.dart', isBuildingBundle: false, @@ -1328,11 +1329,7 @@ void main() { treeShakeIcons: false, packageConfigPath: '.dart_tool/package_config.json', ), - targetArchs: [ - AndroidArch.arm64_v8a, - AndroidArch.armeabi_v7a, - AndroidArch.x86_64, - ], + targetArchs: [CpuArch.arm64, CpuArch.armv7, CpuArch.x64], ), target: 'lib/main.dart', isBuildingBundle: true, @@ -1403,11 +1400,7 @@ void main() { treeShakeIcons: false, packageConfigPath: '.dart_tool/package_config.json', ), - targetArchs: [ - AndroidArch.arm64_v8a, - AndroidArch.armeabi_v7a, - AndroidArch.x86_64, - ], + targetArchs: [CpuArch.arm64, CpuArch.armv7, CpuArch.x64], ), target: 'lib/main.dart', isBuildingBundle: true, @@ -1471,11 +1464,7 @@ void main() { treeShakeIcons: false, packageConfigPath: '.dart_tool/package_config.json', ), - targetArchs: [ - AndroidArch.arm64_v8a, - AndroidArch.armeabi_v7a, - AndroidArch.x86_64, - ], + targetArchs: [CpuArch.arm64, CpuArch.armv7, CpuArch.x64], ), target: 'lib/main.dart', isBuildingBundle: true, @@ -1561,11 +1550,7 @@ void main() { treeShakeIcons: false, packageConfigPath: '.dart_tool/package_config.json', ), - targetArchs: [ - AndroidArch.arm64_v8a, - AndroidArch.armeabi_v7a, - AndroidArch.x86_64, - ], + targetArchs: [CpuArch.arm64, CpuArch.armv7, CpuArch.x64], ), target: 'lib/main.dart', isBuildingBundle: true, @@ -1654,11 +1639,7 @@ void main() { treeShakeIcons: false, packageConfigPath: '.dart_tool/package_config.json', ), - targetArchs: [ - AndroidArch.arm64_v8a, - AndroidArch.armeabi_v7a, - AndroidArch.x86_64, - ], + targetArchs: [CpuArch.arm64, CpuArch.armv7, CpuArch.x64], ), target: 'lib/main.dart', isBuildingBundle: true, @@ -3069,6 +3050,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_studio_test.dart b/packages/flutter_tools/test/general.shard/android/android_studio_test.dart index 020727e1e7783..e6c1ac58dabf4 100644 --- a/packages/flutter_tools/test/general.shard/android/android_studio_test.dart +++ b/packages/flutter_tools/test/general.shard/android/android_studio_test.dart @@ -478,10 +478,7 @@ void main() { // Spotlight finds the one known and two random installations. processManager.addCommands([ FakeCommand( - command: const [ - 'mdfind', - 'kMDItemCFBundleIdentifier="com.google.android.studio*"', - ], + command: const ['sh', '-c', kSpotlightMdfindCommand], stdout: '$randomLocation1\n$randomLocation2\n$studioInApplication', ), FakeCommand( @@ -519,6 +516,25 @@ void main() { }, ); + testUsingContext( + 'installation detection on MacOS gracefully handles unresponsive Spotlight query (issue #189177)', + () { + processManager.addCommands([ + const FakeCommand(command: ['sh', '-c', kSpotlightMdfindCommand], exitCode: 137), + ]); + + expect(AndroidStudio.allInstalled(), isEmpty); + expect(processManager, hasNoRemainingExpectations); + }, + overrides: { + FileSystem: () => fileSystem, + FileSystemUtils: () => fsUtils, + ProcessManager: () => processManager, + Platform: () => platform, + PlistParser: () => plistUtils, + }, + ); + testUsingContext( 'finds latest valid install', () { @@ -828,10 +844,7 @@ void main() { processManager.addCommands([ FakeCommand( - command: const [ - 'mdfind', - 'kMDItemCFBundleIdentifier="com.google.android.studio*"', - ], + command: const ['sh', '-c', kSpotlightMdfindCommand], stdout: extractedDownloadZip, ), FakeCommand(command: [studioInApplicationJavaBinary, '-version']), diff --git a/packages/flutter_tools/test/general.shard/android/build_validation_test.dart b/packages/flutter_tools/test/general.shard/android/build_validation_test.dart index 13eacdbd9acff..8103f9e14b4db 100644 --- a/packages/flutter_tools/test/general.shard/android/build_validation_test.dart +++ b/packages/flutter_tools/test/general.shard/android/build_validation_test.dart @@ -13,10 +13,10 @@ void main() { () => validateBuild( const AndroidBuildInfo( BuildInfo.release, - targetArchs: [ - AndroidArch.x86_64, - AndroidArch.armeabi_v7a, - AndroidArch.arm64_v8a, + targetArchs: [ + CpuArch.x64, + CpuArch.armv7, + CpuArch.arm64, ], ), ), @@ -36,7 +36,7 @@ void main() { buildNumber: 'a', packageConfigPath: '.dart_tool/package_config.json', ), - targetArchs: [AndroidArch.x86_64], + targetArchs: [CpuArch.x64], ), ), throwsToolExit(message: 'buildNumber: a was not a valid integer value.'), @@ -53,7 +53,7 @@ void main() { buildNumber: '-1', packageConfigPath: '.dart_tool/package_config.json', ), - targetArchs: [AndroidArch.x86_64], + targetArchs: [CpuArch.x64], ), ), throwsToolExit(message: 'buildNumber: -1 must be a positive integer value.'), @@ -70,7 +70,7 @@ void main() { buildNumber: '2100000001', packageConfigPath: '.dart_tool/package_config.json', ), - targetArchs: [AndroidArch.x86_64], + targetArchs: [CpuArch.x64], ), ), throwsToolExit( @@ -92,7 +92,7 @@ void main() { buildNumber: '2', packageConfigPath: '.dart_tool/package_config.json', ), - targetArchs: [AndroidArch.x86_64], + targetArchs: [CpuArch.x64], ), ), returnsNormally, diff --git a/packages/flutter_tools/test/general.shard/android/gradle_test.dart b/packages/flutter_tools/test/general.shard/android/gradle_test.dart index 934523eb3c428..9c5301b264b4d 100644 --- a/packages/flutter_tools/test/general.shard/android/gradle_test.dart +++ b/packages/flutter_tools/test/general.shard/android/gradle_test.dart @@ -631,6 +631,44 @@ flutter: ProcessManager: () => FakeProcessManager.any(), }, ); + + testUsingContext( + 'returns false for commented-out AndroidX properties', + () async { + final Directory androidDirectory = globals.fs.systemTempDirectory.createTempSync( + 'flutter_android.', + ); + + androidDirectory + .childFile('gradle.properties') + .writeAsStringSync('#android.useAndroidX=true'); + + expect(isAppUsingAndroidX(androidDirectory), isFalse); + }, + overrides: { + FileSystem: () => fs, + ProcessManager: () => FakeProcessManager.any(), + }, + ); + + testUsingContext( + 'returns true when AndroidX property has spaces around the separator', + () async { + final Directory androidDirectory = globals.fs.systemTempDirectory.createTempSync( + 'flutter_android.', + ); + + androidDirectory + .childFile('gradle.properties') + .writeAsStringSync('android.useAndroidX = true'); + + expect(isAppUsingAndroidX(androidDirectory), isTrue); + }, + overrides: { + FileSystem: () => fs, + ProcessManager: () => FakeProcessManager.any(), + }, + ); }); group('printHowToConsumeAar', () { diff --git a/packages/flutter_tools/test/general.shard/base/build_test.dart b/packages/flutter_tools/test/general.shard/base/build_test.dart index 32783a6ca0873..2f88ba4a34670 100644 --- a/packages/flutter_tools/test/general.shard/base/build_test.dart +++ b/packages/flutter_tools/test/general.shard/base/build_test.dart @@ -70,7 +70,7 @@ void main() { final int result = await genSnapshot.run( snapshotType: SnapshotType(TargetPlatform.ios, BuildMode.release), - darwinArch: DarwinArch.arm64, + cpuArch: CpuArch.arm64, additionalArgs: ['--additional_arg'], ); expect(result, 0); @@ -130,7 +130,7 @@ void main() { expect( await snapshotter.build( platform: TargetPlatform.ios, - darwinArch: DarwinArch.arm64, + cpuArch: CpuArch.arm64, sdkRoot: 'path/to/sdk', buildMode: BuildMode.debug, mainPath: 'main.dill', @@ -224,7 +224,7 @@ void main() { buildMode: BuildMode.profile, mainPath: 'main.dill', outputPath: outputPath, - darwinArch: DarwinArch.arm64, + cpuArch: CpuArch.arm64, sdkRoot: 'path/to/sdk', splitDebugInfo: 'foo', dartObfuscation: false, @@ -284,7 +284,7 @@ void main() { buildMode: BuildMode.profile, mainPath: 'main.dill', outputPath: outputPath, - darwinArch: DarwinArch.arm64, + cpuArch: CpuArch.arm64, sdkRoot: 'path/to/sdk', dartObfuscation: true, ); @@ -342,7 +342,7 @@ void main() { buildMode: BuildMode.release, mainPath: 'main.dill', outputPath: outputPath, - darwinArch: DarwinArch.arm64, + cpuArch: CpuArch.arm64, sdkRoot: 'path/to/sdk', dartObfuscation: false, ); 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 7cd111bce0fe7..57fcd7f4dad76 100644 --- a/packages/flutter_tools/test/general.shard/build_info_test.dart +++ b/packages/flutter_tools/test/general.shard/build_info_test.dart @@ -95,21 +95,21 @@ void main() { }); testWithoutContext('getDartNameForDarwinArch returns name used in Dart SDK', () { - expect(DarwinArch.armv7.dartName, 'armv7'); - expect(DarwinArch.arm64.dartName, 'arm64'); - expect(DarwinArch.x86_64.dartName, 'x64'); + expect(CpuArch.armv7.dartName, 'armv7'); + expect(CpuArch.arm64.dartName, 'arm64'); + expect(CpuArch.x64.dartName, 'x64'); }); - testWithoutContext('getNameForDarwinArch returns Apple names', () { - expect(DarwinArch.armv7.name, 'armv7'); - expect(DarwinArch.arm64.name, 'arm64'); - expect(DarwinArch.x86_64.name, 'x86_64'); + testWithoutContext('darwinArchName returns Apple names', () { + expect(CpuArch.armv7.darwinArchName, 'armv7'); + expect(CpuArch.arm64.darwinArchName, 'arm64'); + expect(CpuArch.x64.darwinArchName, 'x86_64'); }); testWithoutContext('getNameForTargetPlatform on Darwin arches', () { - expect(TargetPlatform.ios.getName(darwinArch: DarwinArch.arm64), 'ios-arm64'); - expect(TargetPlatform.ios.getName(darwinArch: DarwinArch.armv7), 'ios-armv7'); - expect(TargetPlatform.ios.getName(darwinArch: DarwinArch.x86_64), 'ios-x86_64'); + expect(TargetPlatform.ios.getName(cpuArch: CpuArch.arm64), 'ios-arm64'); + expect(TargetPlatform.ios.getName(cpuArch: CpuArch.armv7), 'ios-armv7'); + expect(TargetPlatform.ios.getName(cpuArch: CpuArch.x64), 'ios-x86_64'); expect(TargetPlatform.android.getName(), isNot(contains('ios'))); }); @@ -124,7 +124,7 @@ void main() { localEngine: 'ios_debug_unopt', ), ).single, - DarwinArch.arm64, + CpuArch.arm64, ); expect( @@ -135,7 +135,7 @@ void main() { localEngine: 'ios_debug_sim_unopt', ), ).single, - DarwinArch.x86_64, + CpuArch.x64, ); expect( @@ -146,18 +146,18 @@ void main() { localEngine: 'ios_debug_sim_unopt_arm64', ), ).single, - DarwinArch.arm64, + CpuArch.arm64, ); expect( defaultIOSArchsForEnvironment(EnvironmentType.physical, Artifacts.test()).single, - DarwinArch.arm64, + CpuArch.arm64, ); - expect( - defaultIOSArchsForEnvironment(EnvironmentType.simulator, Artifacts.test()), - [DarwinArch.x86_64, DarwinArch.arm64], - ); + expect(defaultIOSArchsForEnvironment(EnvironmentType.simulator, Artifacts.test()), [ + CpuArch.x64, + CpuArch.arm64, + ]); }, overrides: { FileSystem: () => MemoryFileSystem.test(), @@ -175,7 +175,7 @@ void main() { localEngine: 'host_debug_unopt', ), ).single, - DarwinArch.x86_64, + CpuArch.x64, ); expect( @@ -185,12 +185,12 @@ void main() { localEngine: 'host_debug_unopt_arm64', ), ).single, - DarwinArch.arm64, + CpuArch.arm64, ); - expect(defaultMacOSArchsForEnvironment(Artifacts.test()), [ - DarwinArch.x86_64, - DarwinArch.arm64, + expect(defaultMacOSArchsForEnvironment(Artifacts.test()), [ + CpuArch.x64, + CpuArch.arm64, ]); }, overrides: { @@ -199,12 +199,15 @@ void main() { }, ); - testWithoutContext('getIOSArchForName on Darwin arches', () { - expect(getIOSArchForName('armv7'), DarwinArch.armv7); - expect(getIOSArchForName('arm64'), DarwinArch.arm64); - expect(getIOSArchForName('arm64e'), DarwinArch.arm64); - expect(getIOSArchForName('x86_64'), DarwinArch.x86_64); - expect(() => getIOSArchForName('bogus'), throwsException); + testWithoutContext('getCpuArchForName on Darwin and Android arches', () { + expect(getCpuArchForName('armv7'), CpuArch.armv7); + expect(getCpuArchForName('arm64'), CpuArch.arm64); + expect(getCpuArchForName('arm64e'), CpuArch.arm64); + expect(getCpuArchForName('x86_64'), CpuArch.x64); + expect(getCpuArchForName('android-arm'), CpuArch.armv7); + expect(getCpuArchForName('android-arm64'), CpuArch.arm64); + expect(getCpuArchForName('android-x64'), CpuArch.x64); + expect(() => getCpuArchForName('bogus'), throwsException); }); testWithoutContext('named BuildInfo has correct defaults', () { diff --git a/packages/flutter_tools/test/general.shard/build_system/targets/common_test.dart b/packages/flutter_tools/test/general.shard/build_system/targets/common_test.dart index f4fc8918de625..64f77221e483f 100644 --- a/packages/flutter_tools/test/general.shard/build_system/targets/common_test.dart +++ b/packages/flutter_tools/test/general.shard/build_system/targets/common_test.dart @@ -101,7 +101,7 @@ void main() { '--target=flutter', '--no-print-incremental-dependencies', ...buildModeOptions(BuildMode.profile, []), - '--track-widget-creation', + '--track-creation-locations', '--aot', '--tfa', '--target-os', @@ -143,7 +143,7 @@ void main() { '--target=flutter', '--no-print-incremental-dependencies', ...buildModeOptions(BuildMode.profile, []), - '--track-widget-creation', + '--track-creation-locations', '--aot', '--tfa', '--target-os', @@ -188,7 +188,7 @@ void main() { '--target=flutter', '--no-print-incremental-dependencies', ...buildModeOptions(BuildMode.profile, []), - '--track-widget-creation', + '--track-creation-locations', '--aot', '--tfa', '--target-os', @@ -233,7 +233,7 @@ void main() { '--target=flutter', '--no-print-incremental-dependencies', ...buildModeOptions(BuildMode.profile, []), - '--track-widget-creation', + '--track-creation-locations', '--aot', '--tfa', '--target-os', @@ -279,7 +279,7 @@ void main() { '--target=flutter', '--no-print-incremental-dependencies', ...buildModeOptions(BuildMode.profile, []), - '--track-widget-creation', + '--track-creation-locations', '--aot', '--tfa', '--target-os', @@ -647,7 +647,7 @@ void main() { '--target=flutter', '--no-print-incremental-dependencies', ...buildModeOptions(BuildMode.debug, []), - '--track-widget-creation', + '--track-creation-locations', '--no-link-platform', '--packages', '/.dart_tool/package_config.json', 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 30ef77873b3c3..a51af6e45bfd6 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 @@ -134,6 +134,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 { 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..d1881a0e19018 100644 --- a/packages/flutter_tools/test/general.shard/compile_incremental_test.dart +++ b/packages/flutter_tools/test/general.shard/compile_incremental_test.dart @@ -47,7 +47,7 @@ void main() { '-Ddart.vm.profile=false', '-Ddart.vm.product=false', '--enable-asserts', - '--track-widget-creation', + '--track-creation-locations', ]; setUp(() { diff --git a/packages/flutter_tools/test/general.shard/compile_test.dart b/packages/flutter_tools/test/general.shard/compile_test.dart index 8d37a124a35ba..0be122136050a 100644 --- a/packages/flutter_tools/test/general.shard/compile_test.dart +++ b/packages/flutter_tools/test/general.shard/compile_test.dart @@ -224,7 +224,7 @@ void main() { '-Ddart.vm.profile=false', '-Ddart.vm.product=false', '--enable-asserts', - '--track-widget-creation', + '--track-creation-locations', '--initialize-from-dill', 'build/0ca43a24517cbfd39e8c3fdfd86bd8d9.cache.dill.track.dill', '--verbosity=error', @@ -299,7 +299,7 @@ void main() { '-Ddart.vm.profile=false', '-Ddart.vm.product=false', '--enable-asserts', - '--track-widget-creation', + '--track-creation-locations', '--include-unsupported-platform-library-stubs', '--initialize-from-dill', 'build/d484347ee69722eb276c222b372bed02.cache.dill.track.dill', diff --git a/packages/flutter_tools/test/general.shard/drive/drive_service_test.dart b/packages/flutter_tools/test/general.shard/drive/drive_service_test.dart index 11b393ce2661e..594773bebdf9f 100644 --- a/packages/flutter_tools/test/general.shard/drive/drive_service_test.dart +++ b/packages/flutter_tools/test/general.shard/drive/drive_service_test.dart @@ -404,6 +404,50 @@ void main() { ); await driverService.stop(); }); + + testWithoutContext( + 'Listens to device log reader even if connection to VM service fails', + () async { + final processManager = FakeProcessManager.empty(); + final logReader = FakeDeviceLogReader(); + final DriverService driverService = FlutterDriverService( + applicationPackageFactory: FakeApplicationPackageFactory(FakeApplicationPackage()), + logger: BufferLogger.test(), + platform: FakePlatform(), + processUtils: ProcessUtils(logger: BufferLogger.test(), processManager: processManager), + dartSdkPath: 'dart', + devtoolsLauncher: FakeDevtoolsLauncher(), + logFlushDelay: Duration.zero, + vmServiceConnector: + ( + Uri httpUri, { + ReloadSources? reloadSources, + Restart? restart, + CompileExpression? compileExpression, + FlutterProject? flutterProject, + PrintStructuredErrorLogMethod? printStructuredErrorLogMethod, + io.CompressionOptions compression = io.CompressionOptions.compressionDefault, + Device? device, + required Logger logger, + }) async { + throw Exception('Failed to connect to VM service'); + }, + ); + final device = FakeDevice(LaunchResult.failed(), logReader: logReader); + + try { + await driverService.reuseApplication( + Uri.parse('http://127.0.0.1:63426/1UasC_ihpXY=/'), + device, + DebuggingOptions.enabled(BuildInfo.debug), + ); + fail('Expected reuseApplication to fail'); + } on Exception catch (e) { + expect(e.toString(), contains('Failed to connect to VM service')); + } + expect(logReader.isListened, true); + }, + ); } FlutterDriverService setUpDriverService({ @@ -466,8 +510,10 @@ class FakeApplicationPackage extends Fake implements ApplicationPackage { } class FakeDevice extends Fake implements Device { - FakeDevice(this.result, {this.supportsFlutterExit = true}); + FakeDevice(this.result, {this.supportsFlutterExit = true, DeviceLogReader? logReader}) + : _logReader = logReader ?? NoOpDeviceLogReader('test'); + final DeviceLogReader _logReader; LaunchResult result; bool didStopApp = false; bool didUninstallApp = false; @@ -495,7 +541,7 @@ class FakeDevice extends Fake implements Device { Future getLogReader({ ApplicationPackage? app, bool includePastLogs = false, - }) async => NoOpDeviceLogReader('test'); + }) async => _logReader; @override Future startApp( @@ -563,3 +609,25 @@ class FakeDartDevelopmentService extends Fake disposed = true; } } + +class FakeDeviceLogReader implements DeviceLogReader { + final StreamController _logLinesController = StreamController.broadcast(); + bool isListened = false; + + @override + String get name => 'fake_log_reader'; + + @override + Stream get logLines { + isListened = true; + return _logLinesController.stream; + } + + @override + void dispose() { + _logLinesController.close(); + } + + @override + Future provideVmService(FlutterVmService connectedVmService) async {} +} 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..2ae98c8f8a4ad 100644 --- a/packages/flutter_tools/test/general.shard/ios/devices_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/devices_test.dart @@ -88,7 +88,7 @@ void main() { xcodeDebug: xcodeDebug, name: 'iPhone 1', sdkVersion: '13.3', - cpuArchitecture: DarwinArch.arm64, + cpuArch: .arm64, connectionInterface: DeviceConnectionInterface.attached, isConnected: true, isPaired: true, @@ -112,7 +112,7 @@ void main() { coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', - cpuArchitecture: DarwinArch.armv7, + cpuArch: .armv7, connectionInterface: DeviceConnectionInterface.attached, isConnected: true, isPaired: true, @@ -137,7 +137,7 @@ void main() { coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', - cpuArchitecture: DarwinArch.arm64, + cpuArch: .arm64, sdkVersion: '1.0.0', connectionInterface: DeviceConnectionInterface.attached, isConnected: true, @@ -161,7 +161,7 @@ void main() { coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', - cpuArchitecture: DarwinArch.arm64, + cpuArch: .arm64, sdkVersion: '13.1.1', connectionInterface: DeviceConnectionInterface.attached, isConnected: true, @@ -185,7 +185,7 @@ void main() { coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', - cpuArchitecture: DarwinArch.arm64, + cpuArch: .arm64, sdkVersion: '10', connectionInterface: DeviceConnectionInterface.attached, isConnected: true, @@ -209,7 +209,7 @@ void main() { coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', - cpuArchitecture: DarwinArch.arm64, + cpuArch: .arm64, sdkVersion: '0', connectionInterface: DeviceConnectionInterface.attached, isConnected: true, @@ -233,7 +233,7 @@ void main() { coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', - cpuArchitecture: DarwinArch.arm64, + cpuArch: .arm64, sdkVersion: 'bogus', connectionInterface: DeviceConnectionInterface.attached, isConnected: true, @@ -259,7 +259,7 @@ void main() { coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', - cpuArchitecture: DarwinArch.arm64, + cpuArch: .arm64, sdkVersion: '13.3.1', connectionInterface: DeviceConnectionInterface.attached, isConnected: true, @@ -285,7 +285,7 @@ void main() { coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', - cpuArchitecture: DarwinArch.arm64, + cpuArch: .arm64, sdkVersion: '13.3.1 (20ADBC)', connectionInterface: DeviceConnectionInterface.attached, isConnected: true, @@ -311,7 +311,7 @@ void main() { coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', - cpuArchitecture: DarwinArch.arm64, + cpuArch: .arm64, sdkVersion: '16.4.1(a) (20ADBC)', connectionInterface: DeviceConnectionInterface.attached, isConnected: true, @@ -337,7 +337,7 @@ void main() { coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', - cpuArchitecture: DarwinArch.arm64, + cpuArch: .arm64, sdkVersion: '0', connectionInterface: DeviceConnectionInterface.attached, isConnected: true, @@ -363,7 +363,7 @@ void main() { coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', - cpuArchitecture: DarwinArch.arm64, + cpuArch: .arm64, connectionInterface: DeviceConnectionInterface.attached, isConnected: true, isPaired: true, @@ -385,7 +385,7 @@ void main() { coreDeviceLauncher: coreDeviceLauncher, xcodeDebug: xcodeDebug, name: 'iPhone 1', - cpuArchitecture: DarwinArch.arm64, + cpuArch: .arm64, sdkVersion: 'bogus', connectionInterface: DeviceConnectionInterface.attached, isConnected: true, @@ -411,7 +411,7 @@ void main() { xcodeDebug: xcodeDebug, name: 'iPhone 1', sdkVersion: '13.3 17C54', - cpuArchitecture: DarwinArch.arm64, + cpuArch: .arm64, connectionInterface: DeviceConnectionInterface.attached, isConnected: true, isPaired: true, @@ -437,7 +437,7 @@ void main() { xcodeDebug: xcodeDebug, name: 'iPhone 1', sdkVersion: '13.3', - cpuArchitecture: DarwinArch.arm64, + cpuArch: .arm64, connectionInterface: DeviceConnectionInterface.attached, isConnected: true, isPaired: true, @@ -470,7 +470,7 @@ void main() { xcodeDebug: xcodeDebug, name: 'iPhone 1', sdkVersion: '13.3', - cpuArchitecture: DarwinArch.arm64, + cpuArch: .arm64, connectionInterface: DeviceConnectionInterface.attached, isConnected: true, isPaired: true, @@ -561,7 +561,7 @@ void main() { xcodeDebug: xcodeDebug, name: 'iPhone 1', sdkVersion: '13.3', - cpuArchitecture: DarwinArch.arm64, + cpuArch: .arm64, connectionInterface: DeviceConnectionInterface.attached, isConnected: true, isPaired: true, @@ -626,7 +626,7 @@ void main() { 'd83d5bc53967baa0ee18626ba87b6254b2ab5418', name: 'Paired iPhone', sdkVersion: '13.3', - cpuArchitecture: DarwinArch.arm64, + cpuArch: .arm64, iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), iosDeploy: iosDeploy, analytics: FakeAnalytics(), @@ -648,7 +648,7 @@ void main() { '00008027-00192736010F802E', name: 'iPad Pro', sdkVersion: '13.3', - cpuArchitecture: DarwinArch.arm64, + cpuArch: .arm64, iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), iosDeploy: iosDeploy, analytics: FakeAnalytics(), @@ -978,7 +978,7 @@ void main() { '00000001-0000000000000000', name: 'iPad', sdkVersion: '13.3', - cpuArchitecture: DarwinArch.arm64, + cpuArch: .arm64, iProxy: IProxy.test(logger: logger, processManager: FakeProcessManager.any()), iosDeploy: iosDeploy, analytics: FakeAnalytics(), 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..a5a2699c6c29c 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,7 +8,6 @@ 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/build_info.dart'; import 'package:flutter_tools/src/cache.dart'; import 'package:flutter_tools/src/device.dart'; import 'package:flutter_tools/src/ios/application_package.dart'; @@ -366,7 +365,7 @@ IOSDevice setUpIOSDevice({ logger: logger, fileSystem: fileSystem ?? MemoryFileSystem.test(), sdkVersion: '13.3', - cpuArchitecture: DarwinArch.arm64, + cpuArch: .arm64, platform: platform, iMobileDevice: IMobileDevice( logger: logger, 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..cc3ddc77ef8ad 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,7 +7,6 @@ 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/build_info.dart'; import 'package:flutter_tools/src/cache.dart'; import 'package:flutter_tools/src/device.dart'; import 'package:flutter_tools/src/ios/core_devices.dart'; @@ -116,7 +115,7 @@ IOSDevice setUpIOSDevice(FileSystem fileSystem) { platform: platform, name: 'iPhone 1', sdkVersion: '13.3', - cpuArchitecture: DarwinArch.arm64, + cpuArch: .arm64, iProxy: IProxy.test(logger: logger, processManager: processManager), connectionInterface: DeviceConnectionInterface.attached, isConnected: true, 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..925c291684e6b 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 @@ -1465,7 +1465,7 @@ IOSDevice setUpIOSDevice({ IOSCoreDeviceControl? coreDeviceControl, IOSCoreDeviceLauncher? coreDeviceLauncher, FakeXcodeDebug? xcodeDebug, - DarwinArch cpuArchitecture = DarwinArch.arm64, + CpuArch cpuArchitecture = CpuArch.arm64, FakeExactAnalytics? analytics, }) { artifacts ??= Artifacts.test(); @@ -1500,7 +1500,7 @@ IOSDevice setUpIOSDevice({ coreDeviceControl: coreDeviceControl ?? FakeIOSCoreDeviceControl(), coreDeviceLauncher: coreDeviceLauncher ?? FakeIOSCoreDeviceLauncher(), xcodeDebug: xcodeDebug ?? FakeXcodeDebug(), - cpuArchitecture: cpuArchitecture, + cpuArch: cpuArchitecture, connectionInterface: DeviceConnectionInterface.attached, isConnected: true, isPaired: true, 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..e1df4bc55456f 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 @@ -1983,7 +1983,7 @@ IOSDevice setUpIOSDevice({ coreDeviceControl: coreDeviceControl ?? FakeIOSCoreDeviceControl(), coreDeviceLauncher: coreDeviceLauncher ?? FakeIOSCoreDeviceLauncher(), xcodeDebug: xcodeDebug ?? FakeXcodeDebug(), - cpuArchitecture: DarwinArch.arm64, + cpuArch: .arm64, connectionInterface: interfaceType, isConnected: true, isPaired: true, diff --git a/packages/flutter_tools/test/general.shard/isolated/build_system/targets/native_assets_test.dart b/packages/flutter_tools/test/general.shard/isolated/build_system/targets/native_assets_test.dart index c549eecaa9c34..e305e9a7749c7 100644 --- a/packages/flutter_tools/test/general.shard/isolated/build_system/targets/native_assets_test.dart +++ b/packages/flutter_tools/test/general.shard/isolated/build_system/targets/native_assets_test.dart @@ -54,7 +54,7 @@ void main() { defines: { kBuildMode: BuildMode.profile.cliName, kTargetPlatform: TargetPlatform.android.getName(), - kAndroidArchs: AndroidArch.arm64_v8a.platformName, + kAndroidArchs: CpuArch.arm64.androidPlatformName, }, inputs: {}, artifacts: artifacts, diff --git a/packages/flutter_tools/test/general.shard/macos/xcode_test.dart b/packages/flutter_tools/test/general.shard/macos/xcode_test.dart index 48d57c6896a41..8c63fd11af424 100644 --- a/packages/flutter_tools/test/general.shard/macos/xcode_test.dart +++ b/packages/flutter_tools/test/general.shard/macos/xcode_test.dart @@ -1157,14 +1157,14 @@ void main() { expect(devices[0].id, '00008027-00192736010F802E'); expect(devices[0].name, 'An iPhone (Space Gray)'); expect(await devices[0].sdkNameAndVersion, 'iOS 13.3 17C54'); - expect(devices[0].cpuArchitecture, DarwinArch.arm64); + expect(await devices[0].cpuArch, CpuArch.arm64); expect(devices[0].connectionInterface, DeviceConnectionInterface.attached); expect(devices[0].isConnected, true); expect(devices[1].id, '98206e7a4afd4aedaff06e687594e089dede3c44'); expect(devices[1].name, 'iPad 1'); expect(await devices[1].sdkNameAndVersion, 'iOS 10.1 14C54'); - expect(devices[1].cpuArchitecture, DarwinArch.armv7); + expect(await devices[1].cpuArch, CpuArch.armv7); expect(devices[1].connectionInterface, DeviceConnectionInterface.attached); expect(devices[1].isConnected, true); @@ -1172,8 +1172,8 @@ void main() { expect(devices[2].name, 'A networked iPad'); expect(await devices[2].sdkNameAndVersion, 'iOS 10.1 14C54'); expect( - devices[2].cpuArchitecture, - DarwinArch.arm64, + await devices[2].cpuArch, + CpuArch.arm64, ); // Defaults to arm64 for unknown architecture. expect(devices[2].connectionInterface, DeviceConnectionInterface.wireless); expect(devices[2].isConnected, true); @@ -1182,8 +1182,8 @@ void main() { expect(devices[3].name, 'iPad 2'); expect(await devices[3].sdkNameAndVersion, 'iOS 10.1 14C54'); expect( - devices[3].cpuArchitecture, - DarwinArch.arm64, + await devices[3].cpuArch, + CpuArch.arm64, ); // Defaults to arm64 for unknown architecture. expect(devices[3].connectionInterface, DeviceConnectionInterface.attached); expect(devices[3].isConnected, true); @@ -1191,7 +1191,7 @@ void main() { expect(devices[4].id, 'c4ca6f7a53027d1b7e4972e28478e7a28e2faee2'); expect(devices[4].name, 'iPhone'); expect(await devices[4].sdkNameAndVersion, 'iOS 13.3 17C54'); - expect(devices[4].cpuArchitecture, DarwinArch.arm64); + expect(await devices[4].cpuArch, CpuArch.arm64); expect(devices[4].connectionInterface, DeviceConnectionInterface.attached); expect(devices[4].isConnected, false); @@ -1308,8 +1308,8 @@ void main() { ), ); final List devices = await xcdevice.getAvailableIOSDevices(); - expect(devices[0].cpuArchitecture, DarwinArch.armv7); - expect(devices[1].cpuArchitecture, DarwinArch.arm64); + expect(await devices[0].cpuArch, CpuArch.armv7); + expect(await devices[1].cpuArch, CpuArch.arm64); expect(fakeProcessManager, hasNoRemainingExpectations); }, overrides: { @@ -1422,7 +1422,7 @@ void main() { expect(devices[0].id, 'c4ca6f7a53027d1b7e4972e28478e7a28e2faee2'); expect(devices[0].name, 'iPhone'); expect(await devices[0].sdkNameAndVersion, 'iOS 13.3 17C54'); - expect(devices[0].cpuArchitecture, DarwinArch.arm64); + expect(await devices[0].cpuArch, CpuArch.arm64); expect(devices[0].connectionInterface, DeviceConnectionInterface.attached); expect(devices[0].isConnected, true); @@ -1491,7 +1491,7 @@ void main() { expect(devices[0].id, 'c4ca6f7a53027d1b7e4972e28478e7a28e2faee2'); expect(devices[0].name, 'iPhone_2'); expect(await devices[0].sdkNameAndVersion, 'iOS 13.3 17C54'); - expect(devices[0].cpuArchitecture, DarwinArch.arm64); + expect(await devices[0].cpuArch, CpuArch.arm64); expect(devices[0].connectionInterface, DeviceConnectionInterface.attached); expect(devices[0].isConnected, false); @@ -1561,7 +1561,7 @@ void main() { expect(devices[0].id, 'c4ca6f7a53027d1b7e4972e28478e7a28e2faee2'); expect(devices[0].name, 'iPhone_1'); expect(await devices[0].sdkNameAndVersion, 'iOS 14.3 17C54'); - expect(devices[0].cpuArchitecture, DarwinArch.arm64); + expect(await devices[0].cpuArch, CpuArch.arm64); expect(devices[0].connectionInterface, DeviceConnectionInterface.attached); expect(devices[0].isConnected, false); @@ -1778,7 +1778,7 @@ void main() { expect(devices[0].id, '00008027-00192736010F802E'); expect(devices[0].name, 'An iPhone (Space Gray)'); expect(await devices[0].sdkNameAndVersion, 'iOS 13.3 17C54'); - expect(devices[0].cpuArchitecture, DarwinArch.arm64); + expect(await devices[0].cpuArch, CpuArch.arm64); expect(devices[0].connectionInterface, DeviceConnectionInterface.wireless); expect(devices[0].isConnected, true); expect(devices[0].devModeEnabled, true); @@ -1786,7 +1786,7 @@ void main() { expect(devices[1].id, '98206e7a4afd4aedaff06e687594e089dede3c44'); expect(devices[1].name, 'iPad 1'); expect(await devices[1].sdkNameAndVersion, 'iOS 10.1 14C54'); - expect(devices[1].cpuArchitecture, DarwinArch.armv7); + expect(await devices[1].cpuArch, CpuArch.armv7); expect(devices[1].connectionInterface, DeviceConnectionInterface.attached); expect(devices[1].isConnected, true); expect(devices[1].devModeEnabled, true); @@ -1795,8 +1795,8 @@ void main() { expect(devices[2].name, 'A networked iPad'); expect(await devices[2].sdkNameAndVersion, 'iOS 10.1 14C54'); expect( - devices[2].cpuArchitecture, - DarwinArch.arm64, + await devices[2].cpuArch, + CpuArch.arm64, ); // Defaults to arm64 for unknown architecture. expect(devices[2].connectionInterface, DeviceConnectionInterface.attached); expect(devices[2].isConnected, true); @@ -1806,8 +1806,8 @@ void main() { expect(devices[3].name, 'iPad 2'); expect(await devices[3].sdkNameAndVersion, 'iOS 10.1 14C54'); expect( - devices[3].cpuArchitecture, - DarwinArch.arm64, + await devices[3].cpuArch, + CpuArch.arm64, ); // Defaults to arm64 for unknown architecture. expect(devices[3].connectionInterface, DeviceConnectionInterface.attached); expect(devices[3].isConnected, true); @@ -1816,7 +1816,7 @@ void main() { expect(devices[4].id, 'c4ca6f7a53027d1b7e4972e28478e7a28e2faee2'); expect(devices[4].name, 'iPhone'); expect(await devices[4].sdkNameAndVersion, 'iOS 13.3 17C54'); - expect(devices[4].cpuArchitecture, DarwinArch.arm64); + expect(await devices[4].cpuArch, CpuArch.arm64); expect(devices[4].connectionInterface, DeviceConnectionInterface.attached); expect(devices[4].isConnected, false); expect(devices[4].devModeEnabled, true); diff --git a/packages/flutter_tools/test/general.shard/migrations/lldb_init_migration_test.dart b/packages/flutter_tools/test/general.shard/migrations/lldb_init_migration_test.dart index 868a5bfb987ff..d37946f226f91 100644 --- a/packages/flutter_tools/test/general.shard/migrations/lldb_init_migration_test.dart +++ b/packages/flutter_tools/test/general.shard/migrations/lldb_init_migration_test.dart @@ -74,35 +74,6 @@ void main() { ); }); - testWithoutContext('fails if Xcode workspace not found', () async { - final memoryFileSystem = MemoryFileSystem(); - final testLogger = BufferLogger.test(); - final project = FakeXcodeProject( - platform: SupportedPlatform.ios.name, - fileSystem: memoryFileSystem, - logger: testLogger, - ); - _createProjectFiles(project); - project.xcodeWorkspace = null; - - final migration = LLDBInitMigration( - project, - BuildInfo.debug, - testLogger, - environmentType: EnvironmentType.physical, - fileSystem: memoryFileSystem, - ); - await migration.migrate(); - expect(testLogger.traceText, contains('Xcode workspace not found.')); - expect( - testLogger.errorText, - contains( - 'Running Flutter in debug mode on new iOS versions requires a LLDB Init File, but the ' - 'scheme does not have it set.', - ), - ); - }); - testWithoutContext('fails if scheme not found', () async { final memoryFileSystem = MemoryFileSystem(); final testLogger = BufferLogger.test(); @@ -496,6 +467,36 @@ void main() { ), ); }); + + testWithoutContext('succeeds with no Runner.xcworkspace', () async { + final memoryFileSystem = MemoryFileSystem(); + final testLogger = BufferLogger.test(); + final project = FakeXcodeProject( + platform: SupportedPlatform.ios.name, + fileSystem: memoryFileSystem, + logger: testLogger, + ); + _createProjectFiles(project); + project.xcodeWorkspace = null; + project.xcodeProjectSchemeFile().writeAsStringSync(_validScheme()); + + final migration = LLDBInitMigration( + project, + BuildInfo.debug, + testLogger, + environmentType: EnvironmentType.physical, + fileSystem: memoryFileSystem, + ); + await migration.migrate(); + expect(testLogger.errorText, isEmpty); + expect( + project.xcodeProjectSchemeFile().readAsStringSync(), + _validScheme( + lldbInitFile: + '\n customLLDBInitFile = "\$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"', + ), + ); + }); }); } 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..0cdcc098ea59d 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 @@ -143,35 +143,6 @@ void main() { expect(testLogger.statusText, isEmpty); }); - testWithoutContext('fails if Xcode workspace not found', () async { - final memoryFileSystem = MemoryFileSystem(); - final testLogger = BufferLogger.test(); - final project = FakeXcodeProject( - platform: FlutterDarwinPlatform.ios.name, - fileSystem: memoryFileSystem, - logger: testLogger, - ); - _createProjectFiles(project, FlutterDarwinPlatform.ios, schemeMigrated: false); - project.xcodeWorkspace = null; - - final projectMigration = SwiftPackageManagerIntegrationMigration( - project, - FlutterDarwinPlatform.ios, - BuildInfo.debug, - xcodeProjectInterpreter: FakeXcodeProjectInterpreter(), - logger: testLogger, - fileSystem: memoryFileSystem, - plistParser: FakePlistParser(), - config: FakeConfig(), - ); - await expectLater( - () => projectMigration.migrate(), - throwsToolExit(message: 'Xcode workspace not found.'), - ); - expect(testLogger.traceText, isEmpty); - expect(testLogger.statusText, isEmpty); - }); - testWithoutContext('fails if scheme not found', () async { final memoryFileSystem = MemoryFileSystem(); final testLogger = BufferLogger.test(); @@ -558,6 +529,43 @@ void main() { ); }); + testWithoutContext('successfully updates scheme with no Runner.xcworkspace', () async { + final memoryFileSystem = MemoryFileSystem(); + final testLogger = BufferLogger.test(); + final project = FakeXcodeProject( + platform: platform.name, + fileSystem: memoryFileSystem, + logger: testLogger, + ); + _createProjectFiles(project, platform, schemeMigrated: false); + project.xcodeWorkspace = null; + project.xcodeProjectSchemeFile().writeAsStringSync(_validBuildActions(platform)); + + final plistParser = FakePlistParser.multiple([ + _plutilOutput(_allSectionsMigratedAsJson(platform)), + _plutilOutput(_allSectionsMigratedAsJson(platform)), + ]); + project.xcodeProjectInfoFile.writeAsStringSync( + _projectSettings(_allSectionsMigrated(platform)), + ); + final projectMigration = SwiftPackageManagerIntegrationMigration( + project, + platform, + BuildInfo.debug, + xcodeProjectInterpreter: FakeXcodeProjectInterpreter(), + logger: testLogger, + fileSystem: memoryFileSystem, + plistParser: plistParser, + config: FakeConfig(), + ); + + await projectMigration.migrate(); + expect( + project.xcodeProjectSchemeFile().readAsStringSync(), + _validBuildActions(platform, hasFrameworkScript: true), + ); + }); + testWithoutContext('successfully updates scheme with preexisting PreActions', () async { final memoryFileSystem = MemoryFileSystem(); final testLogger = BufferLogger.test(); diff --git a/packages/flutter_tools/test/general.shard/resident_runner_helpers.dart b/packages/flutter_tools/test/general.shard/resident_runner_helpers.dart index 452311ebb9086..21aa7c7090044 100644 --- a/packages/flutter_tools/test/general.shard/resident_runner_helpers.dart +++ b/packages/flutter_tools/test/general.shard/resident_runner_helpers.dart @@ -206,9 +206,13 @@ class FakeFlutterDevice extends Fake implements FlutterDevice { UpdateFSReport report = UpdateFSReport(success: true, invalidatedSourcesCount: 1); Exception? reportError; Exception? runColdError; + Exception? connectError; int runHotCode = 0; int runColdCode = 0; + @override + Duration logFlushDelay = Duration.zero; + @override ResidentCompiler? generator; @@ -266,7 +270,11 @@ class FakeFlutterDevice extends Fake implements FlutterDevice { required DebuggingOptions debuggingOptions, int? hostVmServicePort, bool? ipv6 = false, - }) async {} + }) async { + if (connectError != null) { + throw connectError!; + } + } @override Future updateDevFS({ diff --git a/packages/flutter_tools/test/general.shard/resident_runner_test.dart b/packages/flutter_tools/test/general.shard/resident_runner_test.dart index 236ea20b5566b..1564f472becb1 100644 --- a/packages/flutter_tools/test/general.shard/resident_runner_test.dart +++ b/packages/flutter_tools/test/general.shard/resident_runner_test.dart @@ -2275,6 +2275,20 @@ flutter: }, ); + testUsingContext( + 'ResidentRunner delays on connection failure to allow logs to flush', + () => testbed.run(() async { + flutterDevice.connectError = Exception('Failed to connect'); + flutterDevice.logFlushDelay = const Duration(milliseconds: 100); + + final stopwatch = Stopwatch()..start(); + final int result = await residentRunner.attach(); + stopwatch.stop(); + + expect(result, 2); + expect(stopwatch.elapsedMilliseconds, greaterThanOrEqualTo(100)); + }), + ); group('ResidentRunner cached Initial Dill Compilation', () { late TestBed testbed; late FakeFlutterDevice flutterDevice; diff --git a/packages/flutter_tools/test/host_cross_arch.shard/cache_test.dart b/packages/flutter_tools/test/host_cross_arch.shard/cache_test.dart deleted file mode 100644 index 7311412e9ab2c..0000000000000 --- a/packages/flutter_tools/test/host_cross_arch.shard/cache_test.dart +++ /dev/null @@ -1,58 +0,0 @@ -// Copyright 2014 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'package:file/file.dart'; -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 '../integration.shard/test_utils.dart'; -import '../src/common.dart'; - -Future main() async { - test('verify the dart binary arch matches the host arch', () async { - final HostPlatform dartArch = _identifyMacBinaryArch(_dartBinary.path); - final os = OperatingSystemUtils( - processManager: processManager, - fileSystem: fileSystem, - platform: platform, - logger: BufferLogger.test(), - ); - expect(dartArch, os.hostPlatform); - }, skip: !platform.isMacOS); // [intended] Calls macOS-specific commands -} - -// Call `file` on the path and parse the output. -HostPlatform _identifyMacBinaryArch(String path) { - // Expect STDOUT like: - // bin/cache/dart-sdk/bin/dart: Mach-O 64-bit executable x86_64 - final pattern = RegExp(r'Mach-O 64-bit executable (\w+)'); - final ProcessResult result = processManager.runSync(['file', _dartBinary.path]); - expect( - result, - ProcessResultMatcher(stdoutPattern: '${_dartBinary.path}: Mach-O 64-bit executable'), - ); - final RegExpMatch? match = pattern.firstMatch(result.stdout as String); - if (match == null) { - fail('Unrecognized STDOUT from `file`: "${result.stdout}"'); - } - switch (match.group(1)) { - case 'x86_64': - return HostPlatform.darwin_x64; - case 'arm64': - return HostPlatform.darwin_arm64; - default: - fail('Unexpected architecture ${match.group(1)}'); - } -} - -final String _flutterRootPath = getFlutterRoot(); -final Directory _flutterRoot = fileSystem.directory(_flutterRootPath); -final File _dartBinary = _flutterRoot - .childDirectory('bin') - .childDirectory('cache') - .childDirectory('dart-sdk') - .childDirectory('bin') - .childFile('dart') - .absolute; diff --git a/packages/flutter_tools/test/host_cross_arch.shard/macos_content_validation_test.dart b/packages/flutter_tools/test/host_cross_arch.shard/macos_content_validation_test.dart deleted file mode 100644 index c578a26f037b6..0000000000000 --- a/packages/flutter_tools/test/host_cross_arch.shard/macos_content_validation_test.dart +++ /dev/null @@ -1,307 +0,0 @@ -// Copyright 2014 The Flutter Authors. All rights reserved. -// Use of this source code is governed by a BSD-style license that can be -// found in the LICENSE file. - -import 'dart:convert'; - -import 'package:file_testing/file_testing.dart'; -import 'package:flutter_tools/src/base/file_system.dart'; -import 'package:flutter_tools/src/base/io.dart'; -import 'package:flutter_tools/src/build_info.dart'; - -import '../integration.shard/test_utils.dart'; -import '../src/common.dart'; - -void main() { - final String flutterBin = fileSystem.path.join(getFlutterRoot(), 'bin', 'flutter'); - - setUpAll(() { - processManager.runSync([flutterBin, 'config', '--enable-macos-desktop']); - }); - - for (final buildMode in [BuildMode.debug, BuildMode.profile, BuildMode.release]) { - test('verify ${buildMode.cliName} FlutterMacOS.xcframework artifact', () { - final String flutterRoot = getFlutterRoot(); - - final String artifactDir; - switch (buildMode) { - case BuildMode.debug: - case BuildMode.jitRelease: - artifactDir = 'darwin-x64'; - case BuildMode.profile: - artifactDir = 'darwin-x64-profile'; - case BuildMode.release: - artifactDir = 'darwin-x64-release'; - } - final Directory xcframeworkArtifact = fileSystem.directory( - fileSystem.path.join( - flutterRoot, - 'bin', - 'cache', - 'artifacts', - 'engine', - artifactDir, - 'FlutterMacOS.xcframework', - ), - ); - - final Directory tempDir = createResolvedTempDirectorySync('macos_content_validation.'); - - // Pre-cache macOS engine FlutterMacOS.xcframework artifacts. - final ProcessResult result = processManager.runSync([ - flutterBin, - ...getLocalEngineArguments(), - 'precache', - '--macos', - ], workingDirectory: tempDir.path); - - expect(result, const ProcessResultMatcher()); - expect(xcframeworkArtifact.existsSync(), isTrue); - - final Directory frameworkArtifact = fileSystem.directory( - fileSystem.path.joinAll([ - xcframeworkArtifact.path, - 'macos-arm64_x86_64', - 'FlutterMacOS.framework', - ]), - ); - // Check read/write permissions are set correctly in the framework engine artifact. - final String artifactStat = frameworkArtifact.statSync().mode.toRadixString(8); - expect(artifactStat, '40755'); - - // Verify Info.plist has correct engine version and build mode - final File engineInfo = fileSystem.file( - fileSystem.path.join(flutterRoot, 'bin', 'cache', 'engine_stamp.json'), - ); - expect(engineInfo, exists); - - final String engineVersion; - if (json.decode(engineInfo.readAsStringSync().trim()) as Map case { - 'git_revision': final String parsedVersion, - }) { - engineVersion = parsedVersion; - } else { - fail('engine_stamp.json missing "git_revision" key'); - } - - final File infoPlist = fileSystem.file( - fileSystem.path.joinAll([ - xcframeworkArtifact.path, - 'macos-arm64_x86_64', - 'FlutterMacOS.framework', - 'Versions', - 'A', - 'Resources', - 'Info.plist', - ]), - ); - expect(infoPlist, exists); - - final String infoPlistContents = infoPlist.readAsStringSync(); - expect(infoPlistContents, contains(engineVersion)); - expect(infoPlistContents, contains(buildMode.cliName)); - - if (buildMode == BuildMode.release) { - final Directory dsymArtifact = fileSystem.directory( - fileSystem.path.joinAll([ - xcframeworkArtifact.path, - 'macos-arm64_x86_64', - 'dSYMs', - 'FlutterMacOS.framework.dSYM', - ]), - ); - // Verify dSYM is present. - expect(dsymArtifact.existsSync(), isTrue); - - // Check read/write permissions are set correctly in the framework engine artifact. - final String artifactStat = dsymArtifact.statSync().mode.toRadixString(8); - expect(artifactStat, '40755'); - } - }); - } - - for (final buildMode in ['Debug', 'Release']) { - final String buildModeLower = buildMode.toLowerCase(); - - test('flutter build macos --$buildModeLower builds a valid app', () { - final String workingDirectory = fileSystem.path.join( - getFlutterRoot(), - 'dev', - 'integration_tests', - 'flutter_gallery', - ); - - processManager.runSync([ - flutterBin, - ...getLocalEngineArguments(), - 'clean', - ], workingDirectory: workingDirectory); - - final File podfile = fileSystem.file( - fileSystem.path.join(workingDirectory, 'macos', 'Podfile'), - ); - final File podfileLock = fileSystem.file( - fileSystem.path.join(workingDirectory, 'macos', 'Podfile.lock'), - ); - expect(podfile, exists); - expect(podfileLock, exists); - - // Simulate a newer Podfile than Podfile.lock. - podfile.setLastModifiedSync(DateTime.now()); - podfileLock.setLastModifiedSync(DateTime.now().subtract(const Duration(days: 1))); - expect(podfileLock.lastModifiedSync().isBefore(podfile.lastModifiedSync()), isTrue); - - final buildCommand = [ - flutterBin, - ...getLocalEngineArguments(), - 'build', - 'macos', - '--$buildModeLower', - ]; - final ProcessResult result = processManager.runSync( - buildCommand, - workingDirectory: workingDirectory, - ); - - printOnFailure('Output of flutter build macos:'); - printOnFailure(result.stdout.toString()); - printOnFailure(result.stderr.toString()); - expect(result.exitCode, 0); - - expect(result.stdout, contains('Running pod install')); - expect(podfile.lastModifiedSync().isBefore(podfileLock.lastModifiedSync()), isTrue); - - final Directory buildPath = fileSystem.directory( - fileSystem.path.join(workingDirectory, 'build', 'macos', 'Build', 'Products', buildMode), - ); - - final Directory outputApp = buildPath.childDirectory('Flutter Gallery.app'); - final Directory outputAppFramework = fileSystem.directory( - fileSystem.path.join(outputApp.path, 'Contents', 'Frameworks', 'App.framework'), - ); - - final File frameworkDsymBinary = buildPath.childFile( - 'FlutterMacOS.framework.dSYM/Contents/Resources/DWARF/FlutterMacOS', - ); - - final File libBinary = outputAppFramework.childFile('App'); - final File libDsymBinary = buildPath.childFile( - 'App.framework.dSYM/Contents/Resources/DWARF/App', - ); - - _checkFatBinary(libBinary, buildModeLower, 'dynamically linked shared library'); - - final List libSymbols = AppleTestUtils.getExportedSymbols(libBinary.path); - - if (buildMode == 'Debug') { - // Framework dSYM is not copied for debug builds. - expect(frameworkDsymBinary.existsSync(), isFalse); - - // dSYM is not created for a debug build. - expect(libDsymBinary.existsSync(), isFalse); - expect(libSymbols, isEmpty); - } else { - // Check framework dSYM file copied. - _checkFatBinary(frameworkDsymBinary, buildModeLower, 'dSYM companion file'); - - // Check extracted dSYM file. - _checkFatBinary(libDsymBinary, buildModeLower, 'dSYM companion file'); - expect(libSymbols, equals(AppleTestUtils.requiredSymbols)); - final List dSymSymbols = AppleTestUtils.getExportedSymbols(libDsymBinary.path); - expect(dSymSymbols, containsAll(AppleTestUtils.requiredSymbols)); - // The actual number of symbols is going to vary but there should - // be "many" in the dSYM. At the time of writing, it was 19195. - expect(dSymSymbols.length, greaterThanOrEqualTo(15000)); - } - - expect(outputAppFramework.childLink('Resources'), exists); - - final File vmSnapshot = fileSystem.file( - fileSystem.path.join( - outputApp.path, - 'Contents', - 'Frameworks', - 'App.framework', - 'Resources', - 'flutter_assets', - 'vm_snapshot_data', - ), - ); - - expect(vmSnapshot.existsSync(), buildMode == 'Debug'); - - final Directory outputFlutterFramework = fileSystem.directory( - fileSystem.path.join(outputApp.path, 'Contents', 'Frameworks', 'FlutterMacOS.framework'), - ); - - // Check read/write permissions are being correctly set. - final String outputFrameworkStat = outputFlutterFramework.statSync().mode.toRadixString(8); - expect(outputFrameworkStat, '40755'); - - // Check complicated macOS framework symlink structure. - final Link current = outputFlutterFramework.childDirectory('Versions').childLink('Current'); - - expect(current.targetSync(), 'A'); - - expect( - outputFlutterFramework.childLink('FlutterMacOS').targetSync(), - fileSystem.path.join('Versions', 'Current', 'FlutterMacOS'), - ); - - expect(outputFlutterFramework.childLink('Resources'), exists); - expect( - outputFlutterFramework.childLink('Resources').targetSync(), - fileSystem.path.join('Versions', 'Current', 'Resources'), - ); - - expect(outputFlutterFramework.childLink('Headers'), isNot(exists)); - expect(outputFlutterFramework.childDirectory('Headers'), isNot(exists)); - expect(outputFlutterFramework.childLink('Modules'), isNot(exists)); - expect(outputFlutterFramework.childDirectory('Modules'), isNot(exists)); - - // PrivacyInfo.xcprivacy was first added to the top-level path, but - // the correct location is Versions/A/Resources/PrivacyInfo.xcprivacy. - // TODO(jmagman): Switch expectation to only check Resources/ once the new path rolls. - // https://github.com/flutter/flutter/issues/157016#issuecomment-2420786225 - final File topLevelPrivacy = outputFlutterFramework.childFile('PrivacyInfo.xcprivacy'); - final File resourcesLevelPrivacy = fileSystem.file( - fileSystem.path.join(outputFlutterFramework.path, 'Resources', 'PrivacyInfo.xcprivacy'), - ); - - expect(topLevelPrivacy.existsSync() || resourcesLevelPrivacy.existsSync(), isTrue); - - // Build again without cleaning. - final ProcessResult secondBuild = processManager.runSync( - buildCommand, - workingDirectory: workingDirectory, - ); - - printOnFailure('Output of second build:'); - printOnFailure(secondBuild.stdout.toString()); - printOnFailure(secondBuild.stderr.toString()); - expect(secondBuild.exitCode, 0); - - expect(secondBuild.stdout, isNot(contains('Running pod install'))); - - processManager.runSync([ - flutterBin, - ...getLocalEngineArguments(), - 'clean', - ], workingDirectory: workingDirectory); - }, skip: !platform.isMacOS); // [intended] only makes sense for macos platform. - } -} - -void _checkFatBinary(File file, String buildModeLower, String expectedType) { - final archs = processManager.runSync(['file', file.path]).stdout as String; - - final bool containsX64 = archs.contains('Mach-O 64-bit $expectedType x86_64'); - final bool containsArm = archs.contains('Mach-O 64-bit $expectedType arm64'); - if (buildModeLower == 'debug') { - // Only build the architecture matching the machine running this test, not both. - expect(containsX64 ^ containsArm, isTrue, reason: 'Unexpected architecture $archs'); - } else { - expect(containsX64, isTrue, reason: 'Unexpected architecture $archs'); - expect(containsArm, isTrue, reason: 'Unexpected architecture $archs'); - } -} diff --git a/packages/flutter_tools/test/integration.shard/build_without_xcworkspace_test.dart b/packages/flutter_tools/test/integration.shard/build_without_xcworkspace_test.dart new file mode 100644 index 0000000000000..869d3a6d2ef26 --- /dev/null +++ b/packages/flutter_tools/test/integration.shard/build_without_xcworkspace_test.dart @@ -0,0 +1,124 @@ +// 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:file_testing/file_testing.dart'; +import 'package:flutter_tools/src/base/error_handling_io.dart'; +import 'package:flutter_tools/src/base/file_system.dart'; +import 'package:flutter_tools/src/base/io.dart'; + +import '../src/common.dart'; +import 'test_utils.dart'; + +void main() { + late Directory workingDirectory; + + setUp(() { + workingDirectory = fileSystem.systemTempDirectory.createTempSync( + 'build_without_xcworkspace_test.', + ); + }); + + tearDown(() { + ErrorHandlingFileSystem.deleteIfExists(workingDirectory, recursive: true); + }); + + test( + 'flutter build ios succeeds when no .xcworkspace is present', + () async { + await _testBuildWithoutWorkspace( + workingDirectory: workingDirectory, + targetPlatform: 'ios', + buildArgs: ['--debug', '--no-codesign'], + // This file only exists if the app was fully built. + fullyBuiltMarker: (Directory appDirectory) => appDirectory + .childDirectory('build') + .childDirectory('ios') + .childDirectory('iphoneos') + .childDirectory('Runner.app') + .childFile('AppFrameworkInfo.plist'), + ); + }, + skip: !platform.isMacOS, // [intended] Can only build for iOS on macOS. + ); + + test( + 'flutter build macos succeeds when no .xcworkspace is present', + () async { + await _testBuildWithoutWorkspace( + workingDirectory: workingDirectory, + targetPlatform: 'macos', + buildArgs: ['--debug'], + // This file only exists if the app was fully built. + fullyBuiltMarker: (Directory appDirectory) => appDirectory + .childDirectory('build') + .childDirectory('macos') + .childDirectory('Build') + .childDirectory('Products') + .childDirectory('Debug') + .childDirectory('App.framework') + .childDirectory('Resources') + .childFile('Info.plist'), + ); + }, + skip: !platform.isMacOS, // [intended] Can only build for macOS on macOS. + ); +} + +/// Creates an app, removes its `Runner.xcworkspace`, and verifies the build +/// still succeeds. +Future _testBuildWithoutWorkspace({ + required Directory workingDirectory, + required String targetPlatform, + required List buildArgs, + required File Function(Directory appDirectory) fullyBuiltMarker, +}) async { + const appName = 'no_workspace_app'; + + final ProcessResult createResult = await processManager.run([ + flutterBin, + ...getLocalEngineArguments(), + 'create', + '--org', + 'io.flutter.devicelab', + appName, + '--platforms=$targetPlatform', + ], workingDirectory: workingDirectory.path); + expect( + createResult.exitCode, + 0, + reason: + 'Failed to create app: \n' + 'stdout: \n${createResult.stdout}\n' + 'stderr: \n${createResult.stderr}\n', + ); + + final Directory appDirectory = workingDirectory.childDirectory(appName); + + final Directory workspace = appDirectory + .childDirectory(targetPlatform) + .childDirectory('Runner.xcworkspace'); + ErrorHandlingFileSystem.deleteIfExists(workspace, recursive: true); + expect(workspace, isNot(exists)); + + final ProcessResult buildResult = await processManager.run([ + flutterBin, + ...getLocalEngineArguments(), + 'build', + targetPlatform, + ...buildArgs, + ], workingDirectory: appDirectory.path); + expect( + buildResult.exitCode, + 0, + reason: + 'Failed to build the app without a .xcworkspace: \n' + 'stdout: \n${buildResult.stdout}\n' + 'stderr: \n${buildResult.stderr}\n', + ); + + // The build must not require or silently recreate the workspace; that is + // the regression this test guards against. + expect(workspace, isNot(exists)); + expect(fullyBuiltMarker(appDirectory), exists); +} diff --git a/packages/flutter_tools/test/integration.shard/cache_test.dart b/packages/flutter_tools/test/integration.shard/cache_test.dart index ea115fd8c1fb3..3957ade6deb56 100644 --- a/packages/flutter_tools/test/integration.shard/cache_test.dart +++ b/packages/flutter_tools/test/integration.shard/cache_test.dart @@ -182,6 +182,17 @@ Future main(List args) async { .replaceAll('x86_64', 'x64'); expect(dartTargetArch, equals(unameArch)); }); + + testWithoutContext('verify the dart binary arch matches the host arch', () async { + final HostPlatform dartArch = _identifyMacBinaryArch(_dartBinary.path); + final os = OperatingSystemUtils( + processManager: processManager, + fileSystem: fileSystem, + platform: platform, + logger: BufferLogger.test(), + ); + expect(dartArch, os.hostPlatform); + }, skip: !platform.isMacOS); // [intended] Calls macOS-specific commands } class FakeArtifactUpdater extends Fake implements ArtifactUpdater { @@ -221,3 +232,34 @@ class FakeVersionlessArtifact extends CachedArtifact { OperatingSystemUtils operatingSystemUtils, ) async {} } + +// Call `file` on the path and parse the output. +HostPlatform _identifyMacBinaryArch(String path) { + // Expect STDOUT like: + // bin/cache/dart-sdk/bin/dart: Mach-O 64-bit executable x86_64 + final pattern = RegExp(r'Mach-O 64-bit executable (\w+)'); + final ProcessResult result = processManager.runSync(['file', path]); + expect(result, ProcessResultMatcher(stdoutPattern: '$path: Mach-O 64-bit executable')); + final RegExpMatch? match = pattern.firstMatch(result.stdout as String); + if (match == null) { + fail('Unrecognized STDOUT from `file`: "${result.stdout}"'); + } + switch (match.group(1)) { + case 'x86_64': + return HostPlatform.darwin_x64; + case 'arm64': + return HostPlatform.darwin_arm64; + default: + fail('Unexpected architecture ${match.group(1)}'); + } +} + +final String _flutterRootPath = getFlutterRoot(); +final Directory _flutterRoot = fileSystem.directory(_flutterRootPath); +final File _dartBinary = _flutterRoot + .childDirectory('bin') + .childDirectory('cache') + .childDirectory('dart-sdk') + .childDirectory('bin') + .childFile('dart') + .absolute; 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..04776a23e6fef 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 @@ -2,10 +2,6 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. -// TODO(bkonyi): remove and cleanup prints once https://github.com/flutter/flutter/issues/172636 -// is resolved. -// ignore_for_file: avoid_print - import 'dart:async'; import 'package:dds/dap.dart'; @@ -133,7 +129,6 @@ class DapTestClient { bool? supportsRunInTerminalRequest, bool? supportsProgressReporting, }) async { - print('DapTestClient.initialize: wait for responses'); final List responses = await Future.wait(>[ event('initialized'), sendRequest( @@ -145,7 +140,6 @@ class DapTestClient { ), sendRequest(SetExceptionBreakpointsArguments(filters: [exceptionPauseMode])), ]); - print('DapTestClient.initialize: got responses, sending config done'); await sendRequest(ConfigurationDoneArguments()); return responses[1] as Response; // Return the initialize response. } @@ -267,12 +261,8 @@ class DapTestClient { Future Function()? launch, }) { return Future.wait(>[ - initialize( - exceptionPauseMode: exceptionPauseMode, - ).then((_) => print('DapTestClient.initialize: completed')), - (launch?.call() ?? this.launch(program: program, cwd: cwd)).then( - (_) => print('DapTestClient.launch: completed'), - ), + initialize(exceptionPauseMode: exceptionPauseMode), + launch?.call() ?? this.launch(program: program, cwd: cwd), ], eagerError: true); } @@ -408,20 +398,11 @@ extension DapTestClientExtension on DapTestClient { final Future>> testNotificationEventsFuture = testNotificationEvents .toList(); - print('DapTestClient.start: started'); if (start != null) { await start(); } else { await this.start(program: program, cwd: cwd, launch: launch); } - print('DapTestClient.start: completed'); - - unawaited(outputEventsFuture.then((_) => print('DapTestClient.outputEventsFuture: completed'))); - unawaited( - testNotificationEventsFuture.then( - (_) => print('DapTestClient.testNotificationEventsFuture: completed'), - ), - ); return TestEvents( output: await outputEventsFuture, 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..84ac28524032e 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 @@ -29,11 +29,7 @@ final useInProcessDap = Platform.environment['DAP_TEST_INTERNAL'] == 'true'; /// This is useful for debugging locally or on the bots and will include both /// DAP traffic (between the test DAP client and the DAP server) and the VM /// Service traffic (wrapped in a custom 'dart.log' event). -final bool verboseLogging = - Platform.environment['DAP_TEST_VERBOSE'] == 'true' || - // Enable verbose logging on CI bots. - // TODO(bkonyi): remove this once https://github.com/flutter/flutter/issues/172636 is resolved. - Platform.environment.containsKey('SWARMING_TASK_ID'); +final bool verboseLogging = Platform.environment['DAP_TEST_VERBOSE'] == 'true'; const endOfErrorOutputMarker = '════════════════════════════════════════════════════════════════════════════════'; diff --git a/packages/flutter_tools/test/host_cross_arch.shard/ios_content_validation_test.dart b/packages/flutter_tools/test/integration.shard/ios_content_validation_test.dart similarity index 99% rename from packages/flutter_tools/test/host_cross_arch.shard/ios_content_validation_test.dart rename to packages/flutter_tools/test/integration.shard/ios_content_validation_test.dart index 8719a1239ec42..1c04eeec03367 100644 --- a/packages/flutter_tools/test/host_cross_arch.shard/ios_content_validation_test.dart +++ b/packages/flutter_tools/test/integration.shard/ios_content_validation_test.dart @@ -10,8 +10,8 @@ import 'package:flutter_tools/src/base/io.dart'; import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/darwin/darwin.dart'; -import '../integration.shard/test_utils.dart'; import '../src/common.dart'; +import 'test_utils.dart'; void main() { group( @@ -333,7 +333,7 @@ void main() { for (final buildMode in [BuildMode.debug, BuildMode.profile, BuildMode.release]) { for (final arch in ['ios-arm64', 'ios-arm64_x86_64-simulator']) { - test('verify ${buildMode.cliName} $arch Flutter.framework Info.plist', () { + testWithoutContext('verify ${buildMode.cliName} $arch Flutter.framework Info.plist', () { final String artifactDir; switch (buildMode) { case BuildMode.debug: diff --git a/packages/flutter_tools/test/integration.shard/macos_content_validation_test.dart b/packages/flutter_tools/test/integration.shard/macos_content_validation_test.dart new file mode 100644 index 0000000000000..eba8334517175 --- /dev/null +++ b/packages/flutter_tools/test/integration.shard/macos_content_validation_test.dart @@ -0,0 +1,309 @@ +// 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:convert'; + +import 'package:file_testing/file_testing.dart'; +import 'package:flutter_tools/src/base/file_system.dart'; +import 'package:flutter_tools/src/base/io.dart'; +import 'package:flutter_tools/src/build_info.dart'; + +import '../src/common.dart'; +import 'test_utils.dart'; + +void main() { + group('macOS content validation', () { + final String flutterBin = fileSystem.path.join(getFlutterRoot(), 'bin', 'flutter'); + + setUpAll(() { + processManager.runSync([flutterBin, 'config', '--enable-macos-desktop']); + }); + + for (final buildMode in [BuildMode.debug, BuildMode.profile, BuildMode.release]) { + testWithoutContext('verify ${buildMode.cliName} FlutterMacOS.xcframework artifact', () { + final String flutterRoot = getFlutterRoot(); + + final String artifactDir; + switch (buildMode) { + case BuildMode.debug: + case BuildMode.jitRelease: + artifactDir = 'darwin-x64'; + case BuildMode.profile: + artifactDir = 'darwin-x64-profile'; + case BuildMode.release: + artifactDir = 'darwin-x64-release'; + } + final Directory xcframeworkArtifact = fileSystem.directory( + fileSystem.path.join( + flutterRoot, + 'bin', + 'cache', + 'artifacts', + 'engine', + artifactDir, + 'FlutterMacOS.xcframework', + ), + ); + + final Directory tempDir = createResolvedTempDirectorySync('macos_content_validation.'); + + // Pre-cache macOS engine FlutterMacOS.xcframework artifacts. + final ProcessResult result = processManager.runSync([ + flutterBin, + ...getLocalEngineArguments(), + 'precache', + '--macos', + ], workingDirectory: tempDir.path); + + expect(result, const ProcessResultMatcher()); + expect(xcframeworkArtifact.existsSync(), isTrue); + + final Directory frameworkArtifact = fileSystem.directory( + fileSystem.path.joinAll([ + xcframeworkArtifact.path, + 'macos-arm64_x86_64', + 'FlutterMacOS.framework', + ]), + ); + // Check read/write permissions are set correctly in the framework engine artifact. + final String artifactStat = frameworkArtifact.statSync().mode.toRadixString(8); + expect(artifactStat, '40755'); + + // Verify Info.plist has correct engine version and build mode + final File engineInfo = fileSystem.file( + fileSystem.path.join(flutterRoot, 'bin', 'cache', 'engine_stamp.json'), + ); + expect(engineInfo, exists); + + final String engineVersion; + if (json.decode(engineInfo.readAsStringSync().trim()) as Map case { + 'git_revision': final String parsedVersion, + }) { + engineVersion = parsedVersion; + } else { + fail('engine_stamp.json missing "git_revision" key'); + } + + final File infoPlist = fileSystem.file( + fileSystem.path.joinAll([ + xcframeworkArtifact.path, + 'macos-arm64_x86_64', + 'FlutterMacOS.framework', + 'Versions', + 'A', + 'Resources', + 'Info.plist', + ]), + ); + expect(infoPlist, exists); + + final String infoPlistContents = infoPlist.readAsStringSync(); + expect(infoPlistContents, contains(engineVersion)); + expect(infoPlistContents, contains(buildMode.cliName)); + + if (buildMode == BuildMode.release) { + final Directory dsymArtifact = fileSystem.directory( + fileSystem.path.joinAll([ + xcframeworkArtifact.path, + 'macos-arm64_x86_64', + 'dSYMs', + 'FlutterMacOS.framework.dSYM', + ]), + ); + // Verify dSYM is present. + expect(dsymArtifact.existsSync(), isTrue); + + // Check read/write permissions are set correctly in the framework engine artifact. + final String artifactStat = dsymArtifact.statSync().mode.toRadixString(8); + expect(artifactStat, '40755'); + } + }); + } + + for (final buildMode in ['Debug', 'Release']) { + final String buildModeLower = buildMode.toLowerCase(); + + testWithoutContext('flutter build macos --$buildModeLower builds a valid app', () { + final String workingDirectory = fileSystem.path.join( + getFlutterRoot(), + 'dev', + 'integration_tests', + 'flutter_gallery', + ); + + processManager.runSync([ + flutterBin, + ...getLocalEngineArguments(), + 'clean', + ], workingDirectory: workingDirectory); + + final File podfile = fileSystem.file( + fileSystem.path.join(workingDirectory, 'macos', 'Podfile'), + ); + final File podfileLock = fileSystem.file( + fileSystem.path.join(workingDirectory, 'macos', 'Podfile.lock'), + ); + expect(podfile, exists); + expect(podfileLock, exists); + + // Simulate a newer Podfile than Podfile.lock. + podfile.setLastModifiedSync(DateTime.now()); + podfileLock.setLastModifiedSync(DateTime.now().subtract(const Duration(days: 1))); + expect(podfileLock.lastModifiedSync().isBefore(podfile.lastModifiedSync()), isTrue); + + final buildCommand = [ + flutterBin, + ...getLocalEngineArguments(), + 'build', + 'macos', + '--$buildModeLower', + ]; + final ProcessResult result = processManager.runSync( + buildCommand, + workingDirectory: workingDirectory, + ); + + printOnFailure('Output of flutter build macos:'); + printOnFailure(result.stdout.toString()); + printOnFailure(result.stderr.toString()); + expect(result.exitCode, 0); + + expect(result.stdout, contains('Running pod install')); + expect(podfile.lastModifiedSync().isBefore(podfileLock.lastModifiedSync()), isTrue); + + final Directory buildPath = fileSystem.directory( + fileSystem.path.join(workingDirectory, 'build', 'macos', 'Build', 'Products', buildMode), + ); + + final Directory outputApp = buildPath.childDirectory('Flutter Gallery.app'); + final Directory outputAppFramework = fileSystem.directory( + fileSystem.path.join(outputApp.path, 'Contents', 'Frameworks', 'App.framework'), + ); + + final File frameworkDsymBinary = buildPath.childFile( + 'FlutterMacOS.framework.dSYM/Contents/Resources/DWARF/FlutterMacOS', + ); + + final File libBinary = outputAppFramework.childFile('App'); + final File libDsymBinary = buildPath.childFile( + 'App.framework.dSYM/Contents/Resources/DWARF/App', + ); + + _checkFatBinary(libBinary, buildModeLower, 'dynamically linked shared library'); + + final List libSymbols = AppleTestUtils.getExportedSymbols(libBinary.path); + + if (buildMode == 'Debug') { + // Framework dSYM is not copied for debug builds. + expect(frameworkDsymBinary.existsSync(), isFalse); + + // dSYM is not created for a debug build. + expect(libDsymBinary.existsSync(), isFalse); + expect(libSymbols, isEmpty); + } else { + // Check framework dSYM file copied. + _checkFatBinary(frameworkDsymBinary, buildModeLower, 'dSYM companion file'); + + // Check extracted dSYM file. + _checkFatBinary(libDsymBinary, buildModeLower, 'dSYM companion file'); + expect(libSymbols, equals(AppleTestUtils.requiredSymbols)); + final List dSymSymbols = AppleTestUtils.getExportedSymbols(libDsymBinary.path); + expect(dSymSymbols, containsAll(AppleTestUtils.requiredSymbols)); + // The actual number of symbols is going to vary but there should + // be "many" in the dSYM. At the time of writing, it was 19195. + expect(dSymSymbols.length, greaterThanOrEqualTo(15000)); + } + + expect(outputAppFramework.childLink('Resources'), exists); + + final File vmSnapshot = fileSystem.file( + fileSystem.path.join( + outputApp.path, + 'Contents', + 'Frameworks', + 'App.framework', + 'Resources', + 'flutter_assets', + 'vm_snapshot_data', + ), + ); + + expect(vmSnapshot.existsSync(), buildMode == 'Debug'); + + final Directory outputFlutterFramework = fileSystem.directory( + fileSystem.path.join(outputApp.path, 'Contents', 'Frameworks', 'FlutterMacOS.framework'), + ); + + // Check read/write permissions are being correctly set. + final String outputFrameworkStat = outputFlutterFramework.statSync().mode.toRadixString(8); + expect(outputFrameworkStat, '40755'); + + // Check complicated macOS framework symlink structure. + final Link current = outputFlutterFramework.childDirectory('Versions').childLink('Current'); + + expect(current.targetSync(), 'A'); + + expect( + outputFlutterFramework.childLink('FlutterMacOS').targetSync(), + fileSystem.path.join('Versions', 'Current', 'FlutterMacOS'), + ); + + expect(outputFlutterFramework.childLink('Resources'), exists); + expect( + outputFlutterFramework.childLink('Resources').targetSync(), + fileSystem.path.join('Versions', 'Current', 'Resources'), + ); + + expect(outputFlutterFramework.childLink('Headers'), isNot(exists)); + expect(outputFlutterFramework.childDirectory('Headers'), isNot(exists)); + expect(outputFlutterFramework.childLink('Modules'), isNot(exists)); + expect(outputFlutterFramework.childDirectory('Modules'), isNot(exists)); + + // PrivacyInfo.xcprivacy was first added to the top-level path, but + // the correct location is Versions/A/Resources/PrivacyInfo.xcprivacy. + // TODO(jmagman): Switch expectation to only check Resources/ once the new path rolls. + // https://github.com/flutter/flutter/issues/157016#issuecomment-2420786225 + final File topLevelPrivacy = outputFlutterFramework.childFile('PrivacyInfo.xcprivacy'); + final File resourcesLevelPrivacy = fileSystem.file( + fileSystem.path.join(outputFlutterFramework.path, 'Resources', 'PrivacyInfo.xcprivacy'), + ); + + expect(topLevelPrivacy.existsSync() || resourcesLevelPrivacy.existsSync(), isTrue); + + // Build again without cleaning. + final ProcessResult secondBuild = processManager.runSync( + buildCommand, + workingDirectory: workingDirectory, + ); + + printOnFailure('Output of second build:'); + printOnFailure(secondBuild.stdout.toString()); + printOnFailure(secondBuild.stderr.toString()); + expect(secondBuild.exitCode, 0); + + expect(secondBuild.stdout, isNot(contains('Running pod install'))); + + processManager.runSync([ + flutterBin, + ...getLocalEngineArguments(), + 'clean', + ], workingDirectory: workingDirectory); + }); + } + }, skip: !platform.isMacOS); // [intended] macOS content validation only runs on macOS. +} + +void _checkFatBinary(File file, String buildModeLower, String expectedType) { + final archs = processManager.runSync(['file', file.path]).stdout as String; + + final bool containsX64 = archs.contains('Mach-O 64-bit $expectedType x86_64'); + final bool containsArm = archs.contains('Mach-O 64-bit $expectedType arm64'); + if (buildModeLower == 'debug') { + // Only build the architecture matching the machine running this test, not both. + expect(containsX64 ^ containsArm, isTrue, reason: 'Unexpected architecture $archs'); + } else { + expect(containsX64, isTrue, reason: 'Unexpected architecture $archs'); + expect(containsArm, isTrue, reason: 'Unexpected architecture $archs'); + } +} diff --git a/pubspec.lock b/pubspec.lock index a47233dcd6897..1345c3610b65d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -218,6 +218,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.9" + cupertino_ui: + dependency: transitive + description: + name: cupertino_ui + sha256: "427360789a03b76eaf659c37131570d2fc98e381f80d55f252bd9e30f37e7126" + url: "https://pub.dev" + source: hosted + version: "0.0.2" dap: dependency: transitive description: @@ -263,10 +271,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 +287,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: @@ -658,6 +666,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.13.0" + material_ui: + dependency: transitive + description: + name: material_ui + sha256: e1fa67393d807bd1841e5ece1ec260ed84f440ae7351e40b6406f733e4fe31cf + url: "https://pub.dev" + source: hosted + version: "0.0.2" meta: dependency: "direct main" description: @@ -1171,10 +1187,10 @@ packages: dependency: "direct main" description: name: vector_math - sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188" + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.2" video_player: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index 80163c30f82a4..3af22515f270b 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -190,7 +190,7 @@ dependencies: url_launcher_platform_interface: 2.3.2 url_launcher_web: 2.4.3 url_launcher_windows: 3.1.5 - vector_math: ^2.4.0 + vector_math: ^2.4.2 video_player: 2.13.0 video_player_android: 2.12.0 video_player_avfoundation: 2.11.0 @@ -222,4 +222,4 @@ dependencies: dev_dependencies: ffigen: 20.1.1 -# PUBSPEC CHECKSUM: eb62ub +# PUBSPEC CHECKSUM: db69ps