Better filtering for Android `scenario_app` runner. (#50937)

_🍴 'd from https://github.com/flutter/engine/pull/50933, will rebase
when merged._

Closes https://github.com/flutter/flutter/issues/143458.

A picture is a 1000 words:

![Screenshot 2024-02-23 at 7 01
29 PM](https://github.com/flutter/engine/assets/168174/7254b3be-cc49-4bad-ae43-e61ac4a853ad)

This is still noisy, but at least all the output appears to be part of
the execution.

As you recall, the full logs are always available in the
FLUTTER_LOGS_DIR output.
diff --git a/testing/scenario_app/bin/run_android_tests.dart b/testing/scenario_app/bin/run_android_tests.dart
index f0ad827..436cb42 100644
--- a/testing/scenario_app/bin/run_android_tests.dart
+++ b/testing/scenario_app/bin/run_android_tests.dart
@@ -47,48 +47,52 @@
     ..addOption(
       'adb',
       help: 'Path to the adb tool',
-      defaultsTo: engine != null ? join(
-        engine.srcDir.path,
-        'third_party',
-        'android_tools',
-        'sdk',
-        'platform-tools',
-        'adb',
-      ) : null,
+      defaultsTo: engine != null
+          ? join(
+              engine.srcDir.path,
+              'third_party',
+              'android_tools',
+              'sdk',
+              'platform-tools',
+              'adb',
+            )
+          : null,
     )
     ..addOption(
       'ndk-stack',
       help: 'Path to the ndk-stack tool',
-      defaultsTo: engine != null ? join(
-        engine.srcDir.path,
-        'third_party',
-        'android_tools',
-        'ndk',
-        'prebuilt',
-        () {
-          if (Platform.isLinux) {
-            return 'linux-x86_64';
-          } else if (Platform.isMacOS) {
-            return 'darwin-x86_64';
-          } else if (Platform.isWindows) {
-            return 'windows-x86_64';
-          } else {
-            throw UnsupportedError('Unsupported platform: ${Platform.operatingSystem}');
-          }
-        }(),
-        'bin',
-        'ndk-stack',
-      ) : null,
+      defaultsTo: engine != null
+          ? join(
+              engine.srcDir.path,
+              'third_party',
+              'android_tools',
+              'ndk',
+              'prebuilt',
+              () {
+                if (Platform.isLinux) {
+                  return 'linux-x86_64';
+                } else if (Platform.isMacOS) {
+                  return 'darwin-x86_64';
+                } else if (Platform.isWindows) {
+                  return 'windows-x86_64';
+                } else {
+                  throw UnsupportedError('Unsupported platform: ${Platform.operatingSystem}');
+                }
+              }(),
+              'bin',
+              'ndk-stack',
+            )
+          : null,
     )
     ..addOption(
       'out-dir',
       help: 'Out directory',
-      defaultsTo:
-        engine?.
-        outputs().
-        where((Output o) => basename(o.path.path).startsWith('android_')).
-        firstOrNull?.
-        path.path,
+      defaultsTo: engine
+          ?.outputs()
+          .where((Output o) => basename(o.path.path).startsWith('android_'))
+          .firstOrNull
+          ?.path
+          .path,
     )
     ..addOption(
       'smoke-test',
@@ -106,14 +110,16 @@
     ..addOption(
       'output-contents-golden',
       help: 'Path to a file that contains the expected filenames of golden files.',
-      defaultsTo: engine != null ? join(
-        engine.srcDir.path,
-        'flutter',
-        'testing',
-        'scenario_app',
-        'android',
-        'expected_golden_output.txt',
-      ) : null,
+      defaultsTo: engine != null
+          ? join(
+              engine.srcDir.path,
+              'flutter',
+              'testing',
+              'scenario_app',
+              'android',
+              'expected_golden_output.txt',
+            )
+          : null,
     )
     ..addOption(
       'impeller-backend',
@@ -124,8 +130,8 @@
     ..addOption(
       'logs-dir',
       help: 'The directory to store the logs and screenshots. Defaults to '
-            'the value of the FLUTTER_LOGS_DIR environment variable, if set, '
-            'otherwise it defaults to a path within out-dir.',
+          'the value of the FLUTTER_LOGS_DIR environment variable, if set, '
+          'otherwise it defaults to a path within out-dir.',
       defaultsTo: Platform.environment['FLUTTER_LOGS_DIR'],
     );
 
@@ -153,7 +159,10 @@
       final String? contentsGolden = results['output-contents-golden'] as String?;
       final _ImpellerBackend? impellerBackend = _ImpellerBackend.tryParse(results['impeller-backend'] as String?);
       if (enableImpeller && impellerBackend == null) {
-        panic(<String>['invalid graphics-backend', results['impeller-backend'] as String? ?? '<null>']);
+        panic(<String>[
+          'invalid graphics-backend',
+          results['impeller-backend'] as String? ?? '<null>'
+        ]);
       }
       final Directory logsDir = Directory(results['logs-dir'] as String? ?? join(outDir.path, 'scenario_app', 'logs'));
       final String? ndkStack = results['ndk-stack'] as String?;
@@ -215,7 +224,10 @@
   const ProcessManager pm = LocalProcessManager();
 
   if (!outDir.existsSync()) {
-    panic(<String>['out-dir does not exist: $outDir', 'make sure to build the selected engine variant']);
+    panic(<String>[
+      'out-dir does not exist: $outDir',
+      'make sure to build the selected engine variant'
+    ]);
   }
 
   if (!adb.existsSync()) {
@@ -236,11 +248,17 @@
   log('writing logs and screenshots to ${logsDir.path}');
 
   if (!testApk.existsSync()) {
-    panic(<String>['test apk does not exist: ${testApk.path}', 'make sure to build the selected engine variant']);
+    panic(<String>[
+      'test apk does not exist: ${testApk.path}',
+      'make sure to build the selected engine variant'
+    ]);
   }
 
   if (!appApk.existsSync()) {
-    panic(<String>['app apk does not exist: ${appApk.path}', 'make sure to build the selected engine variant']);
+    panic(<String>[
+      'app apk does not exist: ${appApk.path}',
+      'make sure to build the selected engine variant'
+    ]);
   }
 
   // Start a TCP socket in the host, and forward it to the device that runs the tests.
@@ -248,7 +266,7 @@
   // for the screenshots.
   // On LUCI, the host uploads the screenshots to Skia Gold.
   SkiaGoldClient? skiaGoldClient;
-  late  ServerSocket server;
+  late ServerSocket server;
   final List<Future<void>> pendingComparisons = <Future<void>>[];
   await step('Starting server...', () async {
     server = await ServerSocket.bind(InternetAddress.anyIPv4, _tcpPort);
@@ -259,7 +277,8 @@
       if (verbose) {
         stdout.writeln('client connected ${client.remoteAddress.address}:${client.remotePort}');
       }
-      client.transform(const ScreenshotBlobTransformer()).listen((Screenshot screenshot) {
+      client.transform(const ScreenshotBlobTransformer()).listen(
+          (Screenshot screenshot) {
         final String fileName = screenshot.filename;
         final Uint8List fileContent = screenshot.fileContent;
         if (verbose) {
@@ -277,18 +296,15 @@
         }
         if (isSkiaGoldClientAvailable) {
           final Future<void> comparison = skiaGoldClient!
-            .addImg(fileName, goldenFile,
-                    screenshotSize: screenshot.pixelCount)
-            .catchError((dynamic err) {
-              panic(<String>['skia gold comparison failed: $err']);
-            });
+              .addImg(fileName, goldenFile, screenshotSize: screenshot.pixelCount)
+              .catchError((dynamic err) {
+            panic(<String>['skia gold comparison failed: $err']);
+          });
           pendingComparisons.add(comparison);
         }
-      },
-      onError: (dynamic err) {
+      }, onError: (dynamic err) {
         panic(<String>['error while receiving bytes: $err']);
-      },
-      cancelOnError: true);
+      }, cancelOnError: true);
     });
   });
 
@@ -311,27 +327,38 @@
       final (Future<int> logcatExitCode, Stream<String> logcatOutput) = getProcessStreams(logcatProcess);
 
       logcatProcessExitCode = logcatExitCode;
+      String? filterProcessId;
+
       logcatOutput.listen((String line) {
         // Always write to the full log.
         logcat.writeln(line);
 
         // Conditionally parse and write to stderr.
         final AdbLogLine? adbLogLine = AdbLogLine.tryParse(line);
-        switch (adbLogLine?.process) {
-          case null:
-            break;
-          case 'ActivityManager':
-            // These are mostly noise, i.e. "D ActivityManager: freezing 24632 com.blah".
-            if (adbLogLine!.severity == 'D') {
-              break;
-            }
-          // TODO(matanlurey): Figure out why this isn't 'flutter.scenario' or similar.
-          // Also, why is there two different names?
-          case 'utter.scenario':
-          case 'utter.scenarios':
-          case 'flutter':
-          case 'FlutterJNI':
-            log('[adb] $line');
+        if (verbose || adbLogLine == null) {
+          log(line);
+          return;
+        }
+
+        // If we haven't already found a process ID, try to find one.
+        // The process ID will help us filter out logs from other processes.
+        filterProcessId ??= adbLogLine.tryParseProcess();
+
+        // If this is a "verbose" log, possibly skip it.
+        final bool isVerbose = adbLogLine.isVerbose(filterProcessId: filterProcessId);
+        if (isVerbose || filterProcessId == null) {
+          // We've requested verbose output, so print everything.
+          if (verbose) {
+            adbLogLine.printFormatted();
+          }
+          return;
+        }
+
+        // It's a non-verbose log, so print it.
+        adbLogLine.printFormatted();
+      }, onError: (Object? err) {
+        if (verbose) {
+          logWarning('logcat stream error: $err');
         }
       });
     });
@@ -364,10 +391,7 @@
       log('using dimensions: ${json.encode(dimensions)}');
       skiaGoldClient = SkiaGoldClient(
         outDir,
-        dimensions: <String, String>{
-          'AndroidAPILevel': connectedDeviceAPILevel,
-          'GraphicsBackend': enableImpeller ? 'impeller-${impellerBackend!.name}' : 'skia',
-        },
+        dimensions: dimensions,
       );
     });
 
