Refactor Android `scenario_app` to remove shell entrypoint, simplify. (#50922)

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

There is still more work I want to do, like pulling args parsing into it's own class, and potentially cleanup the [golden file collection](https://github.com/flutter/flutter/issues/144047), but those seem reasonable for future (lower priority) PRs.
diff --git a/ci/builders/linux_android_emulator.json b/ci/builders/linux_android_emulator.json
index 7217a14..ad9763b 100644
--- a/ci/builders/linux_android_emulator.json
+++ b/ci/builders/linux_android_emulator.json
@@ -59,7 +59,7 @@
                     ]
                 },
                 {
-                    "language": "bash",
+                    "language": "dart",
                     "name": "Android Scenario App Integration Tests (Skia)",
                     "test_dependencies": [
                         {
@@ -74,14 +74,14 @@
                     "contexts": [
                         "android_virtual_device"
                     ],
-                    "script": "flutter/testing/scenario_app/run_android_tests.sh",
+                    "script": "flutter/testing/scenario_app/bin/run_android_tests.dart",
                     "parameters": [
-                        "android_emulator_debug_x64",
+                        "--out-dir=../out/android_emulator_debug_x64",
                         "--no-enable-impeller"
                     ]
                 },
                 {
-                    "language": "bash",
+                    "language": "dart",
                     "name": "Android Scenario App Integration Tests (Impeller/Vulkan)",
                     "test_dependencies": [
                         {
@@ -96,9 +96,9 @@
                     "contexts": [
                         "android_virtual_device"
                     ],
-                    "script": "flutter/testing/scenario_app/run_android_tests.sh",
+                    "script": "flutter/testing/scenario_app/bin/run_android_tests.dart",
                     "parameters": [
-                        "android_emulator_debug_x64",
+                        "--out-dir=../out/android_emulator_debug_x64",
                         "--enable-impeller",
                         "--impeller-backend=vulkan"
                     ]
diff --git a/ci/builders/linux_android_emulator_api_33.json b/ci/builders/linux_android_emulator_api_33.json
index 104793a..ed91c3a 100644
--- a/ci/builders/linux_android_emulator_api_33.json
+++ b/ci/builders/linux_android_emulator_api_33.json
@@ -59,7 +59,7 @@
                     ]
                 },
                 {
-                    "language": "bash",
+                    "language": "dart",
                     "name": "Scenario App Integration Tests",
                     "test_dependencies": [
                         {
@@ -74,9 +74,9 @@
                     "contexts": [
                         "android_virtual_device"
                     ],
-                    "script": "flutter/testing/scenario_app/run_android_tests.sh",
+                    "script": "flutter/testing/scenario_app/bin/run_android_tests.dart",
                     "parameters": [
-                        "android_debug_api33_x64"
+                        "--out-dir=../out/android_debug_api33_x64"
                     ]
                 }
             ]
diff --git a/ci/builders/linux_android_emulator_opengles.json b/ci/builders/linux_android_emulator_opengles.json
index 4f74f62..0f2fd3f 100644
--- a/ci/builders/linux_android_emulator_opengles.json
+++ b/ci/builders/linux_android_emulator_opengles.json
@@ -34,7 +34,7 @@
             },
             "tests": [
                 {
-                    "language": "bash",
+                    "language": "dart",
                     "name": "Android Scenario App Integration Tests (Impeller/OpenGLES)",
                     "test_dependencies": [
                         {
@@ -49,9 +49,9 @@
                     "contexts": [
                         "android_virtual_device"
                     ],
-                    "script": "flutter/testing/scenario_app/run_android_tests.sh",
+                    "script": "flutter/testing/scenario_app/bin/run_android_tests.dart",
                     "parameters": [
-                        "android_emulator_opengles_debug_x64",
+                        "--out-dir=../out/android_emulator_opengles_debug_x64",
                         "--enable-impeller",
                         "--impeller-backend=opengles"
                     ]
diff --git a/testing/scenario_app/README.md b/testing/scenario_app/README.md
index 8d866ce..3a7a2b2 100644
--- a/testing/scenario_app/README.md
+++ b/testing/scenario_app/README.md
@@ -6,17 +6,20 @@
 in conjunction with Android and iOS-specific embedding code that simulates the
 use of the engine in a real app (such as plugins and platform views).
 
-The [`run_android_tests.sh`](run_android_tests.sh) and
+The [`bin/run_android_tests.dart`](bin/run_android_tests.dart) and
 [`run_ios_tests.sh`](run_ios_tests.sh) are then used to run the tests on a
 connected device or emulator.
 
 See also:
 
+- [File an issue][file_issue] with the `e: scenario-app` label.
 - [`bin/`](bin/), the entry point for running Android integration tests.
 - [`lib/`](lib/), the Dart code and instrumentation for the scenario app.
 - [`ios/`](ios/), the iOS-side native code and tests.
 - [`android/`](android/), the Android-side native code and tests.
 
+[file_issue]: https://github.com/flutter/flutter/issues/new?labels=e:%20scenario-app,engine,platform-android,fyi-android,team-engine
+
 ## Running a smoke test on Firebase TestLab
 
 To run the smoke test on Firebase TestLab test, build `android_profile_arm64`,
@@ -41,10 +44,3 @@
 
 Then set the scenario from the Android or iOS app by calling `set_scenario` on
 platform channel `driver`.
-
-## Output validation
-
-When using `//flutter/testing/scenario_app/run_android_tests.sh` the generated
-output will be checked against a golden file at
-`//flutter/testing/scenario_app_android_output.txt` to make sure all output was
-generated. A patch will be printed to stdout if they don't match.
diff --git a/testing/scenario_app/android/README.md b/testing/scenario_app/android/README.md
index 164039a..4dd6d2f 100644
--- a/testing/scenario_app/android/README.md
+++ b/testing/scenario_app/android/README.md
@@ -5,15 +5,16 @@
 run the tests, you will need to build the engine with the appropriate
 configuration.
 
-For example, `android_debug_unopt` or `android_debug_unopt_arm64` was built,
-run:
+For example, for the latest `android` build you've made locally:
 
 ```sh
-# From the root of the engine repository
-$ ./testing/scenario_app/run_android_tests.sh android_debug_unopt
+dart ./testing/scenario_app/bin/run_android_tests.dart
+```
 
-# Or, for arm64
-$ ./testing/scenario_app/run_android_tests.sh android_debug_unopt_arm64
+Or for a specific, build, such as `android_debug_unopt_arm64`:
+
+```sh
+dart ./testing/scenario_app/bin/run_android_tests.dart --out-dir=../out/android_debug_unopt_arm64
 ```
 
 ## Debugging
@@ -28,8 +29,7 @@
 to verify the setup:
 
 ```sh
-# From the root of the engine repository
-$ ./testing/scenario_app/run_android_tests.sh android_debug_unopt_arm64 --smoke-test dev.flutter.scenarios.EngineLaunchE2ETest
+dart ./testing/scenario_app/bin/run_android_tests.dart --smoke-test dev.flutter.scenarios.EngineLaunchE2ETest
 ```
 
 The result of `adb logcat` and screenshots taken during the test will be stored
@@ -43,7 +43,7 @@
 ## CI Configuration
 
 See [`ci/builders/linux_android_emulator.json`](../../../ci/builders/linux_android_emulator.json)
-, and grep for `run_android_tests.sh`.
+, and grep for `run_android_tests.dart`.
 
 The following matrix of configurations is tested on the CI:
 
@@ -67,3 +67,9 @@
 ## Updating Gradle dependencies
 
 See [Updating the Embedding Dependencies](../../../tools/cipd/android_embedding_bundle/README.md).
+
+## Output validation
+
+The generated output will be checked against a golden file
+([`expected_golden_output.txt`](./expected_golden_output.txt)) to make sure all
+output was generated. A patch will be printed to stdout if they don't match.
diff --git a/testing/scenario_app_android_output.txt b/testing/scenario_app/android/expected_golden_output.txt
similarity index 100%
rename from testing/scenario_app_android_output.txt
rename to testing/scenario_app/android/expected_golden_output.txt
diff --git a/testing/scenario_app/bin/android_integration_tests.dart b/testing/scenario_app/bin/run_android_tests.dart
similarity index 87%
rename from testing/scenario_app/bin/android_integration_tests.dart
rename to testing/scenario_app/bin/run_android_tests.dart
index 0812f4a..f0ad827 100644
--- a/testing/scenario_app/bin/android_integration_tests.dart
+++ b/testing/scenario_app/bin/run_android_tests.dart
@@ -46,7 +46,7 @@
     )
     ..addOption(
       'adb',
-      help: 'Absolute path to the adb tool',
+      help: 'Path to the adb tool',
       defaultsTo: engine != null ? join(
         engine.srcDir.path,
         'third_party',
@@ -57,6 +57,30 @@
       ) : 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,
+    )
+    ..addOption(
       'out-dir',
       help: 'Out directory',
       defaultsTo:
@@ -68,7 +92,7 @@
     )
     ..addOption(
       'smoke-test',
-      help: 'runs a single test to verify the setup',
+      help: 'Runs a single test to verify the setup',
     )
     ..addFlag(
       'use-skia-gold',
@@ -79,8 +103,17 @@
       'enable-impeller',
       help: 'Enable Impeller for the Android app.',
     )
-    ..addOption('output-contents-golden',
-      help: 'Path to a file that will be used to check the contents of the output to make sure everything was created.',
+    ..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,
     )
     ..addOption(
       'impeller-backend',
@@ -123,6 +156,10 @@
         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?;
+      if (ndkStack == null) {
+        panic(<String>['--ndk-stack is required']);
+      }
       await _run(
         verbose: verbose,
         outDir: outDir,
@@ -133,6 +170,7 @@
         impellerBackend: impellerBackend,
         logsDir: logsDir,
         contentsGolden: contentsGolden,
+        ndkStack: ndkStack,
       );
       exit(0);
     },
@@ -172,6 +210,7 @@
   required _ImpellerBackend? impellerBackend,
   required Directory logsDir,
   required String? contentsGolden,
+  required String ndkStack,
 }) async {
   const ProcessManager pm = LocalProcessManager();
 
@@ -187,6 +226,9 @@
   final String logcatPath = join(logsDir.path, 'logcat.txt');
 
   // TODO(matanlurey): Use screenshots/ sub-directory (https://github.com/flutter/flutter/issues/143604).
+  if (!logsDir.existsSync()) {
+    logsDir.createSync(recursive: true);
+  }
   final String screenshotPath = logsDir.path;
   final String apkOutPath = join(scenarioAppPath, 'app', 'outputs', 'apk');
   final File testApk = File(join(apkOutPath, 'androidTest', 'debug', 'app-debug-androidTest.apk'));
@@ -279,6 +321,10 @@
           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':
@@ -388,6 +434,33 @@
   } finally {
     await server.close();
 
+    await step('Killing logcat process...', () async {
+      final bool delivered = logcatProcess.kill(ProcessSignal.sigkill);
+      assert(delivered);
+      await logcatProcessExitCode;
+    });
+
+    await step('Flush logcat...', () async {
+      await logcat.flush();
+      await logcat.close();
+      log('wrote logcat to $logcatPath');
+    });
+
+    await step('Symbolize stack traces', () async {
+      final ProcessResult result = await pm.run(
+        <String>[
+          ndkStack,
+          '-sym',
+          outDir.path,
+          '-dump',
+          logcatPath,
+        ],
+      );
+      if (result.exitCode != 0) {
+        panic(<String>['Failed to symbolize stack traces']);
+      }
+    });
+
     await step('Remove reverse port...', () async {
       final int exitCode = await pm.runAndForward(<String>[
         adb.path,
@@ -413,12 +486,6 @@
       }
     });
 
-    await step('Killing logcat process...', () async {
-      final bool delivered = logcatProcess.kill(ProcessSignal.sigkill);
-      assert(delivered);
-      await logcatProcessExitCode;
-    });
-
     await step('Wait for Skia gold comparisons...', () async {
       await Future.wait(pendingComparisons);
     });
@@ -426,6 +493,8 @@
     if (contentsGolden != null) {
       // Check the output here.
       await step('Check output files...', () async {
+        // TODO(matanlurey): Resolve this in a better way. On CI this file always exists.
+        File(join(screenshotPath, 'noop.txt')).writeAsStringSync('');
         // TODO(gaaclarke): We should move this into dir_contents_diff.
         _withTemporaryCwd(contentsGolden, () {
           final int exitCode = dirContentsDiff(basename(contentsGolden), screenshotPath);
@@ -435,11 +504,5 @@
         });
       });
     }
-
-    await step('Flush logcat...', () async {
-      await logcat.flush();
-      await logcat.close();
-      log('wrote logcat to $logcatPath');
-    });
   }
 }
diff --git a/testing/scenario_app/bin/utils/adb_logcat_filtering.dart b/testing/scenario_app/bin/utils/adb_logcat_filtering.dart
index def78fb..ca67ab1 100644
--- a/testing/scenario_app/bin/utils/adb_logcat_filtering.dart
+++ b/testing/scenario_app/bin/utils/adb_logcat_filtering.dart
@@ -60,6 +60,9 @@
   /// 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 process name, such as `ActivityManager`.
   String get process => _match.group(3)!;
 
diff --git a/testing/scenario_app/run_android_tests.sh b/testing/scenario_app/run_android_tests.sh
deleted file mode 100755
index 18d14a7..0000000
--- a/testing/scenario_app/run_android_tests.sh
+++ /dev/null
@@ -1,96 +0,0 @@
-#!/bin/bash
-# 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.
-
-# Runs the Android scenario tests on a connected device.
-#   To run the test on a x64 emulator, build `android_debug_unopt_x64`, and then run
-#   `./run_android_tests.sh android_debug_unopt_x64`.
-
-set -e
-
-# Check number of args.
-if [ $# -lt 1 ]; then
-  echo "Usage: $0 <variant> [flags*]"
-  exit 1
-fi
-
-# Needed because if it is set, cd may print the path it changed to.
-unset CDPATH
-
-BUILD_VARIANT=$1
-
-# On Mac OS, readlink -f doesn't work, so follow_links traverses the path one
-# link at a time, and then cds into the link destination and find out where it
-# ends up.
-#
-# The function is enclosed in a subshell to avoid changing the working directory
-# of the caller.
-function follow_links() (
-  cd -P "$(dirname -- "$1")"
-  file="$PWD/$(basename -- "$1")"
-  while [[ -L "$file" ]]; do
-    cd -P "$(dirname -- "$file")"
-    file="$(readlink -- "$file")"
-    cd -P "$(dirname -- "$file")"
-    file="$PWD/$(basename -- "$file")"
-  done
-  echo "$file"
-)
-
-SCRIPT_DIR=$(follow_links "$(dirname -- "${BASH_SOURCE[0]}")")
-SRC_DIR="$(
-  cd "$SCRIPT_DIR/../../.."
-  pwd -P
-)"
-OUT_DIR="$SRC_DIR/out/$BUILD_VARIANT"
-CONTENTS_GOLDEN="$SRC_DIR/flutter/testing/scenario_app_android_output.txt"
-
-# TODO(matanlurey): If the test runner was purely in Dart, this would not have
-# been necesesary to repeat. However my best guess is the Dart script was seen
-# as potentially crashing, so it was wrapped in a shell script. If we can change
-# this, we should.
-#
-# Define a logs directory for ADB and screenshots.
-# By default, it should be the environment variable FLUTTER_LOGS_DIR, but if
-# it's not set, use the output directory and append "scenario_app/logs".
-LOGS_DIR=${FLUTTER_LOGS_DIR:-"$OUT_DIR/scenario_app/logs"}
-
-# Create the logs directory if it doesn't exist.
-mkdir -p "$LOGS_DIR"
-
-# Dump the logcat and symbolize stack traces before exiting.
-function dumpLogcat {
-  ndkstack="windows-x86_64"
-  if [ "$(uname)" == "Darwin" ]; then
-    ndkstack="darwin-x86_64"
-  elif [ "$(expr substr $(uname -s) 1 5)" == "Linux" ]; then
-    ndkstack="linux-x86_64"
-  fi
-
-  # Get the expected location of logcat.txt.
-  logcat_file="$LOGS_DIR/logcat.txt"
-
-  echo "-> Symbolize stack traces"
-  "$SRC_DIR"/third_party/android_tools/ndk/prebuilt/"$ndkstack"/bin/ndk-stack \
-    -sym "$OUT_DIR" \
-    -dump "$logcat_file"
-  echo "<- Done"
-
-  # Output the directory for the logs.
-  echo "TIP: Full logs are in $LOGS_DIR"
-}
-
-# On error, dump the logcat and symbolize stack traces.
-trap dumpLogcat ERR
-
-cd $SCRIPT_DIR
-
-"$SRC_DIR"/third_party/dart/tools/sdks/dart-sdk/bin/dart pub get
-
-"$SRC_DIR"/third_party/dart/tools/sdks/dart-sdk/bin/dart run \
-  "$SCRIPT_DIR"/bin/android_integration_tests.dart \
-  --out-dir="$OUT_DIR" \
-  --logs-dir="$LOGS_DIR" \
-  --output-contents-golden="$CONTENTS_GOLDEN" \
-  "$@"
diff --git a/testing/scenario_app/tool/run_android_tests_smoke.sh b/testing/scenario_app/tool/run_android_tests_smoke.sh
deleted file mode 100755
index 87ab86e..0000000
--- a/testing/scenario_app/tool/run_android_tests_smoke.sh
+++ /dev/null
@@ -1,37 +0,0 @@
-#!/bin/bash
-# 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.
-
-# This is a debugging script that runs a single Android E2E test on a connected
-# device or emulator, and reports the exit code. It was largely created to debug
-# why `./testing/scenario_app/run_android_tests.sh` did or did not report
-# failures correctly.
-
-ADB="../third_party/android_tools/sdk/platform-tools/adb"
-OUT_DIR="../out/android_debug_unopt_arm64"
-SMOKE_TEST="dev.flutter.scenarios.EngineLaunchE2ETest"
-
-# Optionally skip installation if -s is passed.
-if [ "$1" != "-s" ]; then
-  # Install the app and test APKs.
-  echo "Installing app and test APKs..."
-  $ADB install -r $OUT_DIR/scenario_app/app/outputs/apk/debug/app-debug.apk
-  $ADB install -r $OUT_DIR/scenario_app/app/outputs/apk/androidTest/debug/app-debug-androidTest.apk
-fi
-
-# Configure the device for testing.
-echo "Configuring device for testing..."
-$ADB shell settings put secure immersive_mode_confirmations confirmed
-
-# Reverse port 3000 to the device.
-echo "Reversing port 3000 to the device..."
-$ADB reverse tcp:3000 tcp:3000
-
-# Run the test.
-echo "Running test..."
-$ADB shell am instrument -w -r -e class $SMOKE_TEST dev.flutter.scenarios.test/dev.flutter.TestRunner
-
-# Reverse port 3000 to the device.
-echo "Reversing port 3000 to the device..."
-$ADB reverse --remove tcp:3000