Merge "Add go/a-update link/banner to adevice" into main
diff --git a/Android.bp b/Android.bp
index 60c8327..67f7510 100644
--- a/Android.bp
+++ b/Android.bp
@@ -57,3 +57,11 @@
         canonical_path_from_root: false,
     },
 }
+
+filegroup {
+    name: "adte-owners-files",
+    srcs: [
+        "OWNERS_ADTE_TEAM",
+        "OWNERS",
+    ],
+}
diff --git a/OWNERS_ADTE_TEAM b/OWNERS_ADTE_TEAM
index 76e26bc..12ca660 100644
--- a/OWNERS_ADTE_TEAM
+++ b/OWNERS_ADTE_TEAM
@@ -2,6 +2,7 @@
 davidjames@google.com
 hwj@google.com
 hzalek@google.com
+ihcinihsdk@google.com
 kevindagostino@google.com
 liuyg@google.com
 lucafarsi@google.com
diff --git a/atest/Android.bp b/atest/Android.bp
index 7337b1d..c5fda1f 100644
--- a/atest/Android.bp
+++ b/atest/Android.bp
@@ -68,6 +68,7 @@
     defaults: ["atest_binary_defaults"],
     main: "atest_main.py",
     data: [
+        ":adte-owners-files",
         ":atest_flag_list_for_completion",
         ":atest_log_uploader",
     ],
diff --git a/atest/arg_parser.py b/atest/arg_parser.py
index 3250525..05d7720 100644
--- a/atest/arg_parser.py
+++ b/atest/arg_parser.py
@@ -20,6 +20,7 @@
 
 from atest import bazel_mode
 from atest import constants
+from atest import rollout_control
 from atest.atest_utils import BuildOutputMode
 
 
@@ -119,7 +120,7 @@
   )
   parser.add_argument(
       '--bazel-mode',
-      default=True,
+      default=not rollout_control.disable_bazel_mode_by_default.is_enabled(),
       action='store_true',
       help='Run tests using Bazel (default: True).',
   )
diff --git a/atest/atest_enum.py b/atest/atest_enum.py
index 33b9861..d44fdf2 100644
--- a/atest/atest_enum.py
+++ b/atest/atest_enum.py
@@ -117,6 +117,10 @@
   IS_PLOCATEDB_LOCKED = 63
   # Device update duration
   DEVICE_UPDATE_MS = 64
+  # The ID of the feature that is controlled by rollout control. Positive value
+  # means the feature is enabled, negative value means disabled.
+  ROLLOUT_CONTROLLED_FEATURE_ID = 65
+  ROLLOUT_CONTROLLED_FEATURE_ID_OVERRIDE = 66
 
 
 @unique
diff --git a/atest/atest_utils.py b/atest/atest_utils.py
index a893d60..d50e573 100644
--- a/atest/atest_utils.py
+++ b/atest/atest_utils.py
@@ -27,7 +27,9 @@
 import fnmatch
 import hashlib
 import html
-import importlib
+import importlib.resources
+import importlib.util
+import io
 import itertools
 import json
 import logging
@@ -40,6 +42,7 @@
 import shutil
 import subprocess
 import sys
+import threading
 from threading import Thread
 import traceback
 from typing import Any, Dict, IO, List, Set, Tuple
@@ -54,7 +57,7 @@
 from atest.metrics import metrics_utils
 from atest.tf_proto import test_record_pb2
 