@@ -412,11 +436,9 @@
         'am',
         'instrument',
         '-w',
-        if (smokeTestFullPath != null)
-          '-e class $smokeTestFullPath',
+        if (smokeTestFullPath != null) '-e class $smokeTestFullPath',
         'dev.flutter.scenarios.test/dev.flutter.TestRunner',
-        if (enableImpeller)
-          '-e enable-impeller',
+        if (enableImpeller) '-e enable-impeller',
         if (impellerBackend != null)
           '-e impeller-backend ${impellerBackend.name}',
       ]);
@@ -465,7 +487,8 @@
       final int exitCode = await pm.runAndForward(<String>[
         adb.path,
         'reverse',
-        '--remove', 'tcp:3000',
+        '--remove',
+        'tcp:3000',
       ]);
       if (exitCode != 0) {
         panic(<String>['could not unforward port']);
@@ -473,14 +496,16 @@
     });
 
     await step('Uninstalling app APK...', () async {
-      final int exitCode = await pm.runAndForward(<String>[adb.path, 'uninstall', 'dev.flutter.scenarios']);
+      final int exitCode = await pm.runAndForward(
+          <String>[adb.path, 'uninstall', 'dev.flutter.scenarios']);
       if (exitCode != 0) {
         panic(<String>['could not uninstall app apk']);
       }
     });
 
     await step('Uninstalling test APK...', () async {
-      final int exitCode = await pm.runAndForward(<String>[adb.path, 'uninstall', 'dev.flutter.scenarios.test']);
+      final int exitCode = await pm.runAndForward(
+          <String>[adb.path, 'uninstall', 'dev.flutter.scenarios.test']);
       if (exitCode != 0) {
         panic(<String>['could not uninstall app apk']);
       }
diff --git a/testing/scenario_app/bin/utils/adb_logcat_filtering.dart b/testing/scenario_app/bin/utils/adb_logcat_filtering.dart
index ca67ab1..9785263 100644
--- a/testing/scenario_app/bin/utils/adb_logcat_filtering.dart
+++ b/testing/scenario_app/bin/utils/adb_logcat_filtering.dart
@@ -1,3 +1,7 @@
+// 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.
+
 /// Some notes about filtering `adb logcat` output, especially as a result of
 /// running `adb shell` to instrument the app and test scripts, as it's
 /// non-trivial and error-prone.
@@ -26,6 +30,8 @@
 /// See also: <https://developer.android.com/tools/logcat>.
 library;
 
+import 'logs.dart';
+
 /// Represents a line of `adb logcat` output parsed into a structured form.
 ///
 /// For example the line:
@@ -40,14 +46,15 @@
 /// with lazy parsing.
 extension type const AdbLogLine._(Match _match) {
   // RegEx that parses into the following groups:
-  // 1. Everything up to the severity (I, W, E, etc.).
-  //    In other words, any whitespace, numbers, hyphens, colons, and periods.
-  // 2. The severity (a single uppercase letter).
-  // 3. The name of the process (up to the colon).
-  // 4. The message (after the colon).
+  // 1. The time of the log message, such as `02-22 13:54:39.839`.
+  // 2. The process ID.
+  // 3. The thread ID.
+  // 4. The character representing the severity of the log message, such as `I`.
+  // 5. The tag, such as `ActivityManager`.
+  // 6. The actual log message.
   //
   // This regex is simple versus being more precise. Feel free to improve it.
-  static final RegExp _pattern = RegExp(r'([^A-Z]*)([A-Z])\s([^:]*)\:\s(.*)');
+  static final RegExp _pattern = RegExp(r'(\d+-\d+\s[\d|:]+\.\d+)\s+(\d+)\s+(\d+)\s(\w)\s(\S+)\s*:\s*(.*)');
 
   /// Parses the given [adbLogCatLine] into a structured form.
   ///
@@ -57,15 +64,91 @@
     return match == null ? null : AdbLogLine._(match);
   }
 
+  /// Tries to parse the process that was started, if the log line is about it.
+  String? tryParseProcess() {
+    if (name == 'ActivityManager' && message.startsWith('Start proc')) {
+      // Start proc 6840:d
+      final RegExpMatch? match = RegExp(r'Start proc (\d+):').firstMatch(message);
+      return match?.group(1);
+    }
+    return null;
+  }
+
+  /// Returns `true` if the log line is verbose.
+  bool isVerbose({String? filterProcessId}) => !_isRelevant(filterProcessId: filterProcessId);
+  bool _isRelevant({String? filterProcessId}) {
+    // Fatal errors are always useful.
+    if (severity == 'F') {
+      return true;
+    }
+
+    // Debug logs are rarely useful.
+    if (severity == 'D') {
+      return false;
+    }
+
+    // These are "known" noise tags.
+    if (const <String>{
+      'MonitoringInstr',
+      'ResourceExtractor',
+      'THREAD_STATE',
+      'ziparchive',
+    }.contains(name)) {
+      return false;
+    }
+
+    // These are "known" tags useful for debugging.
+    if (const <String>{
+      'utter.scenario',
+      'utter.scenarios',
+      'TestRunner',
+    }.contains(name)) {
+      return true;
+    }
+
+    // If a process ID is specified, exclude logs _not_ from that process.
+    if (filterProcessId != null && process != filterProcessId) {
+      return false;
+    }
+
+    // And... whatever, include anything with the word "flutter".
+    return name.toLowerCase().contains('flutter') || message.toLowerCase().contains('flutter');
+  }
+
+  /// Logs the line to the console.
+  void printFormatted() {
+    final String formatted = '$time [$severity] $name: $message';
+    if (severity == 'W' || severity == 'E' || severity == 'F') {
+      logWarning(formatted);
+    } else if (name == 'TestRunner') {
+      logImportant(formatted);
+    } else {
+      log(formatted);
+    }
+  }
+
   /// The full line of `adb logcat` output.
   String get line => _match.group(0)!;
 
-  /// The character representing the severity of the log message, such as `I`.
-  String get severity => _match.group(2)!;
+  /// The time of the log message, such as `02-22 13:54:39.839`.
+  String get time => _match.group(1)!;
 
-  /// The process name, such as `ActivityManager`.
-  String get process => _match.group(3)!;
+  /// The process ID.
+  String get process => _match.group(2)!;
+
+  /// The thread ID.
+  String get thread => _match.group(3)!;
+
+  /// The character representing the severity of the log message, such as `I`.
+  String get severity => _match.group(4)!;
+
+  /// The tag, such as `ActivityManager`.
+  String get name => _match.group(5)!;
 
   /// The actual log message.
-  String get message => _match.group(4)!;
+  String get message => _match.group(6)!;
+
+  String toDebugString() {
+    return 'AdbLogLine(time: $time, process: $process, thread: $thread, severity: $severity, name: $name, message: $message)';
+  }
 }
diff --git a/testing/scenario_app/bin/utils/logs.dart b/testing/scenario_app/bin/utils/logs.dart
index b84e812..4d883d3 100644
--- a/testing/scenario_app/bin/utils/logs.dart
+++ b/testing/scenario_app/bin/utils/logs.dart
@@ -7,6 +7,7 @@
 bool _supportsAnsi = stdout.supportsAnsiEscapes;
 String _green = _supportsAnsi ? '\u001b[1;32m' : '';
 String _red = _supportsAnsi ? '\u001b[31m' : '';
+String _yellow = _supportsAnsi ? '\u001b[33m' : '';
 String _gray = _supportsAnsi ? '\u001b[90m' : '';
 String _reset = _supportsAnsi? '\u001B[0m' : '';
 
@@ -22,15 +23,27 @@
   }
 }
 
