A touch viewer had to raise the chrome to pause, which dims the picture and covers the subtitle line they were trying to finish reading. A two-finger tap now toggles playback with the chrome left down, so the frame that pauses is the frame that was on screen. It fires the moment the chord resolves, in every player state. The two-finger double tap no longer resets the video zoom. Keeping it would mean holding this toggle back for the double-tap window before acting, and pausing late is pausing on the wrong frame. Zoom reset stays in the video settings sheet, its presets and the keyboard shortcut, and pinching back to 100% now snaps exactly within three percent so touch has a one-gesture path too. Both chord actions share _mobileTouchGesturesAllowed, so the chord is inert under screen lock, in PiP and while the content strip is open; the zoom reset previously fired straight through a locked screen. close #1505
195 lines
5.8 KiB
Dart
195 lines
5.8 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
const _analysisTimeout = Duration(minutes: 3);
|
|
|
|
final _allowedDiagnostics = <AnalyzerDiagnostic>{
|
|
const AnalyzerDiagnostic(
|
|
severity: 'INFO',
|
|
type: 'LINT',
|
|
code: 'UNAWAITED_FUTURES',
|
|
path: 'test/navigation/profile_navigation_scope_test.dart',
|
|
line: 21,
|
|
column: 28,
|
|
length: 4,
|
|
message: "Missing an 'await' for the 'Future' computed by this expression.",
|
|
),
|
|
};
|
|
|
|
Future<void> main() async {
|
|
final scriptDirectory = File.fromUri(Platform.script).parent;
|
|
final root = scriptDirectory.parent.path;
|
|
late final Process process;
|
|
|
|
try {
|
|
process = await Process.start(
|
|
Platform.resolvedExecutable,
|
|
const ['analyze', '--format', 'machine', '--fatal-infos'],
|
|
workingDirectory: root,
|
|
runInShell: false,
|
|
);
|
|
} on ProcessException catch (error) {
|
|
stderr.writeln('Failed to start the analyzer: $error');
|
|
exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
final analyzerStdoutFuture = process.stdout.transform(utf8.decoder).join();
|
|
final analyzerStderrFuture = process.stderr.transform(utf8.decoder).join();
|
|
|
|
int analyzerExitCode;
|
|
try {
|
|
analyzerExitCode = await process.exitCode.timeout(_analysisTimeout);
|
|
} on TimeoutException {
|
|
process.kill(ProcessSignal.sigkill);
|
|
await Future.wait([analyzerStdoutFuture, analyzerStderrFuture]);
|
|
stderr.writeln('Analyzer timed out after ${_analysisTimeout.inMinutes} minutes.');
|
|
exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
final analyzerStdout = await analyzerStdoutFuture;
|
|
final analyzerStderr = await analyzerStderrFuture;
|
|
stdout.write(analyzerStdout);
|
|
stderr.write(analyzerStderr);
|
|
|
|
final failure = validateAnalyzerResult(
|
|
analyzerExitCode: analyzerExitCode,
|
|
analyzerStdout: analyzerStdout,
|
|
analyzerStderr: analyzerStderr,
|
|
rootPath: root,
|
|
);
|
|
if (failure != null) {
|
|
stderr.writeln('Analyzer check failed: $failure');
|
|
exitCode = 1;
|
|
return;
|
|
}
|
|
|
|
final diagnosticCount = analyzerStdout.trim().isEmpty ? 0 : const LineSplitter().convert(analyzerStdout).length;
|
|
stdout.writeln(
|
|
'Analyzer passed with $diagnosticCount explicitly allowed info '
|
|
'diagnostic(s).',
|
|
);
|
|
}
|
|
|
|
String? validateAnalyzerResult({
|
|
required int analyzerExitCode,
|
|
required String analyzerStdout,
|
|
required String analyzerStderr,
|
|
required String rootPath,
|
|
}) {
|
|
if (analyzerStderr.trim().isNotEmpty) {
|
|
return 'unexpected stderr output (exit $analyzerExitCode)';
|
|
}
|
|
|
|
final lines = const LineSplitter().convert(analyzerStdout);
|
|
final diagnostics = <AnalyzerDiagnostic>[];
|
|
for (final line in lines) {
|
|
if (line.isEmpty) {
|
|
return 'unexpected blank line in machine output';
|
|
}
|
|
final diagnostic = AnalyzerDiagnostic.tryParse(line, rootPath: rootPath);
|
|
if (diagnostic == null) {
|
|
return 'malformed machine output: $line';
|
|
}
|
|
diagnostics.add(diagnostic);
|
|
}
|
|
|
|
final seen = <AnalyzerDiagnostic>{};
|
|
for (final diagnostic in diagnostics) {
|
|
if (diagnostic.severity != 'INFO') {
|
|
return 'unexpected ${diagnostic.severity} diagnostic: ${diagnostic.code}';
|
|
}
|
|
if (!_allowedDiagnostics.contains(diagnostic)) {
|
|
return 'info diagnostic is not explicitly allowed: '
|
|
'${diagnostic.code} at ${diagnostic.path}:${diagnostic.line}';
|
|
}
|
|
if (!seen.add(diagnostic)) {
|
|
return 'duplicate diagnostic: '
|
|
'${diagnostic.code} at ${diagnostic.path}:${diagnostic.line}';
|
|
}
|
|
}
|
|
|
|
if (analyzerExitCode == 0) {
|
|
return diagnostics.isEmpty ? null : 'analyzer returned success despite --fatal-infos diagnostics';
|
|
}
|
|
if (analyzerExitCode == 1 && diagnostics.isNotEmpty) {
|
|
return null;
|
|
}
|
|
return 'analyzer exited $analyzerExitCode without only allowed info diagnostics';
|
|
}
|
|
|
|
class AnalyzerDiagnostic {
|
|
const AnalyzerDiagnostic({
|
|
required this.severity,
|
|
required this.type,
|
|
required this.code,
|
|
required this.path,
|
|
required this.line,
|
|
required this.column,
|
|
required this.length,
|
|
required this.message,
|
|
});
|
|
|
|
factory AnalyzerDiagnostic._fromParts(List<String> parts, String rootPath) {
|
|
return AnalyzerDiagnostic(
|
|
severity: parts[0],
|
|
type: parts[1],
|
|
code: parts[2],
|
|
path: _relativePath(parts[3], rootPath),
|
|
line: int.parse(parts[4]),
|
|
column: int.parse(parts[5]),
|
|
length: int.parse(parts[6]),
|
|
message: parts.sublist(7).join('|'),
|
|
);
|
|
}
|
|
|
|
static AnalyzerDiagnostic? tryParse(String line, {required String rootPath}) {
|
|
final parts = line.split('|');
|
|
if (parts.length < 8 || parts.take(8).any((part) => part.isEmpty)) {
|
|
return null;
|
|
}
|
|
if (int.tryParse(parts[4]) == null || int.tryParse(parts[5]) == null || int.tryParse(parts[6]) == null) {
|
|
return null;
|
|
}
|
|
return AnalyzerDiagnostic._fromParts(parts, rootPath);
|
|
}
|
|
|
|
static String _relativePath(String path, String rootPath) {
|
|
final normalizedPath = _normalizePath(path);
|
|
final normalizedRoot = _normalizePath(rootPath);
|
|
final rootPrefix = '$normalizedRoot/';
|
|
if (normalizedPath.toLowerCase().startsWith(rootPrefix.toLowerCase())) {
|
|
return normalizedPath.substring(rootPrefix.length);
|
|
}
|
|
return normalizedPath;
|
|
}
|
|
|
|
static String _normalizePath(String path) => path.replaceAll(r'\\', '/').replaceAll(r'\', '/');
|
|
|
|
final String severity;
|
|
final String type;
|
|
final String code;
|
|
final String path;
|
|
final int line;
|
|
final int column;
|
|
final int length;
|
|
final String message;
|
|
|
|
@override
|
|
bool operator ==(Object other) =>
|
|
other is AnalyzerDiagnostic &&
|
|
severity == other.severity &&
|
|
type == other.type &&
|
|
code == other.code &&
|
|
path == other.path &&
|
|
line == other.line &&
|
|
column == other.column &&
|
|
length == other.length &&
|
|
message == other.message;
|
|
|
|
@override
|
|
int get hashCode => Object.hash(severity, type, code, path, line, column, length, message);
|
|
}
|