-_BUILD_OUTPUT_ROLLING_LINES = 6
+DEFAULT_OUTPUT_ROLLING_LINES = 6
 _BASH_CLEAR_PREVIOUS_LINE_CODE = '\033[F\033[K'
 _BASH_RESET_CODE = '\033[0m'
 DIST_OUT_DIR = Path(
@@ -270,44 +273,113 @@
   return output
 
 
-def _stream_io_output(io_input: IO, io_output: IO, max_lines=None):
+def stream_io_output(
+    io_input: IO,
+    max_lines=None,
+    full_output_receiver: IO = None,
+    io_output: IO = None,
+    is_io_output_atty=None,
+):
   """Stream an IO output with max number of rolling lines to display if set.
 
   Args:
       input: The file-like object to read the output from.
-      output: The file-like object to write the output to.
       max_lines: The maximum number of rolling lines to display. If None, all
         lines will be displayed.
+      full_output_receiver: Optional io to receive the full output.
+      io_output: The file-like object to write the output to.
+      is_io_output_atty: Whether the io_output is a TTY.
   """
-  print('\n----------------------------------------------------')
-  term_width, _ = get_terminal_size()
-  full_output = []
-  last_lines = None if not max_lines else deque(maxlen=max_lines)
-  last_number_of_lines = 0
-  for line in iter(io_input.readline, ''):
-    full_output.append(line)
-    line = line.rstrip()
-    if last_lines is None:
+  if io_output is None:
+    io_output = _original_sys_stdout
+  if is_io_output_atty is None:
+    is_io_output_atty = _has_colors(io_output)
+  if not max_lines or not is_io_output_atty:
+    for line in iter(io_input.readline, ''):
+      if not line:
+        break
+      if full_output_receiver is not None:
+        full_output_receiver.write(
+            line if isinstance(line, str) else line.decode('utf-8')
+        )
       io_output.write(line)
-      io_output.write('\n')
       io_output.flush()
-      continue
+    return
+
+  term_width, _ = get_terminal_size()
+  last_lines = deque(maxlen=max_lines)
+  is_rolling = True
+
+  def reset_output():
+    if is_rolling and last_lines:
+      io_output.write(_BASH_CLEAR_PREVIOUS_LINE_CODE * (len(last_lines) + 2))
+
+  def write_output(new_lines: list[str]):
+    if not is_rolling:
+      return
+    last_lines.extend(new_lines)
+    lines = ['========== Rolling subprocess output ==========']
+    lines.extend(last_lines)
+    lines.append('-----------------------------------------------')
+    io_output.write('\n'.join(lines))
+    io_output.write('\n')
+    io_output.flush()
+
+  original_stdout = sys.stdout
+  original_stderr = sys.stderr
+
+  lock = threading.Lock()
+
+  class SafeStdout:
+
+    def __init__(self):
+      self._buffers = []
+
+    def write(self, buf: str) -> None:
+      if len(buf) == 1 and buf[0] == '\n' and self._buffers:
+        with lock:
+          reset_output()
+          original_stdout.write(''.join(self._buffers))
+          original_stdout.write('\n')
+          original_stdout.flush()
+          write_output([])
+          self._buffers.clear()
+      else:
+        self._buffers.append(buf)
+
+    def flush(self) -> None:
+      original_stdout.flush()
+
+  sys.stdout = SafeStdout()
+  sys.stderr = sys.stdout
+
+  for line in iter(io_input.readline, ''):
+    if not line:
+      break
+    line = line.decode('utf-8') if isinstance(line, bytes) else line
+    if full_output_receiver is not None:
+      full_output_receiver.write(line)
+    line = line.rstrip().replace('\t', '  ')
     # Split the line if it's longer than the terminal width
     wrapped_lines = (
         [line]
         if len(line) <= term_width
         else [line[i : i + term_width] for i in range(0, len(line), term_width)]
     )
-    last_lines.extend(wrapped_lines)
-    io_output.write(_BASH_CLEAR_PREVIOUS_LINE_CODE * last_number_of_lines)
-    io_output.write('\n'.join(last_lines))
-    io_output.write('\n')
+    with lock:
+      reset_output()
+      write_output(wrapped_lines)
+
+  with lock:
+    reset_output()
+    is_rolling = False
+    io_output.write(_BASH_RESET_CODE)
     io_output.flush()
-    last_number_of_lines = len(last_lines)
+
+  sys.stdout = original_stdout
+  sys.stderr = original_stderr
+
   io_input.close()
-  io_output.write(_BASH_RESET_CODE)
-  io_output.flush()
-  print('----------------------------------------------------')
 
 
 def run_limited_output(
@@ -336,12 +408,18 @@
       start_new_session=start_new_session,
       text=True,
   ) as proc:
-    _stream_io_output(
-        proc.stdout, _original_sys_stdout, _BUILD_OUTPUT_ROLLING_LINES
+    full_output_receiver = io.StringIO()
+    stream_io_output(
+        proc.stdout,
+        DEFAULT_OUTPUT_ROLLING_LINES,
+        full_output_receiver,
+        _original_sys_stdout,
     )
     returncode = proc.wait()
     if returncode:
-      raise subprocess.CalledProcessError(returncode, cmd, full_output)
+      raise subprocess.CalledProcessError(
+          returncode, cmd, full_output_receiver.getvalue()
+      )
 
 
 def get_build_out_dir(*joinpaths) -> Path:
@@ -533,6 +611,11 @@
   return all((len(args.tests) == 1, args.tests[0][0] == ':'))
 
 
+def is_atty_terminal() -> bool:
+  """Check if the current process is running in a TTY."""
+  return getattr(_original_sys_stdout, 'isatty', lambda: False)()
+
+
 def _has_colors(stream):
   """Check the output stream is colorful.
 
@@ -852,7 +935,7 @@
   # Save test_info to files.
   try:
     with open(cache_path, 'wb') as test_info_cache_file:
-      logging.debug('Saving cache %s.', cache_path)
+      logging.debug('Saving cache for %s as %s.', test_reference, cache_path)
       pickle.dump(test_infos, test_info_cache_file, protocol=2)
   except (pickle.PicklingError, TypeError, IOError) as err:
     # Won't break anything, just log this error, and collect the exception
@@ -876,7 +959,7 @@
 
   cache_file = get_test_info_cache_path(test_reference, cache_root)
   if os.path.isfile(cache_file):
-    logging.debug('Loading cache %s.', cache_file)
+    logging.debug('Loading cache %s from %s.', test_reference, cache_file)
     try:
       with open(cache_file, 'rb') as config_dictionary_file:
         return pickle.load(config_dictionary_file, encoding='utf-8')
diff --git a/atest/atest_utils_unittest.py b/atest/atest_utils_unittest.py
index 0b8d6a3..a3eac85 100755
--- a/atest/atest_utils_unittest.py
+++ b/atest/atest_utils_unittest.py
@@ -66,6 +66,7 @@
 ----------------------------
 """
 
+
 class StreamIoOutputTest(unittest.TestCase):
   """Class that tests the _stream_io_output function."""
 
@@ -76,9 +77,13 @@
     io_input.seek(0)
     io_output = StringIO()
 
-    atest_utils._stream_io_output(io_input, io_output, max_lines=None)
+    atest_utils.stream_io_output(
+        io_input, max_lines=None, io_output=io_output, is_io_output_atty=True
+    )
 
-    self.assertNotIn(atest_utils._BASH_CLEAR_PREVIOUS_LINE_CODE, io_output.getvalue())
+    self.assertNotIn(
+        atest_utils._BASH_CLEAR_PREVIOUS_LINE_CODE, io_output.getvalue()
+    )
 
   @mock.patch.object(atest_utils, 'get_terminal_size', return_value=(5, -1))
   def test_stream_io_output_wrap_long_lines(self, _):
@@ -88,7 +93,9 @@
     io_input.seek(0)
     io_output = StringIO()
 
-    atest_utils._stream_io_output(io_input, io_output, max_lines=10)
+    atest_utils.stream_io_output(
+        io_input, max_lines=10, io_output=io_output, is_io_output_atty=True
+    )
 
     self.assertIn('11111\n11111', io_output.getvalue())
 
@@ -100,10 +107,16 @@
     io_input.seek(0)
     io_output = StringIO()
 
-    atest_utils._stream_io_output(io_input, io_output, max_lines=2)
+    atest_utils.stream_io_output(
+        io_input, max_lines=2, io_output=io_output, is_io_output_atty=True
+    )
 
     self.assertIn(
-        atest_utils._BASH_CLEAR_PREVIOUS_LINE_CODE * 2 + '2\n3\n',
+        '2\n3\n',
+        io_output.getvalue(),
+    )
+    self.assertNotIn(
+        '1\n2\n3\n',
         io_output.getvalue(),
     )
 
@@ -115,10 +128,44 @@
     io_input.seek(0)
     io_output = StringIO()
 
-    atest_utils._stream_io_output(io_input, io_output, max_lines=4)
+    atest_utils.stream_io_output(
+        io_input, max_lines=4, io_output=io_output, is_io_output_atty=True
+    )
 
     self.assertIn(
-        atest_utils._BASH_CLEAR_PREVIOUS_LINE_CODE * 2 + '1\n2\n3\n',
+        '1\n2\n3\n',
+        io_output.getvalue(),
+    )
+
+  @mock.patch.object(atest_utils, 'get_terminal_size', return_value=(5, -1))
+  def test_stream_io_output_no_lines_written_no_lines_cleared(self, _):
+    """Test when nothing is written, no lines are cleared."""
+    io_input = StringIO()
+    io_output = StringIO()
+
+    atest_utils.stream_io_output(
+        io_input, max_lines=2, io_output=io_output, is_io_output_atty=True
+    )
+
+    self.assertNotIn(
+        atest_utils._BASH_CLEAR_PREVIOUS_LINE_CODE,
+        io_output.getvalue(),
+    )
+
+  @mock.patch.object(atest_utils, 'get_terminal_size', return_value=(5, -1))
+  def test_stream_io_output_replace_tab_with_spaces(self, _):
+    """Test when line exceeds max_lines, the previous lines are cleared."""
+    io_input = StringIO()
+    io_input.write('1\t2')
+    io_input.seek(0)
+    io_output = StringIO()
+
+    atest_utils.stream_io_output(
+        io_input, max_lines=2, io_output=io_output, is_io_output_atty=True
+    )
+
+    self.assertNotIn(
+        '\t',
         io_output.getvalue(),
     )
 
diff --git a/atest/bazel/resources/rules/tradefed_test.bzl b/atest/bazel/resources/rules/tradefed_test.bzl
index eca0fc1..7fbc750 100644
--- a/atest/bazel/resources/rules/tradefed_test.bzl
+++ b/atest/bazel/resources/rules/tradefed_test.bzl
@@ -14,10 +14,6 @@
 
 """Rules used to run tests using Tradefed."""
 
-load("//bazel/rules:platform_transitions.bzl", "device_transition", "host_transition")
-load("//bazel/rules:tradefed_test_aspects.bzl", "soong_prebuilt_tradefed_test_aspect")
-load("//bazel/rules:tradefed_test_dependency_info.bzl", "TradefedTestDependencyInfo")
-load("//bazel/rules:common_settings.bzl", "BuildSettingInfo")
 load(
     "//:constants.bzl",
     "aapt2_label",
@@ -32,7 +28,11 @@
     "tradefed_test_framework_label",
     "vts_core_tradefed_harness_label",
 )
+load("//bazel/rules:common_settings.bzl", "BuildSettingInfo")
 load("//bazel/rules:device_test.bzl", "device_test")
+load("//bazel/rules:platform_transitions.bzl", "device_transition", "host_transition")
+load("//bazel/rules:tradefed_test_aspects.bzl", "soong_prebuilt_tradefed_test_aspect")
+load("//bazel/rules:tradefed_test_dependency_info.bzl", "TradefedTestDependencyInfo")
 
 TradefedTestInfo = provider(
     doc = "Info about a Tradefed test module",
@@ -381,7 +381,7 @@
     # Since `vts-core-tradefed-harness` includes `compatibility-tradefed`, we
     # will exclude `compatibility-tradefed` if `vts-core-tradefed-harness` exists.
     if vts_core_tradefed_harness_label in all_tradefed_deps:
-        all_tradefed_deps.pop(compatibility_tradefed_label, default = None)
+        all_tradefed_deps.pop(compatibility_tradefed_label)
 
     return all_tradefed_deps.keys()
 
@@ -424,18 +424,16 @@
 
 def _configure_python_toolchain(ctx):
     py_toolchain_info = ctx.toolchains[_PY_TOOLCHAIN]
-    py2_interpreter = py_toolchain_info.py2_runtime.interpreter
     py3_interpreter = py_toolchain_info.py3_runtime.interpreter
 
     # Create `python` and `python3` symlinks in the runfiles tree and add them
     # to the executable path. This is required because scripts reference these
     # commands in their shebang line.
     py_runfiles = ctx.runfiles(symlinks = {
-        "/".join([py2_interpreter.dirname, "python"]): py2_interpreter,
+        "/".join([py3_interpreter.dirname, "python"]): py3_interpreter,
         "/".join([py3_interpreter.dirname, "python3"]): py3_interpreter,
     })
     py_paths = [
-        _BAZEL_WORK_DIR + py2_interpreter.dirname,
         _BAZEL_WORK_DIR + py3_interpreter.dirname,
     ]
     return (py_paths, py_runfiles)
diff --git a/atest/integration_tests/atest_dry_run_diff_tests.py b/atest/integration_tests/atest_dry_run_diff_tests.py
index 5031b5c..3bab945 100644
--- a/atest/integration_tests/atest_dry_run_diff_tests.py
+++ b/atest/integration_tests/atest_dry_run_diff_tests.py
@@ -154,7 +154,7 @@
             map(
                 lambda result: result.get_atest_log_values_from_prefix(
                     atest_integration_test.DRY_RUN_COMMAND_LOG_PREFIX
-                )[0],
+                ),
                 cmd_results_prod,
             )
         ),
@@ -165,7 +165,7 @@
             map(
                 lambda result: result.get_atest_log_values_from_prefix(
                     atest_integration_test.DRY_RUN_COMMAND_LOG_PREFIX
-                )[0],
+                ),
                 cmd_results_dev,
             )
         ),