+void _logWithColor(String color, String msg) {
+  stdout.writeln('$color$msg$_reset');
+}
+
 void log(String msg) {
-  stdout.writeln('$_gray$msg$_reset');
+  _logWithColor(_gray, msg);
+}
+
+void logImportant(String msg) {
+  stdout.writeln(msg);
+}
+
+void logWarning(String msg) {
+  _logWithColor(_yellow, msg);
 }
 
 final class Panic extends Error {}
 
 Never panic(List<String> messages) {
   for (final String message in messages) {
-    stderr.writeln('$_red$message$_reset');
+    _logWithColor(_red, message);
   }
   throw Panic();
 }
diff --git a/testing/scenario_app/tool/logcat_reader.dart b/testing/scenario_app/tool/logcat_reader.dart
new file mode 100644
index 0000000..909204a
--- /dev/null
+++ b/testing/scenario_app/tool/logcat_reader.dart
@@ -0,0 +1,43 @@
+// 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.
+
+import 'dart:io' as io;
+
+// It's bad to import a file from `bin` into `tool`.
+// However this tool is not very important, so delete it if necessary.
+import '../bin/utils/adb_logcat_filtering.dart';
+
+/// A tiny tool to read saved `adb logcat` output and perform some analysis.
+///
+/// This tool is not meant to be a full-fledged logcat reader. It's just a
+/// simple tool that uses the [AdbLogLine] extension type to parse results of
+/// `adb logcat` and explain what log tag names are most common.
+void main(List<String> args) {
+  if (args case [final String path]) {
+    final List<AdbLogLine> parsed = io.File(path)
+        .readAsLinesSync()
+        .map(AdbLogLine.tryParse)
+        .whereType<AdbLogLine>()
+          // Filter out all debug logs.
+        .where((AdbLogLine line) => line.severity != 'D')
+        .toList();
+
+    final Map<String, int> tagCounts = <String, int>{};
+    for (final AdbLogLine line in parsed) {
+      tagCounts[line.name] = (tagCounts[line.name] ?? 0) + 1;
+    }
+
+    // Print in order of most common to least common.
+    final List<MapEntry<String, int>> sorted = tagCounts.entries.toList()
+      ..sort((MapEntry<String, int> a, MapEntry<String, int> b) => b.value.compareTo(a.value));
+    for (final MapEntry<String, int> entry in sorted) {
+      print("'${entry.key}', // ${entry.value}");
+    }
+
+    return;
+  }
+
+  print('Usage: logcat_reader.dart <path-to-logcat-output>');
+  io.exitCode = 1;
+}