@@ -209,21 +209,29 @@
       with self.subTest(
           name=f'{usages[idx].command}_runner_cmd_has_same_elements'
       ):
-        sanitized_runner_cmd_prod = (
-            atest_integration_test.sanitize_runner_command(runner_cmd_prod[idx])
-        )
-        sanitized_runner_cmd_dev = (
-            atest_integration_test.sanitize_runner_command(runner_cmd_dev[idx])
-        )
         self.assertEqual(
-            set(sanitized_runner_cmd_prod.split(' ')),
-            set(sanitized_runner_cmd_dev.split(' ')),
-            'Runner command mismatch for command:'
-            f' {usages[idx].command}.\nProd:\n'
-            f' {sanitized_runner_cmd_prod}\nDev:\n{sanitized_runner_cmd_dev}\n'
-            f' {impact_str}',
+            len(runner_cmd_prod[idx]),
+            len(runner_cmd_dev[idx]),
+            'Nummber of runner commands mismatch for command:'
+            ' {usages[idx].command}.',
         )
 
+        for cmd_idx in range(len(runner_cmd_prod[idx])):
+          sanitized_runner_cmd_prod = (
+              atest_integration_test.sanitize_runner_command(runner_cmd_prod[idx][cmd_idx])
+          )
+          sanitized_runner_cmd_dev = (
+              atest_integration_test.sanitize_runner_command(runner_cmd_dev[idx][cmd_idx])
+          )
+          self.assertEqual(
+              set(sanitized_runner_cmd_prod.split(' ')),
+              set(sanitized_runner_cmd_dev.split(' ')),
+              'Runner command mismatch for command:'
+              f' {usages[idx].command}.\nProd:\n'
+              f' {sanitized_runner_cmd_prod}\nDev:\n{sanitized_runner_cmd_dev}\n'
+              f' {impact_str}',
+          )
+
 
 # A copy of the list of atest commands tested in the command verification tests.
 _default_input_commands = [
diff --git a/atest/rollout_control.py b/atest/rollout_control.py
new file mode 100644
index 0000000..69d3421
--- /dev/null
+++ b/atest/rollout_control.py
@@ -0,0 +1,191 @@
+#!/usr/bin/env python3
+# Copyright 2024, The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Rollout control for Atest features."""
+
+import functools
+import getpass
+import hashlib
+import importlib.resources
+import logging
+import os
+from atest import atest_enum
+from atest.metrics import metrics
+
+
+@functools.cache
+def _get_project_owners() -> list[str]:
+  """Returns the owners of the feature."""
+  owners = []
+  try:
+    with importlib.resources.as_file(
+        importlib.resources.files('atest').joinpath('OWNERS')
+    ) as version_file_path:
+      owners.extend(version_file_path.read_text(encoding='utf-8').splitlines())
+  except (ModuleNotFoundError, FileNotFoundError) as e:
+    logging.error(e)
+  try:
+    with importlib.resources.as_file(
+        importlib.resources.files('atest').joinpath('OWNERS_ADTE_TEAM')
+    ) as version_file_path:
+      owners.extend(version_file_path.read_text(encoding='utf-8').splitlines())
+  except (ModuleNotFoundError, FileNotFoundError) as e:
+    logging.error(e)
+  return [line.split('@')[0] for line in owners if '@google.com' in line]
+
+
+class RolloutControlledFeature:
+  """Base class for Atest features under rollout control."""
+
+  def __init__(
+      self,
+      name: str,
+      rollout_percentage: float,
+      env_control_flag: str,
+      feature_id: int = None,
+      owners: list[str] | None = None,
+  ):
+    """Initializes the object.
+
+    Args:
+        name: The name of the feature.
+        rollout_percentage: The percentage of users to enable the feature for.
+          The value should be in [0, 100].
+        env_control_flag: The environment variable name to override the feature
+          enablement. When set, 'true' or '1' means enable, other values means
+          disable.
+        feature_id: The ID of the feature that is controlled by rollout control
+          for metric collection purpose. Must be a positive integer.
+        owners: The owners of the feature. If not provided, the owners of the
+          feature will be read from OWNERS file.
+    """
+    if rollout_percentage < 0 or rollout_percentage > 100:
+      raise ValueError(
+          'Rollout percentage must be in [0, 100]. Got %s instead.'
+          % rollout_percentage
+      )
+    if feature_id is not None and feature_id <= 0:
+      raise ValueError(
+          'Feature ID must be a positive integer. Got %s instead.' % feature_id
+      )
+    if owners is None:
+      owners = _get_project_owners()
+    self._name = name
+    self._rollout_percentage = rollout_percentage
+    self._env_control_flag = env_control_flag
+    self._feature_id = feature_id
+    self._owners = owners
+
+  def _check_env_control_flag(self) -> bool | None:
+    """Checks the environment variable to override the feature enablement.
+
+    Returns:
+        True if the feature is enabled, False if disabled, None if not set.
+    """
+    if self._env_control_flag not in os.environ:
+      return None
+    return os.environ[self._env_control_flag] in ('TRUE', 'True', 'true', '1')
+
+  @functools.cache
+  def is_enabled(self, username: str | None = None) -> bool:
+    """Checks whether the current feature is enabled for the user.
+
+    Args:
+        username: The username to check the feature enablement for. If not
+          provided, the current user's username will be used.
+
+    Returns:
+        True if the feature is enabled for the user, False otherwise.
+    """
+    override_flag_value = self._check_env_control_flag()
+    if override_flag_value is not None:
+      logging.debug(
+          'Feature %s is %s by env variable %s.',
+          self._name,
+          'enabled' if override_flag_value else 'disabled',
+          self._env_control_flag,
+      )
+      if self._feature_id:
+        metrics.LocalDetectEvent(
+            detect_type=atest_enum.DetectType.ROLLOUT_CONTROLLED_FEATURE_ID_OVERRIDE,
+            result=self._feature_id
+            if override_flag_value
+            else -self._feature_id,
+        )
+      return override_flag_value
+
+    if self._rollout_percentage == 100:
+      return True
+
+    if username is None:
+      username = getpass.getuser()
+
+    if not username:
+      logging.debug(
+          'Unable to determine the username. Disabling the feature %s.',
+          self._name,
+      )
+      return False
+
+    is_enabled = username in self._owners
+
+    if not is_enabled:
+      if self._rollout_percentage == 0:
+        return False
+
+      hash_object = hashlib.sha256()
+      hash_object.update((username + ' ' + self._name).encode('utf-8'))
+      is_enabled = (
+          int(hash_object.hexdigest(), 16) % 100 < self._rollout_percentage
+      )
+
+    logging.debug(
+        'Feature %s is %s for user %s.',
+        self._name,
+        'enabled' if is_enabled else 'disabled',
+        username,
+    )
+
+    if self._feature_id:
+      metrics.LocalDetectEvent(
+          detect_type=atest_enum.DetectType.ROLLOUT_CONTROLLED_FEATURE_ID,
+          result=self._feature_id if is_enabled else -self._feature_id,
+      )
+
+    if is_enabled and self._rollout_percentage < 100:
+      print(
+          '\nYou are among the first %s%% of users to receive the feature "%s".'
+          ' If you experience any issues, you can disable the feature by'
+          ' setting the environment variable "%s" to false, and the atest team'
+          ' will be notified.\n'
+          % (self._rollout_percentage, self._name, self._env_control_flag)
+      )
+
+    return is_enabled
+
+
+disable_bazel_mode_by_default = RolloutControlledFeature(
+    name='Bazel mode disabled by default',
+    rollout_percentage=5,
+    env_control_flag='DISABLE_BAZEL_MODE_BY_DEFAULT',
+    feature_id=1,
+)
+
+rolling_tf_subprocess_output = RolloutControlledFeature(
+    name='Rolling TradeFed subprocess output',
+    rollout_percentage=5,
+    env_control_flag='ROLLING_TF_SUBPROCESS_OUTPUT',
+    feature_id=2,
+)
diff --git a/atest/rollout_control_unittest.py b/atest/rollout_control_unittest.py
new file mode 100644
index 0000000..ca000d0
--- /dev/null
+++ b/atest/rollout_control_unittest.py
@@ -0,0 +1,104 @@
+#!/usr/bin/env python3
+# Copyright 2024, The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import unittest
+from unittest import mock
+from atest import rollout_control
+
+
+class RolloutControlledFeatureUnittests(unittest.TestCase):
+
+  def test_is_enabled_username_hash_is_greater_than_rollout_percentage_returns_false(
+      self,
+  ):
+    sut = rollout_control.RolloutControlledFeature(
+        name='test_feature',
+        rollout_percentage=66,
+        env_control_flag='TEST_FEATURE',
+    )
+
+    self.assertFalse(sut.is_enabled('username'))
+
+  def test_is_enabled_username_hash_is_equal_to_rollout_percentage_returns_false(
+      self,
+  ):
+    sut = rollout_control.RolloutControlledFeature(
+        name='test_feature',
+        rollout_percentage=67,
+        env_control_flag='TEST_FEATURE',
+    )
+
+    self.assertFalse(sut.is_enabled('username'))
+
+  def test_is_enabled_username_hash_is_less_or_equal_than_rollout_percentage_returns_true(
+      self,
+  ):
+    sut = rollout_control.RolloutControlledFeature(
+        name='test_feature',
+        rollout_percentage=68,
+        env_control_flag='TEST_FEATURE',
+    )
+
+    self.assertTrue(sut.is_enabled('username'))
+
+  def test_is_enabled_username_undetermined_returns_false(self):
+    sut = rollout_control.RolloutControlledFeature(
+        name='test_feature',
+        rollout_percentage=99,
+        env_control_flag='TEST_FEATURE',
+    )
+
+    self.assertFalse(sut.is_enabled(''))
+
+  def test_is_enabled_flag_set_to_true_returns_true(self):
+    sut = rollout_control.RolloutControlledFeature(
+        name='test_feature',
+        rollout_percentage=0,
+        env_control_flag='TEST_FEATURE',
+    )
+
+    with mock.patch.dict('os.environ', {'TEST_FEATURE': 'true'}):
+      self.assertTrue(sut.is_enabled())
+
+  def test_is_enabled_flag_set_to_1_returns_true(self):
+    sut = rollout_control.RolloutControlledFeature(
+        name='test_feature',
+        rollout_percentage=0,
+        env_control_flag='TEST_FEATURE',
+    )
+
+    with mock.patch.dict('os.environ', {'TEST_FEATURE': '1'}):
+      self.assertTrue(sut.is_enabled())
+
+  def test_is_enabled_flag_set_to_false_returns_false(self):
+    sut = rollout_control.RolloutControlledFeature(
+        name='test_feature',
+        rollout_percentage=100,
+        env_control_flag='TEST_FEATURE',
+    )
+
+    with mock.patch.dict('os.environ', {'TEST_FEATURE': 'false'}):
+      self.assertFalse(sut.is_enabled())
+
+  def test_is_enabled_is_owner_returns_true(self):
+    sut = rollout_control.RolloutControlledFeature(
+        name='test_feature',
+        rollout_percentage=0,
+        env_control_flag='TEST_FEATURE',
+        owners=['owner_name'],
+    )
+
+    self.assertFalse(sut.is_enabled('name'))
+    self.assertTrue(sut.is_enabled('owner_name'))
diff --git a/atest/test_finders/module_finder.py b/atest/test_finders/module_finder.py
index 7775668..bbb0d06 100644
--- a/atest/test_finders/module_finder.py
+++ b/atest/test_finders/module_finder.py
@@ -107,7 +107,7 @@
     Args:
         test: TestInfo to update with vts10 specific details.
 
-    Return:
+    Returns:
         TestInfo that is ready for the vts10 test runner.
     """
     test.test_runner = self._VTS_TEST_RUNNER
@@ -197,7 +197,7 @@
     Args:
         test: TestInfo that has been filled out by a find method.
 
-    Return:
+    Returns:
         TestInfo that has been modified as needed and return None if
         this module can't be found in the module_info.
     """
@@ -237,7 +237,9 @@
       logging.debug(
           'Add %s to build targets...', ', '.join(artifact_map.keys())
       )
-      test.artifacts = [apk for p in artifact_map.values() for apk in p]
+      test.artifacts = []
+      for p in artifact_map.values():
+        test.artifacts += p
       logging.debug('Will install target APK: %s\n', test.artifacts)
       metrics.LocalDetectEvent(
           detect_type=DetectType.FOUND_TARGET_ARTIFACTS,
@@ -292,11 +294,6 @@
     for module_path in self.module_info.get_paths(module_name):
       mod_dir = module_path.replace('/', '-')
       targets.add(constants.MODULES_IN + mod_dir)
-    # (b/156457698) Force add vts_kernel_ltp_tests as build target if our
-    # test belongs to REQUIRED_LTP_TEST_MODULES due to required_module
-    # option not working for sh_test in soong.
-    if module_name in constants.REQUIRED_LTP_TEST_MODULES:
-      targets.add('vts_kernel_ltp_tests')
     # (b/184567849) Force adding module_name as a build_target. This will
     # allow excluding MODULES-IN-* and prevent from missing build targets.
     if module_name and self.module_info.is_module(module_name):
@@ -345,14 +342,17 @@
       # Double check if below section is needed.
       if (
           not self.module_info.is_auto_gen_test_config(module_name)
-          and len(test_configs) > 0
+          and test_configs
       ):
         return test_configs
     return [rel_config] if rel_config else []
 
   # pylint: disable=too-many-branches
   # pylint: disable=too-many-locals
-  def _get_test_info_filter(self, path, methods, **kwargs):
+  def _get_test_info_filter(
+      self, path, methods, rel_module_dir=None, class_name=None,
+      is_native_test=False
+  ):
     """Get test info filter.
 
     Args:
@@ -361,20 +361,21 @@
         rel_module_dir: Optional. A string of the module dir no-absolute to
           root.
         class_name: Optional. A string of the class name.
-        is_native_test: Optional. A boolean variable of whether to search for a
-          native test or not.
+        is_native_test: Optional. A boolean variable of whether to search for
+          a native test or not.
 
     Returns:
         A set of test info filter.
     """
     _, file_name = test_finder_utils.get_dir_path_and_filename(path)
     ti_filter = frozenset()
-    if os.path.isfile(path) and kwargs.get('is_native_test', None):
+    if os.path.isfile(path) and is_native_test:
       class_info = test_finder_utils.get_cc_class_info(path)
       ti_filter = frozenset([
           test_info.TestFilter(
               test_filter_utils.get_cc_filter(
-                  class_info, kwargs.get('class_name', '*'), methods
+                  class_info,
+                  class_name if class_name is not None else '*', methods
               ),
               frozenset(),
           )
@@ -404,14 +405,13 @@
         )
       ti_filter = frozenset(cc_filters)
     # If input path is a folder and have class_name information.
-    elif not file_name and kwargs.get('class_name', None):
+    elif not file_name and class_name:
       ti_filter = frozenset(
-          [test_info.TestFilter(kwargs.get('class_name', None), methods)]
+          [test_info.TestFilter(class_name, methods)]
       )
     # Path to non-module dir, treat as package.
-    elif not file_name and kwargs.get(
-        'rel_module_dir', None
-    ) != os.path.relpath(path, self.root_dir):
+    elif not file_name and rel_module_dir != os.path.relpath(
+        path, self.root_dir):
       dir_items = [os.path.join(path, f) for f in os.listdir(path)]
       for dir_item in dir_items:
         if constants.JAVA_EXT_RE.match(dir_item):
@@ -784,7 +784,7 @@
     Args:
         package: A string of the package name.
         module_name: Optional. A string of the module name.
-        ref_config: Optional. A string of rel path of config.
+        rel_config: Optional. A string of rel path of config.
 
     Returns:
         A list of populated TestInfo namedtuple if found, else None.
@@ -868,7 +868,6 @@
     """
     logging.debug('Finding test by path: %s', rel_path)
     path, methods = test_filter_utils.split_methods(rel_path)
-    # TODO: See if this can be generalized and shared with methods above
     # create absolute path from cwd and remove symbolic links
     path = os.path.realpath(path)
     if not os.path.exists(path):
@@ -1026,16 +1025,16 @@
     Args:
         user_input: the target module name for fuzzy searching.
 
-    Return:
+    Returns:
         A list of guessed modules.
     """
     modules_with_ld = self.get_testable_modules_with_ld(
         user_input, ld_range=constants.LD_RANGE
     )
     guessed_modules = []
-    for _distance, _module in modules_with_ld:
-      if _distance <= abs(constants.LD_RANGE):
-        guessed_modules.append(_module)
+    for distance_, module_ in modules_with_ld:
+      if distance_ <= abs(constants.LD_RANGE):
+        guessed_modules.append(module_)
     return guessed_modules
 
   def find_test_by_config_name(self, config_name):
diff --git a/atest/test_runners/atest_tf_test_runner.py b/atest/test_runners/atest_tf_test_runner.py
index f227c4a..87e1046 100644
--- a/atest/test_runners/atest_tf_test_runner.py
+++ b/atest/test_runners/atest_tf_test_runner.py
@@ -31,6 +31,7 @@
 import select
 import shutil
 import socket
+import threading
 import time
 from typing import Any, Dict, List, Set, Tuple
 
@@ -40,6 +41,7 @@
 from atest import constants
 from atest import module_info
 from atest import result_reporter
+from atest import rollout_control
 from atest.atest_enum import DetectType, ExitCode
 from atest.coverage import coverage
 from atest.logstorage import logstorage_utils
@@ -399,12 +401,26 @@
     run_cmds = self.generate_run_commands(
         test_infos, extra_args, server.getsockname()[1]
     )
+    is_rolling_output = (
+        rollout_control.rolling_tf_subprocess_output.is_enabled()
+        and not extra_args.get(constants.VERBOSE, False)
+        and atest_utils.is_atty_terminal()
+    )
+
     logging.debug('Running test: %s', run_cmds[0])
     subproc = self.run(
         run_cmds[0],
         output_to_stdout=extra_args.get(constants.VERBOSE, False),
         env_vars=self.generate_env_vars(extra_args),
+        rolling_output_lines=is_rolling_output,
     )
+
+    if is_rolling_output:
+      threading.Thread(
+          target=atest_utils.stream_io_output,
+          args=(subproc.stdout, atest_utils.DEFAULT_OUTPUT_ROLLING_LINES),
+      ).start()
+
     self.handle_subprocess(
         subproc,
         partial(self._start_monitor, server, subproc, reporter, extra_args),
diff --git a/atest/test_runners/test_runner_base.py b/atest/test_runners/test_runner_base.py
index 499cb1f..927960a 100644
--- a/atest/test_runners/test_runner_base.py
+++ b/atest/test_runners/test_runner_base.py
@@ -79,6 +79,7 @@
     """Init stuff for base class."""
     self.results_dir = results_dir
     self.test_log_file = None
+    self._subprocess_stdout = None
     if not self.NAME:
       raise atest_error.NoTestRunnerName('Class var NAME is not defined.')
     if not self.EXECUTABLE:
@@ -116,7 +117,13 @@
     """Checks whether this runner requires device update."""
     return False
 
-  def run(self, cmd, output_to_stdout=False, env_vars=None):
+  def run(
+      self,
+      cmd,
+      output_to_stdout=False,
+      env_vars=None,
+      rolling_output_lines=False,
+  ):
     """Shell out and execute command.
 
     Args:
@@ -127,20 +134,34 @@
           reporter to print the test results.  Set to True to see the output of
           the cmd. This would be appropriate for verbose runs.
         env_vars: Environment variables passed to the subprocess.
+        rolling_output_lines: If True, the subprocess output will be streamed
+          with rolling lines when output_to_stdout is False.
     """
-    if not output_to_stdout:
-      self.test_log_file = tempfile.NamedTemporaryFile(
-          mode='w', dir=self.results_dir, delete=True
-      )
     logging.debug('Executing command: %s', cmd)
-    return subprocess.Popen(
-        cmd,
-        start_new_session=True,
-        shell=True,
-        stderr=subprocess.STDOUT,
-        stdout=self.test_log_file,
-        env=env_vars,
-    )
+    if rolling_output_lines:
+      proc = subprocess.Popen(
+          cmd,
+          start_new_session=True,
+          shell=True,
+          stderr=subprocess.STDOUT,
+          stdout=None if output_to_stdout else subprocess.PIPE,
+          env=env_vars,
+      )
+      self._subprocess_stdout = proc.stdout
+      return proc
+    else:
+      if not output_to_stdout:
+        self.test_log_file = tempfile.NamedTemporaryFile(
+            mode='w', dir=self.results_dir, delete=True
+        )
+      return subprocess.Popen(
+          cmd,
+          start_new_session=True,
+          shell=True,
+          stderr=subprocess.STDOUT,
+          stdout=self.test_log_file,
+          env=env_vars,
+      )
 
   # pylint: disable=broad-except
   def handle_subprocess(self, subproc, func):
@@ -165,11 +186,15 @@
         # we have to save it above.
         logging.debug('Subproc already terminated, skipping')
       finally:
-        if self.test_log_file:
+        full_output = ''
+        if self._subprocess_stdout:
+          full_output = self._subprocess_stdout.read()
+        elif self.test_log_file:
           with open(self.test_log_file.name, 'r') as f:
-            intro_msg = 'Unexpected Issue. Raw Output:'
-            print(atest_utils.mark_red(intro_msg))
-            print(f.read())
+            full_output = f.read()
+        if full_output:
+          print(atest_utils.mark_red('Unexpected Issue. Raw Output:'))
+          print(full_output)
         # Ignore socket.recv() raising due to ctrl-c
         if not error.args or error.args[0] != errno.EINTR:
           raise error