Merge "Atest: Send a FindTestFinishEvent when test found in fuzzy search."
diff --git a/atest/Android.bp b/atest/Android.bp
index 47fef47..d67972b 100644
--- a/atest/Android.bp
+++ b/atest/Android.bp
@@ -148,3 +148,29 @@
         canonical_path_from_root: false,
     },
 }
+
+python_library_host {
+    name: "asuite_proto",
+    defaults: ["asuite_default"],
+    srcs: [
+        "proto/*.proto",
+    ],
+    proto: {
+        canonical_path_from_root: false,
+    },
+}
+
+python_library_host {
+    name: "asuite_cc_client",
+    defaults: ["asuite_default"],
+    srcs: [
+        "atest_utils.py",
+        "constants.py",
+        "constants_default.py",
+        "metrics/*.py",
+    ],
+    libs: [
+        "asuite_proto",
+        "asuite_metrics",
+    ],
+}
diff --git a/atest/asuite_metrics.py b/atest/asuite_metrics.py
index c588a1b..8dcd7dc 100644
--- a/atest/asuite_metrics.py
+++ b/atest/asuite_metrics.py
@@ -17,10 +17,17 @@
 import json
 import logging
 import os
-import urllib2
 import uuid
 
-import constants
+try:
+    # PYTHON2
+    from urllib2 import Request
+    from urllib2 import urlopen
+except ImportError:
+    # PYTHON3
+    from urllib.request import Request
+    from urllib.request import urlopen
+
 
 _JSON_HEADERS = {'Content-Type': 'application/json'}
 _METRICS_RESPONSE = 'done'
@@ -29,6 +36,8 @@
                           '.config', 'asuite', '.metadata')
 _ANDROID_BUILD_TOP = 'ANDROID_BUILD_TOP'
 
+DUMMY_UUID = '00000000-0000-4000-8000-000000000000'
+
 
 #pylint: disable=broad-except
 def log_event(metrics_url, dummy_key_fallback=True, **kwargs):
@@ -48,15 +57,15 @@
         except Exception:
             if not dummy_key_fallback:
                 return
-            key = constants.DUMMY_UUID
+            key = DUMMY_UUID
         data = {'grouping_key': key,
                 'run_id': str(uuid.uuid4())}
         if kwargs:
             data.update(kwargs)
         data = json.dumps(data)
-        request = urllib2.Request(metrics_url, data=data,
-                                  headers=_JSON_HEADERS)
-        response = urllib2.urlopen(request, timeout=_METRICS_TIMEOUT)
+        request = Request(metrics_url, data=data,
+                          headers=_JSON_HEADERS)
+        response = urlopen(request, timeout=_METRICS_TIMEOUT)
         content = response.read()
         if content != _METRICS_RESPONSE:
             raise Exception('Unexpected metrics response: %s' % content)
diff --git a/atest/atest.py b/atest/atest.py
index 9ea9707..6ae9214 100755
--- a/atest/atest.py
+++ b/atest/atest.py
@@ -44,6 +44,7 @@
 import test_runner_handler
 
 from metrics import metrics
+from metrics import metrics_base
 from metrics import metrics_utils
 from test_runners import regression_test_runner
 
@@ -598,6 +599,7 @@
     RESULTS_DIR = make_test_run_dir()
     with atest_execution_info.AtestExecutionInfo(sys.argv[1:],
                                                  RESULTS_DIR) as result_file:
+        metrics_base.MetricsBase.tool_name = constants.TOOL_NAME
         EXIT_CODE = main(sys.argv[1:], RESULTS_DIR)
         metrics_utils.send_exit_event(EXIT_CODE)
         if result_file:
diff --git a/atest/atest_unittest.py b/atest/atest_unittest.py
index 0a786d0..7ffe5e1 100755
--- a/atest/atest_unittest.py
+++ b/atest/atest_unittest.py
@@ -23,6 +23,8 @@
 import atest
 import constants
 import module_info
+
+from metrics import metrics_utils
 from test_finders import test_info
 
 if sys.version_info[0] == 2:
@@ -213,7 +215,8 @@
                           '\x1b[1;37m\x1b[0m\n')
         self.assertEqual(capture_output.getvalue(), correct_output)
 
-    def test_validate_exec_mode(self):
+    @mock.patch.object(metrics_utils, 'send_exit_event')
+    def test_validate_exec_mode(self, _send_exit):
         """Test _validate_exec_mode."""
         args = []
         parsed_args = atest._parse_args(args)
diff --git a/atest/constants_default.py b/atest/constants_default.py
index a6cc856..abc6740 100644
--- a/atest/constants_default.py
+++ b/atest/constants_default.py
@@ -141,7 +141,7 @@
 }
 PRIVACY_POLICY_URL = 'https://policies.google.com/privacy'
 TERMS_SERVICE_URL = 'https://policies.google.com/terms'
-DUMMY_UUID = '00000000-0000-4000-8000-000000000000'
+TOOL_NAME = 'atest'
 
 # VTS plans
 VTS_STAGING_PLAN = 'vts-staging-default'
diff --git a/atest/metrics/clearcut_client.py b/atest/metrics/clearcut_client.py
index 39e2745..ecb83c3 100644
--- a/atest/metrics/clearcut_client.py
+++ b/atest/metrics/clearcut_client.py
@@ -26,7 +26,18 @@
 import logging
 import threading
 import time
-import urllib2
+try:
+    # PYTHON2
+    from urllib2 import urlopen
+    from urllib2 import Request
+    from urllib2 import HTTPError
+    from urllib2 import URLError
+except ImportError:
+    # PYTHON3
+    from urllib.request import urlopen
+    from urllib.request import Request
+    from urllib.request import HTTPError
+    from urllib.request import URLError
 
 from proto import clientanalytics_pb2
 
@@ -83,7 +94,7 @@
 
     def _serialize_events_to_proto(self, events):
         log_request = clientanalytics_pb2.LogRequest()
-        log_request.request_time_ms = long(time.time() * 1000)
+        log_request.request_time_ms = int(time.time() * 1000)
         # pylint: disable=no-member
         log_request.client_info.client_type = _CLIENT_TYPE
         log_request.log_source = self._log_source
@@ -144,9 +155,9 @@
         Args:
             data: The serialized proto to send to Clearcut.
         """
-        request = urllib2.Request(self._clearcut_url, data=data)
+        request = Request(self._clearcut_url, data=data)
         try:
-            response = urllib2.urlopen(request)
+            response = urlopen(request)
             msg = response.read()
             logging.debug('LogRequest successfully sent to Clearcut.')
             log_response = clientanalytics_pb2.LogResponse()
@@ -156,10 +167,10 @@
             self._min_next_request_time = (log_response.next_request_wait_millis
                                            / 1000 + time.time())
             logging.debug('LogResponse: %s', log_response)
-        except urllib2.HTTPError as e:
+        except HTTPError as e:
             logging.debug('Failed to push events to Clearcut. Error code: %d',
                           e.code)
-        except urllib2.URLError:
+        except URLError:
             logging.debug('Failed to push events to Clearcut.')
         except Exception as e:
             logging.debug(e)
diff --git a/atest/metrics/metrics.py b/atest/metrics/metrics.py
index 1445d84..f6446a6 100644
--- a/atest/metrics/metrics.py
+++ b/atest/metrics/metrics.py
@@ -17,7 +17,8 @@
 """
 
 import constants
-import metrics_base
+
+from . import metrics_base
 
 class AtestStartEvent(metrics_base.MetricsBase):
     """
@@ -132,3 +133,16 @@
     """
     _EVENT_NAME = 'run_tests_finish_event'
     duration = constants.EXTERNAL
+
+class LocalDetectEvent(metrics_base.MetricsBase):
+    """
+    Create local detection event and send it to clearcut.
+
+    Usage:
+        metrics.LocalDetectEvent(
+            detect_type=0,
+            result=0)
+    """
+    _EVENT_NAME = 'local_detect_event'
+    detect_type = constants.EXTERNAL
+    result = constants.EXTERNAL
diff --git a/atest/metrics/metrics_base.py b/atest/metrics/metrics_base.py
index b10b5ca..b716092 100644
--- a/atest/metrics/metrics_base.py
+++ b/atest/metrics/metrics_base.py
@@ -15,19 +15,21 @@
 """
 Metrics base class.
 """
+import logging
 import random
 import time
 import uuid
 
 import atest_utils
 import asuite_metrics
-import clearcut_client
 import constants
 
 from proto import clientanalytics_pb2
 from proto import external_user_log_pb2
 from proto import internal_user_log_pb2
 
+from . import clearcut_client
+
 INTERNAL_USER = 0
 EXTERNAL_USER = 1
 
@@ -50,11 +52,12 @@
         _user_key = str(asuite_metrics._get_grouping_key())
     #pylint: disable=broad-except
     except Exception:
-        _user_key = constants.DUMMY_UUID
+        _user_key = asuite_metrics.DUMMY_UUID
     _user_type = (EXTERNAL_USER if atest_utils.is_external_run()
                   else INTERNAL_USER)
     _log_source = ATEST_LOG_SOURCE[_user_type]
     cc = clearcut_client.Clearcut(_log_source)
+    tool_name = None
 
     def __new__(cls, **kwargs):
         """Send metric event to clearcut.
@@ -67,9 +70,12 @@
             A Clearcut instance.
         """
         # pylint: disable=no-member
+        if not cls.tool_name:
+            logging.debug('There is no tool_name, and metrics stops sending.')
+            return None
         allowed = ({constants.EXTERNAL} if cls._user_type == EXTERNAL_USER
                    else {constants.EXTERNAL, constants.INTERNAL})
-        fields = [k for k, v in vars(cls).iteritems()
+        fields = [k for k, v in vars(cls).items()
                   if not k.startswith('_') and v in allowed]
         fields_and_values = {}
         for field in fields:
@@ -78,6 +84,7 @@
         params = {'user_key': cls._user_key,
                   'run_id': cls._run_id,
                   'user_type': cls._user_type,
+                  'tool_name': cls.tool_name,
                   cls._EVENT_NAME: fields_and_values}
         log_event = cls._build_full_event(ATEST_EVENTS[cls._user_type](**params))
         cls.cc.log(log_event)
@@ -95,6 +102,6 @@
             A clientanalytics_pb2.LogEvent instance.
         """
         log_event = clientanalytics_pb2.LogEvent()
-        log_event.event_time_ms = long((time.time() - random.randint(1, 600)) * 1000)
+        log_event.event_time_ms = int((time.time() - random.randint(1, 600)) * 1000)
         log_event.source_extension = atest_event.SerializeToString()
         return log_event
diff --git a/atest/metrics/metrics_utils.py b/atest/metrics/metrics_utils.py
index 0c1f317..e951eda 100644
--- a/atest/metrics/metrics_utils.py
+++ b/atest/metrics/metrics_utils.py
@@ -18,7 +18,7 @@
 
 import time
 
-import metrics
+from . import metrics
 
 
 def static_var(varname, value):
@@ -56,7 +56,7 @@
     Returns:
         A dict of Duration.
     """
-    seconds = long(diff_time_sec)
+    seconds = int(diff_time_sec)
     nanos = int((diff_time_sec - seconds)*10**9)
     return {'seconds': seconds, 'nanos': nanos}
 
@@ -74,4 +74,5 @@
         stacktrace=stacktrace,
         logs=logs)
     # pylint: disable=no-member
-    clearcut.flush_events()
+    if clearcut:
+        clearcut.flush_events()
diff --git a/atest/proto/external_user_log.proto b/atest/proto/external_user_log.proto
index 26a709b..505497e 100644
--- a/atest/proto/external_user_log.proto
+++ b/atest/proto/external_user_log.proto
@@ -43,12 +43,19 @@
     optional Duration duration = 1;
   }
 
+  // Occurs after detection of catching bug by atest have finished
+  message LocalDetectEvent {
+    optional int32 detect_type = 1;
+    optional int32 result = 2;
+  }
+
   // ------------------------
   // FIELDS FOR ATESTLOGEVENT
   // ------------------------
   optional string user_key = 1;
   optional string run_id = 2;
   optional UserType user_type = 3;
+  optional string tool_name = 10;
   oneof event {
     AtestStartEvent atest_start_event = 4;
     AtestExitEvent atest_exit_event = 5;
@@ -56,5 +63,6 @@
     BuildFinishEvent build_finish_event = 7;
     RunnerFinishEvent runner_finish_event = 8;
     RunTestsFinishEvent run_tests_finish_event = 9;
+    LocalDetectEvent local_detect_event = 11;
   }
 }
diff --git a/atest/proto/external_user_log_pb2.py b/atest/proto/external_user_log_pb2.py
index 56665ed..ba33fd4 100644
--- a/atest/proto/external_user_log_pb2.py
+++ b/atest/proto/external_user_log_pb2.py
@@ -21,7 +21,7 @@
   name='proto/external_user_log.proto',
   package='',
   syntax='proto2',
-  serialized_pb=_b('\n\x1dproto/external_user_log.proto\x1a\x12proto/common.proto\"\xfc\x06\n\x15\x41testLogEventExternal\x12\x10\n\x08user_key\x18\x01 \x01(\t\x12\x0e\n\x06run_id\x18\x02 \x01(\t\x12\x1c\n\tuser_type\x18\x03 \x01(\x0e\x32\t.UserType\x12\x43\n\x11\x61test_start_event\x18\x04 \x01(\x0b\x32&.AtestLogEventExternal.AtestStartEventH\x00\x12\x41\n\x10\x61test_exit_event\x18\x05 \x01(\x0b\x32%.AtestLogEventExternal.AtestExitEventH\x00\x12L\n\x16\x66ind_test_finish_event\x18\x06 \x01(\x0b\x32*.AtestLogEventExternal.FindTestFinishEventH\x00\x12\x45\n\x12\x62uild_finish_event\x18\x07 \x01(\x0b\x32\'.AtestLogEventExternal.BuildFinishEventH\x00\x12G\n\x13runner_finish_event\x18\x08 \x01(\x0b\x32(.AtestLogEventExternal.RunnerFinishEventH\x00\x12L\n\x16run_tests_finish_event\x18\t \x01(\x0b\x32*.AtestLogEventExternal.RunTestsFinishEventH\x00\x1a\x11\n\x0f\x41testStartEvent\x1a@\n\x0e\x41testExitEvent\x12\x1b\n\x08\x64uration\x18\x01 \x01(\x0b\x32\t.Duration\x12\x11\n\texit_code\x18\x02 \x01(\x05\x1a\x43\n\x13\x46indTestFinishEvent\x12\x1b\n\x08\x64uration\x18\x01 \x01(\x0b\x32\t.Duration\x12\x0f\n\x07success\x18\x02 \x01(\x08\x1a@\n\x10\x42uildFinishEvent\x12\x1b\n\x08\x64uration\x18\x01 \x01(\x0b\x32\t.Duration\x12\x0f\n\x07success\x18\x02 \x01(\x08\x1aV\n\x11RunnerFinishEvent\x12\x1b\n\x08\x64uration\x18\x01 \x01(\x0b\x32\t.Duration\x12\x0f\n\x07success\x18\x02 \x01(\x08\x12\x13\n\x0brunner_name\x18\x03 \x01(\t\x1a\x32\n\x13RunTestsFinishEvent\x12\x1b\n\x08\x64uration\x18\x01 \x01(\x0b\x32\t.DurationB\x07\n\x05\x65vent')
+  serialized_pb=_b('\n\x1dproto/external_user_log.proto\x1a\x12proto/common.proto\"\x8f\x08\n\x15\x41testLogEventExternal\x12\x10\n\x08user_key\x18\x01 \x01(\t\x12\x0e\n\x06run_id\x18\x02 \x01(\t\x12\x1c\n\tuser_type\x18\x03 \x01(\x0e\x32\t.UserType\x12\x11\n\ttool_name\x18\n \x01(\t\x12\x43\n\x11\x61test_start_event\x18\x04 \x01(\x0b\x32&.AtestLogEventExternal.AtestStartEventH\x00\x12\x41\n\x10\x61test_exit_event\x18\x05 \x01(\x0b\x32%.AtestLogEventExternal.AtestExitEventH\x00\x12L\n\x16\x66ind_test_finish_event\x18\x06 \x01(\x0b\x32*.AtestLogEventExternal.FindTestFinishEventH\x00\x12\x45\n\x12\x62uild_finish_event\x18\x07 \x01(\x0b\x32\'.AtestLogEventExternal.BuildFinishEventH\x00\x12G\n\x13runner_finish_event\x18\x08 \x01(\x0b\x32(.AtestLogEventExternal.RunnerFinishEventH\x00\x12L\n\x16run_tests_finish_event\x18\t \x01(\x0b\x32*.AtestLogEventExternal.RunTestsFinishEventH\x00\x12\x45\n\x12local_detect_event\x18\x0b \x01(\x0b\x32\'.AtestLogEventExternal.LocalDetectEventH\x00\x1a\x11\n\x0f\x41testStartEvent\x1a@\n\x0e\x41testExitEvent\x12\x1b\n\x08\x64uration\x18\x01 \x01(\x0b\x32\t.Duration\x12\x11\n\texit_code\x18\x02 \x01(\x05\x1a\x43\n\x13\x46indTestFinishEvent\x12\x1b\n\x08\x64uration\x18\x01 \x01(\x0b\x32\t.Duration\x12\x0f\n\x07success\x18\x02 \x01(\x08\x1a@\n\x10\x42uildFinishEvent\x12\x1b\n\x08\x64uration\x18\x01 \x01(\x0b\x32\t.Duration\x12\x0f\n\x07success\x18\x02 \x01(\x08\x1aV\n\x11RunnerFinishEvent\x12\x1b\n\x08\x64uration\x18\x01 \x01(\x0b\x32\t.Duration\x12\x0f\n\x07success\x18\x02 \x01(\x08\x12\x13\n\x0brunner_name\x18\x03 \x01(\t\x1a\x32\n\x13RunTestsFinishEvent\x12\x1b\n\x08\x64uration\x18\x01 \x01(\x0b\x32\t.Duration\x1a\x37\n\x10LocalDetectEvent\x12\x13\n\x0b\x64\x65tect_type\x18\x01 \x01(\x05\x12\x0e\n\x06result\x18\x02 \x01(\x05\x42\x07\n\x05\x65vent')
   ,
   dependencies=[proto_dot_common__pb2.DESCRIPTOR,])
 _sym_db.RegisterFileDescriptor(DESCRIPTOR)
@@ -48,8 +48,8 @@
   extension_ranges=[],
   oneofs=[
   ],
-  serialized_start=579,
-  serialized_end=596,
+  serialized_start=669,
+  serialized_end=686,
 )
 
 _ATESTLOGEVENTEXTERNAL_ATESTEXITEVENT = _descriptor.Descriptor(
@@ -85,8 +85,8 @@
   extension_ranges=[],
   oneofs=[
   ],
-  serialized_start=598,
-  serialized_end=662,
+  serialized_start=688,
+  serialized_end=752,
 )
 
 _ATESTLOGEVENTEXTERNAL_FINDTESTFINISHEVENT = _descriptor.Descriptor(
@@ -122,8 +122,8 @@
   extension_ranges=[],
   oneofs=[
   ],
-  serialized_start=664,
-  serialized_end=731,
+  serialized_start=754,
+  serialized_end=821,
 )
 
 _ATESTLOGEVENTEXTERNAL_BUILDFINISHEVENT = _descriptor.Descriptor(
@@ -159,8 +159,8 @@
   extension_ranges=[],
   oneofs=[
   ],
-  serialized_start=733,
-  serialized_end=797,
+  serialized_start=823,
+  serialized_end=887,
 )
 
 _ATESTLOGEVENTEXTERNAL_RUNNERFINISHEVENT = _descriptor.Descriptor(
@@ -203,8 +203,8 @@
   extension_ranges=[],
   oneofs=[
   ],
-  serialized_start=799,
-  serialized_end=885,
+  serialized_start=889,
+  serialized_end=975,
 )
 
 _ATESTLOGEVENTEXTERNAL_RUNTESTSFINISHEVENT = _descriptor.Descriptor(
@@ -233,8 +233,45 @@
   extension_ranges=[],
   oneofs=[
   ],
-  serialized_start=887,
-  serialized_end=937,
+  serialized_start=977,
+  serialized_end=1027,
+)
+
+_ATESTLOGEVENTEXTERNAL_LOCALDETECTEVENT = _descriptor.Descriptor(
+  name='LocalDetectEvent',
+  full_name='AtestLogEventExternal.LocalDetectEvent',
+  filename=None,
+  file=DESCRIPTOR,
+  containing_type=None,
+  fields=[
+    _descriptor.FieldDescriptor(
+      name='detect_type', full_name='AtestLogEventExternal.LocalDetectEvent.detect_type', index=0,
+      number=1, type=5, cpp_type=1, label=1,
+      has_default_value=False, default_value=0,
+      message_type=None, enum_type=None, containing_type=None,
+      is_extension=False, extension_scope=None,
+      options=None),
+    _descriptor.FieldDescriptor(
+      name='result', full_name='AtestLogEventExternal.LocalDetectEvent.result', index=1,
+      number=2, type=5, cpp_type=1, label=1,
+      has_default_value=False, default_value=0,
+      message_type=None, enum_type=None, containing_type=None,
+      is_extension=False, extension_scope=None,
+      options=None),
+  ],
+  extensions=[
+  ],
+  nested_types=[],
+  enum_types=[
+  ],
+  options=None,
+  is_extendable=False,
+  syntax='proto2',
+  extension_ranges=[],
+  oneofs=[
+  ],
+  serialized_start=1029,
+  serialized_end=1084,
 )
 
 _ATESTLOGEVENTEXTERNAL = _descriptor.Descriptor(
@@ -266,51 +303,65 @@
       is_extension=False, extension_scope=None,
       options=None),
     _descriptor.FieldDescriptor(
-      name='atest_start_event', full_name='AtestLogEventExternal.atest_start_event', index=3,
+      name='tool_name', full_name='AtestLogEventExternal.tool_name', index=3,
+      number=10, type=9, cpp_type=9, label=1,
+      has_default_value=False, default_value=_b("").decode('utf-8'),
+      message_type=None, enum_type=None, containing_type=None,
+      is_extension=False, extension_scope=None,
+      options=None),
+    _descriptor.FieldDescriptor(
+      name='atest_start_event', full_name='AtestLogEventExternal.atest_start_event', index=4,
       number=4, type=11, cpp_type=10, label=1,
       has_default_value=False, default_value=None,
       message_type=None, enum_type=None, containing_type=None,
       is_extension=False, extension_scope=None,
       options=None),
     _descriptor.FieldDescriptor(
-      name='atest_exit_event', full_name='AtestLogEventExternal.atest_exit_event', index=4,
+      name='atest_exit_event', full_name='AtestLogEventExternal.atest_exit_event', index=5,
       number=5, type=11, cpp_type=10, label=1,
       has_default_value=False, default_value=None,
       message_type=None, enum_type=None, containing_type=None,
       is_extension=False, extension_scope=None,
       options=None),
     _descriptor.FieldDescriptor(
-      name='find_test_finish_event', full_name='AtestLogEventExternal.find_test_finish_event', index=5,
+      name='find_test_finish_event', full_name='AtestLogEventExternal.find_test_finish_event', index=6,
       number=6, type=11, cpp_type=10, label=1,
       has_default_value=False, default_value=None,
       message_type=None, enum_type=None, containing_type=None,
       is_extension=False, extension_scope=None,
       options=None),
     _descriptor.FieldDescriptor(
-      name='build_finish_event', full_name='AtestLogEventExternal.build_finish_event', index=6,
+      name='build_finish_event', full_name='AtestLogEventExternal.build_finish_event', index=7,
       number=7, type=11, cpp_type=10, label=1,
       has_default_value=False, default_value=None,
       message_type=None, enum_type=None, containing_type=None,
       is_extension=False, extension_scope=None,
       options=None),
     _descriptor.FieldDescriptor(
-      name='runner_finish_event', full_name='AtestLogEventExternal.runner_finish_event', index=7,
+      name='runner_finish_event', full_name='AtestLogEventExternal.runner_finish_event', index=8,
       number=8, type=11, cpp_type=10, label=1,
       has_default_value=False, default_value=None,
       message_type=None, enum_type=None, containing_type=None,
       is_extension=False, extension_scope=None,
       options=None),
     _descriptor.FieldDescriptor(
-      name='run_tests_finish_event', full_name='AtestLogEventExternal.run_tests_finish_event', index=8,
+      name='run_tests_finish_event', full_name='AtestLogEventExternal.run_tests_finish_event', index=9,
       number=9, type=11, cpp_type=10, label=1,
       has_default_value=False, default_value=None,
       message_type=None, enum_type=None, containing_type=None,
       is_extension=False, extension_scope=None,
       options=None),
+    _descriptor.FieldDescriptor(
+      name='local_detect_event', full_name='AtestLogEventExternal.local_detect_event', index=10,
+      number=11, type=11, cpp_type=10, label=1,
+      has_default_value=False, default_value=None,
+      message_type=None, enum_type=None, containing_type=None,
+      is_extension=False, extension_scope=None,
+      options=None),
   ],
   extensions=[
   ],
-  nested_types=[_ATESTLOGEVENTEXTERNAL_ATESTSTARTEVENT, _ATESTLOGEVENTEXTERNAL_ATESTEXITEVENT, _ATESTLOGEVENTEXTERNAL_FINDTESTFINISHEVENT, _ATESTLOGEVENTEXTERNAL_BUILDFINISHEVENT, _ATESTLOGEVENTEXTERNAL_RUNNERFINISHEVENT, _ATESTLOGEVENTEXTERNAL_RUNTESTSFINISHEVENT, ],
+  nested_types=[_ATESTLOGEVENTEXTERNAL_ATESTSTARTEVENT, _ATESTLOGEVENTEXTERNAL_ATESTEXITEVENT, _ATESTLOGEVENTEXTERNAL_FINDTESTFINISHEVENT, _ATESTLOGEVENTEXTERNAL_BUILDFINISHEVENT, _ATESTLOGEVENTEXTERNAL_RUNNERFINISHEVENT, _ATESTLOGEVENTEXTERNAL_RUNTESTSFINISHEVENT, _ATESTLOGEVENTEXTERNAL_LOCALDETECTEVENT, ],
   enum_types=[
   ],
   options=None,
@@ -323,7 +374,7 @@
       index=0, containing_type=None, fields=[]),
   ],
   serialized_start=54,
-  serialized_end=946,
+  serialized_end=1093,
 )
 
 _ATESTLOGEVENTEXTERNAL_ATESTSTARTEVENT.containing_type = _ATESTLOGEVENTEXTERNAL
@@ -337,6 +388,7 @@
 _ATESTLOGEVENTEXTERNAL_RUNNERFINISHEVENT.containing_type = _ATESTLOGEVENTEXTERNAL
 _ATESTLOGEVENTEXTERNAL_RUNTESTSFINISHEVENT.fields_by_name['duration'].message_type = proto_dot_common__pb2._DURATION
 _ATESTLOGEVENTEXTERNAL_RUNTESTSFINISHEVENT.containing_type = _ATESTLOGEVENTEXTERNAL
+_ATESTLOGEVENTEXTERNAL_LOCALDETECTEVENT.containing_type = _ATESTLOGEVENTEXTERNAL
 _ATESTLOGEVENTEXTERNAL.fields_by_name['user_type'].enum_type = proto_dot_common__pb2._USERTYPE
 _ATESTLOGEVENTEXTERNAL.fields_by_name['atest_start_event'].message_type = _ATESTLOGEVENTEXTERNAL_ATESTSTARTEVENT
 _ATESTLOGEVENTEXTERNAL.fields_by_name['atest_exit_event'].message_type = _ATESTLOGEVENTEXTERNAL_ATESTEXITEVENT
@@ -344,6 +396,7 @@
 _ATESTLOGEVENTEXTERNAL.fields_by_name['build_finish_event'].message_type = _ATESTLOGEVENTEXTERNAL_BUILDFINISHEVENT
 _ATESTLOGEVENTEXTERNAL.fields_by_name['runner_finish_event'].message_type = _ATESTLOGEVENTEXTERNAL_RUNNERFINISHEVENT
 _ATESTLOGEVENTEXTERNAL.fields_by_name['run_tests_finish_event'].message_type = _ATESTLOGEVENTEXTERNAL_RUNTESTSFINISHEVENT
+_ATESTLOGEVENTEXTERNAL.fields_by_name['local_detect_event'].message_type = _ATESTLOGEVENTEXTERNAL_LOCALDETECTEVENT
 _ATESTLOGEVENTEXTERNAL.oneofs_by_name['event'].fields.append(
   _ATESTLOGEVENTEXTERNAL.fields_by_name['atest_start_event'])
 _ATESTLOGEVENTEXTERNAL.fields_by_name['atest_start_event'].containing_oneof = _ATESTLOGEVENTEXTERNAL.oneofs_by_name['event']
@@ -362,6 +415,9 @@
 _ATESTLOGEVENTEXTERNAL.oneofs_by_name['event'].fields.append(
   _ATESTLOGEVENTEXTERNAL.fields_by_name['run_tests_finish_event'])
 _ATESTLOGEVENTEXTERNAL.fields_by_name['run_tests_finish_event'].containing_oneof = _ATESTLOGEVENTEXTERNAL.oneofs_by_name['event']
+_ATESTLOGEVENTEXTERNAL.oneofs_by_name['event'].fields.append(
+  _ATESTLOGEVENTEXTERNAL.fields_by_name['local_detect_event'])
+_ATESTLOGEVENTEXTERNAL.fields_by_name['local_detect_event'].containing_oneof = _ATESTLOGEVENTEXTERNAL.oneofs_by_name['event']
 DESCRIPTOR.message_types_by_name['AtestLogEventExternal'] = _ATESTLOGEVENTEXTERNAL
 
 AtestLogEventExternal = _reflection.GeneratedProtocolMessageType('AtestLogEventExternal', (_message.Message,), dict(
@@ -407,6 +463,13 @@
     # @@protoc_insertion_point(class_scope:AtestLogEventExternal.RunTestsFinishEvent)
     ))
   ,
+
+  LocalDetectEvent = _reflection.GeneratedProtocolMessageType('LocalDetectEvent', (_message.Message,), dict(
+    DESCRIPTOR = _ATESTLOGEVENTEXTERNAL_LOCALDETECTEVENT,
+    __module__ = 'proto.external_user_log_pb2'
+    # @@protoc_insertion_point(class_scope:AtestLogEventExternal.LocalDetectEvent)
+    ))
+  ,
   DESCRIPTOR = _ATESTLOGEVENTEXTERNAL,
   __module__ = 'proto.external_user_log_pb2'
   # @@protoc_insertion_point(class_scope:AtestLogEventExternal)
@@ -418,6 +481,7 @@
 _sym_db.RegisterMessage(AtestLogEventExternal.BuildFinishEvent)
 _sym_db.RegisterMessage(AtestLogEventExternal.RunnerFinishEvent)
 _sym_db.RegisterMessage(AtestLogEventExternal.RunTestsFinishEvent)
+_sym_db.RegisterMessage(AtestLogEventExternal.LocalDetectEvent)
 
 
 # @@protoc_insertion_point(module_scope)
diff --git a/atest/proto/internal_user_log.proto b/atest/proto/internal_user_log.proto
index c2be26f..8fbecc6 100644
--- a/atest/proto/internal_user_log.proto
+++ b/atest/proto/internal_user_log.proto
@@ -59,12 +59,19 @@
     optional Duration duration = 1;
   }
 
+  // Occurs after detection of catching bug by atest have finished
+  message LocalDetectEvent {
+    optional int32 detect_type = 1;
+    optional int32 result = 2;
+  }
+
   // ------------------------
   // FIELDS FOR ATESTLOGEVENT
   // ------------------------
   optional string user_key = 1;
   optional string run_id = 2;
   optional UserType user_type = 3;
+  optional string tool_name = 10;
   oneof event {
     AtestStartEvent atest_start_event = 4;
     AtestExitEvent atest_exit_event = 5;
@@ -72,5 +79,6 @@
     BuildFinishEvent build_finish_event = 7;
     RunnerFinishEvent runner_finish_event = 8;
     RunTestsFinishEvent run_tests_finish_event = 9;
+    LocalDetectEvent local_detect_event = 11;
   }
 }
diff --git a/atest/proto/internal_user_log_pb2.py b/atest/proto/internal_user_log_pb2.py
index 70d81f3..e8585dc 100644
--- a/atest/proto/internal_user_log_pb2.py
+++ b/atest/proto/internal_user_log_pb2.py
@@ -21,7 +21,7 @@
   name='proto/internal_user_log.proto',
   package='',
   syntax='proto2',
-  serialized_pb=_b('\n\x1dproto/internal_user_log.proto\x1a\x12proto/common.proto\"\xb1\t\n\x15\x41testLogEventInternal\x12\x10\n\x08user_key\x18\x01 \x01(\t\x12\x0e\n\x06run_id\x18\x02 \x01(\t\x12\x1c\n\tuser_type\x18\x03 \x01(\x0e\x32\t.UserType\x12\x43\n\x11\x61test_start_event\x18\x04 \x01(\x0b\x32&.AtestLogEventInternal.AtestStartEventH\x00\x12\x41\n\x10\x61test_exit_event\x18\x05 \x01(\x0b\x32%.AtestLogEventInternal.AtestExitEventH\x00\x12L\n\x16\x66ind_test_finish_event\x18\x06 \x01(\x0b\x32*.AtestLogEventInternal.FindTestFinishEventH\x00\x12\x45\n\x12\x62uild_finish_event\x18\x07 \x01(\x0b\x32\'.AtestLogEventInternal.BuildFinishEventH\x00\x12G\n\x13runner_finish_event\x18\x08 \x01(\x0b\x32(.AtestLogEventInternal.RunnerFinishEventH\x00\x12L\n\x16run_tests_finish_event\x18\t \x01(\x0b\x32*.AtestLogEventInternal.RunTestsFinishEventH\x00\x1aY\n\x0f\x41testStartEvent\x12\x14\n\x0c\x63ommand_line\x18\x01 \x01(\t\x12\x17\n\x0ftest_references\x18\x02 \x03(\t\x12\x0b\n\x03\x63wd\x18\x03 \x01(\t\x12\n\n\x02os\x18\x04 \x01(\t\x1a\x62\n\x0e\x41testExitEvent\x12\x1b\n\x08\x64uration\x18\x01 \x01(\x0b\x32\t.Duration\x12\x11\n\texit_code\x18\x02 \x01(\x05\x12\x12\n\nstacktrace\x18\x03 \x01(\t\x12\x0c\n\x04logs\x18\x04 \x01(\t\x1a\x84\x01\n\x13\x46indTestFinishEvent\x12\x1b\n\x08\x64uration\x18\x01 \x01(\x0b\x32\t.Duration\x12\x0f\n\x07success\x18\x02 \x01(\x08\x12\x16\n\x0etest_reference\x18\x03 \x01(\t\x12\x14\n\x0ctest_finders\x18\x04 \x03(\t\x12\x11\n\ttest_info\x18\x05 \x01(\t\x1aQ\n\x10\x42uildFinishEvent\x12\x1b\n\x08\x64uration\x18\x01 \x01(\x0b\x32\t.Duration\x12\x0f\n\x07success\x18\x02 \x01(\x08\x12\x0f\n\x07targets\x18\x03 \x03(\t\x1a\xcd\x01\n\x11RunnerFinishEvent\x12\x1b\n\x08\x64uration\x18\x01 \x01(\x0b\x32\t.Duration\x12\x0f\n\x07success\x18\x02 \x01(\x08\x12\x13\n\x0brunner_name\x18\x03 \x01(\t\x12;\n\x04test\x18\x04 \x03(\x0b\x32-.AtestLogEventInternal.RunnerFinishEvent.Test\x1a\x38\n\x04Test\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0e\n\x06result\x18\x02 \x01(\x05\x12\x12\n\nstacktrace\x18\x03 \x01(\t\x1a\x32\n\x13RunTestsFinishEvent\x12\x1b\n\x08\x64uration\x18\x01 \x01(\x0b\x32\t.DurationB\x07\n\x05\x65vent')
+  serialized_pb=_b('\n\x1dproto/internal_user_log.proto\x1a\x12proto/common.proto\"\xc4\n\n\x15\x41testLogEventInternal\x12\x10\n\x08user_key\x18\x01 \x01(\t\x12\x0e\n\x06run_id\x18\x02 \x01(\t\x12\x1c\n\tuser_type\x18\x03 \x01(\x0e\x32\t.UserType\x12\x11\n\ttool_name\x18\n \x01(\t\x12\x43\n\x11\x61test_start_event\x18\x04 \x01(\x0b\x32&.AtestLogEventInternal.AtestStartEventH\x00\x12\x41\n\x10\x61test_exit_event\x18\x05 \x01(\x0b\x32%.AtestLogEventInternal.AtestExitEventH\x00\x12L\n\x16\x66ind_test_finish_event\x18\x06 \x01(\x0b\x32*.AtestLogEventInternal.FindTestFinishEventH\x00\x12\x45\n\x12\x62uild_finish_event\x18\x07 \x01(\x0b\x32\'.AtestLogEventInternal.BuildFinishEventH\x00\x12G\n\x13runner_finish_event\x18\x08 \x01(\x0b\x32(.AtestLogEventInternal.RunnerFinishEventH\x00\x12L\n\x16run_tests_finish_event\x18\t \x01(\x0b\x32*.AtestLogEventInternal.RunTestsFinishEventH\x00\x12\x45\n\x12local_detect_event\x18\x0b \x01(\x0b\x32\'.AtestLogEventInternal.LocalDetectEventH\x00\x1aY\n\x0f\x41testStartEvent\x12\x14\n\x0c\x63ommand_line\x18\x01 \x01(\t\x12\x17\n\x0ftest_references\x18\x02 \x03(\t\x12\x0b\n\x03\x63wd\x18\x03 \x01(\t\x12\n\n\x02os\x18\x04 \x01(\t\x1a\x62\n\x0e\x41testExitEvent\x12\x1b\n\x08\x64uration\x18\x01 \x01(\x0b\x32\t.Duration\x12\x11\n\texit_code\x18\x02 \x01(\x05\x12\x12\n\nstacktrace\x18\x03 \x01(\t\x12\x0c\n\x04logs\x18\x04 \x01(\t\x1a\x84\x01\n\x13\x46indTestFinishEvent\x12\x1b\n\x08\x64uration\x18\x01 \x01(\x0b\x32\t.Duration\x12\x0f\n\x07success\x18\x02 \x01(\x08\x12\x16\n\x0etest_reference\x18\x03 \x01(\t\x12\x14\n\x0ctest_finders\x18\x04 \x03(\t\x12\x11\n\ttest_info\x18\x05 \x01(\t\x1aQ\n\x10\x42uildFinishEvent\x12\x1b\n\x08\x64uration\x18\x01 \x01(\x0b\x32\t.Duration\x12\x0f\n\x07success\x18\x02 \x01(\x08\x12\x0f\n\x07targets\x18\x03 \x03(\t\x1a\xcd\x01\n\x11RunnerFinishEvent\x12\x1b\n\x08\x64uration\x18\x01 \x01(\x0b\x32\t.Duration\x12\x0f\n\x07success\x18\x02 \x01(\x08\x12\x13\n\x0brunner_name\x18\x03 \x01(\t\x12;\n\x04test\x18\x04 \x03(\x0b\x32-.AtestLogEventInternal.RunnerFinishEvent.Test\x1a\x38\n\x04Test\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0e\n\x06result\x18\x02 \x01(\x05\x12\x12\n\nstacktrace\x18\x03 \x01(\t\x1a\x32\n\x13RunTestsFinishEvent\x12\x1b\n\x08\x64uration\x18\x01 \x01(\x0b\x32\t.Duration\x1a\x37\n\x10LocalDetectEvent\x12\x13\n\x0b\x64\x65tect_type\x18\x01 \x01(\x05\x12\x0e\n\x06result\x18\x02 \x01(\x05\x42\x07\n\x05\x65vent')
   ,
   dependencies=[proto_dot_common__pb2.DESCRIPTOR,])
 _sym_db.RegisterFileDescriptor(DESCRIPTOR)
@@ -76,8 +76,8 @@
   extension_ranges=[],
   oneofs=[
   ],
-  serialized_start=579,
-  serialized_end=668,
+  serialized_start=669,
+  serialized_end=758,
 )
 
 _ATESTLOGEVENTINTERNAL_ATESTEXITEVENT = _descriptor.Descriptor(
@@ -127,8 +127,8 @@
   extension_ranges=[],
   oneofs=[
   ],
-  serialized_start=670,
-  serialized_end=768,
+  serialized_start=760,
+  serialized_end=858,
 )
 
 _ATESTLOGEVENTINTERNAL_FINDTESTFINISHEVENT = _descriptor.Descriptor(
@@ -185,8 +185,8 @@
   extension_ranges=[],
   oneofs=[
   ],
-  serialized_start=771,
-  serialized_end=903,
+  serialized_start=861,
+  serialized_end=993,
 )
 
 _ATESTLOGEVENTINTERNAL_BUILDFINISHEVENT = _descriptor.Descriptor(
@@ -229,8 +229,8 @@
   extension_ranges=[],
   oneofs=[
   ],
-  serialized_start=905,
-  serialized_end=986,
+  serialized_start=995,
+  serialized_end=1076,
 )
 
 _ATESTLOGEVENTINTERNAL_RUNNERFINISHEVENT_TEST = _descriptor.Descriptor(
@@ -273,8 +273,8 @@
   extension_ranges=[],
   oneofs=[
   ],
-  serialized_start=1138,
-  serialized_end=1194,
+  serialized_start=1228,
+  serialized_end=1284,
 )
 
 _ATESTLOGEVENTINTERNAL_RUNNERFINISHEVENT = _descriptor.Descriptor(
@@ -324,8 +324,8 @@
   extension_ranges=[],
   oneofs=[
   ],
-  serialized_start=989,
-  serialized_end=1194,
+  serialized_start=1079,
+  serialized_end=1284,
 )
 
 _ATESTLOGEVENTINTERNAL_RUNTESTSFINISHEVENT = _descriptor.Descriptor(
@@ -354,8 +354,45 @@
   extension_ranges=[],
   oneofs=[
   ],
-  serialized_start=1196,
-  serialized_end=1246,
+  serialized_start=1286,
+  serialized_end=1336,
+)
+
+_ATESTLOGEVENTINTERNAL_LOCALDETECTEVENT = _descriptor.Descriptor(
+  name='LocalDetectEvent',
+  full_name='AtestLogEventInternal.LocalDetectEvent',
+  filename=None,
+  file=DESCRIPTOR,
+  containing_type=None,
+  fields=[
+    _descriptor.FieldDescriptor(
+      name='detect_type', full_name='AtestLogEventInternal.LocalDetectEvent.detect_type', index=0,
+      number=1, type=5, cpp_type=1, label=1,
+      has_default_value=False, default_value=0,
+      message_type=None, enum_type=None, containing_type=None,
+      is_extension=False, extension_scope=None,
+      options=None),
+    _descriptor.FieldDescriptor(
+      name='result', full_name='AtestLogEventInternal.LocalDetectEvent.result', index=1,
+      number=2, type=5, cpp_type=1, label=1,
+      has_default_value=False, default_value=0,
+      message_type=None, enum_type=None, containing_type=None,
+      is_extension=False, extension_scope=None,
+      options=None),
+  ],
+  extensions=[
+  ],
+  nested_types=[],
+  enum_types=[
+  ],
+  options=None,
+  is_extendable=False,
+  syntax='proto2',
+  extension_ranges=[],
+  oneofs=[
+  ],
+  serialized_start=1338,
+  serialized_end=1393,
 )
 
 _ATESTLOGEVENTINTERNAL = _descriptor.Descriptor(
@@ -387,51 +424,65 @@
       is_extension=False, extension_scope=None,
       options=None),
     _descriptor.FieldDescriptor(
-      name='atest_start_event', full_name='AtestLogEventInternal.atest_start_event', index=3,
+      name='tool_name', full_name='AtestLogEventInternal.tool_name', index=3,
+      number=10, type=9, cpp_type=9, label=1,
+      has_default_value=False, default_value=_b("").decode('utf-8'),
+      message_type=None, enum_type=None, containing_type=None,
+      is_extension=False, extension_scope=None,
+      options=None),
+    _descriptor.FieldDescriptor(
+      name='atest_start_event', full_name='AtestLogEventInternal.atest_start_event', index=4,
       number=4, type=11, cpp_type=10, label=1,
       has_default_value=False, default_value=None,
       message_type=None, enum_type=None, containing_type=None,
       is_extension=False, extension_scope=None,
       options=None),
     _descriptor.FieldDescriptor(
-      name='atest_exit_event', full_name='AtestLogEventInternal.atest_exit_event', index=4,
+      name='atest_exit_event', full_name='AtestLogEventInternal.atest_exit_event', index=5,
       number=5, type=11, cpp_type=10, label=1,
       has_default_value=False, default_value=None,
       message_type=None, enum_type=None, containing_type=None,
       is_extension=False, extension_scope=None,
       options=None),
     _descriptor.FieldDescriptor(
-      name='find_test_finish_event', full_name='AtestLogEventInternal.find_test_finish_event', index=5,
+      name='find_test_finish_event', full_name='AtestLogEventInternal.find_test_finish_event', index=6,
       number=6, type=11, cpp_type=10, label=1,
       has_default_value=False, default_value=None,
       message_type=None, enum_type=None, containing_type=None,
       is_extension=False, extension_scope=None,
       options=None),
     _descriptor.FieldDescriptor(
-      name='build_finish_event', full_name='AtestLogEventInternal.build_finish_event', index=6,
+      name='build_finish_event', full_name='AtestLogEventInternal.build_finish_event', index=7,
       number=7, type=11, cpp_type=10, label=1,
       has_default_value=False, default_value=None,
       message_type=None, enum_type=None, containing_type=None,
       is_extension=False, extension_scope=None,
       options=None),
     _descriptor.FieldDescriptor(
-      name='runner_finish_event', full_name='AtestLogEventInternal.runner_finish_event', index=7,
+      name='runner_finish_event', full_name='AtestLogEventInternal.runner_finish_event', index=8,
       number=8, type=11, cpp_type=10, label=1,
       has_default_value=False, default_value=None,
       message_type=None, enum_type=None, containing_type=None,
       is_extension=False, extension_scope=None,
       options=None),
     _descriptor.FieldDescriptor(
-      name='run_tests_finish_event', full_name='AtestLogEventInternal.run_tests_finish_event', index=8,
+      name='run_tests_finish_event', full_name='AtestLogEventInternal.run_tests_finish_event', index=9,
       number=9, type=11, cpp_type=10, label=1,
       has_default_value=False, default_value=None,
       message_type=None, enum_type=None, containing_type=None,
       is_extension=False, extension_scope=None,
       options=None),
+    _descriptor.FieldDescriptor(
+      name='local_detect_event', full_name='AtestLogEventInternal.local_detect_event', index=10,
+      number=11, type=11, cpp_type=10, label=1,
+      has_default_value=False, default_value=None,
+      message_type=None, enum_type=None, containing_type=None,
+      is_extension=False, extension_scope=None,
+      options=None),
   ],
   extensions=[
   ],
-  nested_types=[_ATESTLOGEVENTINTERNAL_ATESTSTARTEVENT, _ATESTLOGEVENTINTERNAL_ATESTEXITEVENT, _ATESTLOGEVENTINTERNAL_FINDTESTFINISHEVENT, _ATESTLOGEVENTINTERNAL_BUILDFINISHEVENT, _ATESTLOGEVENTINTERNAL_RUNNERFINISHEVENT, _ATESTLOGEVENTINTERNAL_RUNTESTSFINISHEVENT, ],
+  nested_types=[_ATESTLOGEVENTINTERNAL_ATESTSTARTEVENT, _ATESTLOGEVENTINTERNAL_ATESTEXITEVENT, _ATESTLOGEVENTINTERNAL_FINDTESTFINISHEVENT, _ATESTLOGEVENTINTERNAL_BUILDFINISHEVENT, _ATESTLOGEVENTINTERNAL_RUNNERFINISHEVENT, _ATESTLOGEVENTINTERNAL_RUNTESTSFINISHEVENT, _ATESTLOGEVENTINTERNAL_LOCALDETECTEVENT, ],
   enum_types=[
   ],
   options=None,
@@ -444,7 +495,7 @@
       index=0, containing_type=None, fields=[]),
   ],
   serialized_start=54,
-  serialized_end=1255,
+  serialized_end=1402,
 )
 
 _ATESTLOGEVENTINTERNAL_ATESTSTARTEVENT.containing_type = _ATESTLOGEVENTINTERNAL
@@ -460,6 +511,7 @@
 _ATESTLOGEVENTINTERNAL_RUNNERFINISHEVENT.containing_type = _ATESTLOGEVENTINTERNAL
 _ATESTLOGEVENTINTERNAL_RUNTESTSFINISHEVENT.fields_by_name['duration'].message_type = proto_dot_common__pb2._DURATION
 _ATESTLOGEVENTINTERNAL_RUNTESTSFINISHEVENT.containing_type = _ATESTLOGEVENTINTERNAL
+_ATESTLOGEVENTINTERNAL_LOCALDETECTEVENT.containing_type = _ATESTLOGEVENTINTERNAL
 _ATESTLOGEVENTINTERNAL.fields_by_name['user_type'].enum_type = proto_dot_common__pb2._USERTYPE
 _ATESTLOGEVENTINTERNAL.fields_by_name['atest_start_event'].message_type = _ATESTLOGEVENTINTERNAL_ATESTSTARTEVENT
 _ATESTLOGEVENTINTERNAL.fields_by_name['atest_exit_event'].message_type = _ATESTLOGEVENTINTERNAL_ATESTEXITEVENT
@@ -467,6 +519,7 @@
 _ATESTLOGEVENTINTERNAL.fields_by_name['build_finish_event'].message_type = _ATESTLOGEVENTINTERNAL_BUILDFINISHEVENT
 _ATESTLOGEVENTINTERNAL.fields_by_name['runner_finish_event'].message_type = _ATESTLOGEVENTINTERNAL_RUNNERFINISHEVENT
 _ATESTLOGEVENTINTERNAL.fields_by_name['run_tests_finish_event'].message_type = _ATESTLOGEVENTINTERNAL_RUNTESTSFINISHEVENT
+_ATESTLOGEVENTINTERNAL.fields_by_name['local_detect_event'].message_type = _ATESTLOGEVENTINTERNAL_LOCALDETECTEVENT
 _ATESTLOGEVENTINTERNAL.oneofs_by_name['event'].fields.append(
   _ATESTLOGEVENTINTERNAL.fields_by_name['atest_start_event'])
 _ATESTLOGEVENTINTERNAL.fields_by_name['atest_start_event'].containing_oneof = _ATESTLOGEVENTINTERNAL.oneofs_by_name['event']
@@ -485,6 +538,9 @@
 _ATESTLOGEVENTINTERNAL.oneofs_by_name['event'].fields.append(
   _ATESTLOGEVENTINTERNAL.fields_by_name['run_tests_finish_event'])
 _ATESTLOGEVENTINTERNAL.fields_by_name['run_tests_finish_event'].containing_oneof = _ATESTLOGEVENTINTERNAL.oneofs_by_name['event']
+_ATESTLOGEVENTINTERNAL.oneofs_by_name['event'].fields.append(
+  _ATESTLOGEVENTINTERNAL.fields_by_name['local_detect_event'])
+_ATESTLOGEVENTINTERNAL.fields_by_name['local_detect_event'].containing_oneof = _ATESTLOGEVENTINTERNAL.oneofs_by_name['event']
 DESCRIPTOR.message_types_by_name['AtestLogEventInternal'] = _ATESTLOGEVENTINTERNAL
 
 AtestLogEventInternal = _reflection.GeneratedProtocolMessageType('AtestLogEventInternal', (_message.Message,), dict(
@@ -537,6 +593,13 @@
     # @@protoc_insertion_point(class_scope:AtestLogEventInternal.RunTestsFinishEvent)
     ))
   ,
+
+  LocalDetectEvent = _reflection.GeneratedProtocolMessageType('LocalDetectEvent', (_message.Message,), dict(
+    DESCRIPTOR = _ATESTLOGEVENTINTERNAL_LOCALDETECTEVENT,
+    __module__ = 'proto.internal_user_log_pb2'
+    # @@protoc_insertion_point(class_scope:AtestLogEventInternal.LocalDetectEvent)
+    ))
+  ,
   DESCRIPTOR = _ATESTLOGEVENTINTERNAL,
   __module__ = 'proto.internal_user_log_pb2'
   # @@protoc_insertion_point(class_scope:AtestLogEventInternal)
@@ -549,6 +612,7 @@
 _sym_db.RegisterMessage(AtestLogEventInternal.RunnerFinishEvent)
 _sym_db.RegisterMessage(AtestLogEventInternal.RunnerFinishEvent.Test)
 _sym_db.RegisterMessage(AtestLogEventInternal.RunTestsFinishEvent)
+_sym_db.RegisterMessage(AtestLogEventInternal.LocalDetectEvent)
 
 
 # @@protoc_insertion_point(module_scope)
diff --git a/src/com/android/tradefed/command/CommandOptions.java b/src/com/android/tradefed/command/CommandOptions.java
index f30fc70..d558e5f 100644
--- a/src/com/android/tradefed/command/CommandOptions.java
+++ b/src/com/android/tradefed/command/CommandOptions.java
@@ -170,6 +170,13 @@
     private boolean mUseRemoteSandbox = false;
 
     @Option(
+        name = "parallel-remote-setup",
+        description =
+                "For remote sharded invocation, whether or not to attempt the setup in parallel."
+    )
+    private boolean mUseParallelRemoteSetup = false;
+
+    @Option(
         name = "auto-collect",
         description =
                 "Specify a set of collectors that will be automatically managed by the harness "
@@ -523,4 +530,10 @@
     public void setHostLogSuffix(String suffix) {
         mHostLogSuffix = suffix;
     }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean shouldUseParallelRemoteSetup() {
+        return mUseParallelRemoteSetup;
+    }
 }
diff --git a/src/com/android/tradefed/command/ICommandOptions.java b/src/com/android/tradefed/command/ICommandOptions.java
index cb1d0ce..b2fefd2 100644
--- a/src/com/android/tradefed/command/ICommandOptions.java
+++ b/src/com/android/tradefed/command/ICommandOptions.java
@@ -196,4 +196,7 @@
 
     /** Sets the suffix to append to Tradefed host_log. */
     public void setHostLogSuffix(String suffix);
+
+    /** Whether or not to attempt parallel setup of the remote devices. */
+    public boolean shouldUseParallelRemoteSetup();
 }
diff --git a/src/com/android/tradefed/config/ArgsOptionParser.java b/src/com/android/tradefed/config/ArgsOptionParser.java
index b45631a..fa11657 100644
--- a/src/com/android/tradefed/config/ArgsOptionParser.java
+++ b/src/com/android/tradefed/config/ArgsOptionParser.java
@@ -342,7 +342,7 @@
                 String tmp = grabNextValue(args, name, "for its key");
                 // only match = to escape use "\="
                 Pattern p = Pattern.compile("(?<!\\\\)=");
-                String[] parts = p.split(tmp);
+                String[] parts = p.split(tmp, /* allow empty-string values */ -1);
                 // Note that we replace escaped = (\=) to =.
                 if (parts.length == 2) {
                     key = parts[0].replaceAll("\\\\=", "=");
diff --git a/src/com/android/tradefed/device/DeviceSelectionOptions.java b/src/com/android/tradefed/device/DeviceSelectionOptions.java
index 30ce3fa..4d31b87 100644
--- a/src/com/android/tradefed/device/DeviceSelectionOptions.java
+++ b/src/com/android/tradefed/device/DeviceSelectionOptions.java
@@ -344,6 +344,10 @@
         mRequestedType = requestedType;
     }
 
+    public DeviceRequestedType getDeviceTypeRequested() {
+        return mRequestedType;
+    }
+
     /**
      * Sets the minimum battery level
      */
diff --git a/src/com/android/tradefed/device/NativeDevice.java b/src/com/android/tradefed/device/NativeDevice.java
index 910e9de..c3b8fe4 100644
--- a/src/com/android/tradefed/device/NativeDevice.java
+++ b/src/com/android/tradefed/device/NativeDevice.java
@@ -125,8 +125,8 @@
     /** the default number of command retry attempts to perform */
     protected static final int MAX_RETRY_ATTEMPTS = 2;
 
-    /** Value returned for any invalid/not found user id: UserHandle defined the -10000 value **/
-    protected static final int INVALID_USER_ID = -10000;
+    /** Value returned for any invalid/not found user id: UserHandle defined the -10000 value */
+    public static final int INVALID_USER_ID = -10000;
 
     /** regex to match input dispatch readiness line **/
     static final Pattern INPUT_DISPATCH_STATE_REGEX =
diff --git a/src/com/android/tradefed/device/cloud/CommonLogRemoteFileUtil.java b/src/com/android/tradefed/device/cloud/CommonLogRemoteFileUtil.java
index 5e39ce9..4b9d492 100644
--- a/src/com/android/tradefed/device/cloud/CommonLogRemoteFileUtil.java
+++ b/src/com/android/tradefed/device/cloud/CommonLogRemoteFileUtil.java
@@ -32,9 +32,9 @@
 public class CommonLogRemoteFileUtil {
 
     /** The directory where to find debug logs for a nested remote instance. */
-    public static final String NESTED_REMOTE_LOG_DIR = "${HOME}/../vsoc-01/cuttlefish_runtime/";
+    public static final String NESTED_REMOTE_LOG_DIR = "/home/%s/cuttlefish_runtime/";
     /** The directory where to find debug logs for an emulator instance. */
-    public static final String EMULATOR_REMOTE_LOG_DIR = "/home/vsoc-01/log/";
+    public static final String EMULATOR_REMOTE_LOG_DIR = "/home/%s/log/";
 
     public static final MultiMap<InstanceType, KnownLogFileEntry> KNOWN_FILES_TO_FETCH =
             new MultiMap<>();
@@ -72,7 +72,8 @@
                 new KnownLogFileEntry("/var/log/daemon.log", null, LogDataType.TEXT));
     }
 
-    private static class KnownLogFileEntry {
+    /** A representation of a known log entry for remote devices. */
+    public static class KnownLogFileEntry {
         public String path;
         public String logName;
         public LogDataType type;
@@ -110,7 +111,8 @@
                         gceAvd,
                         options,
                         runUtil,
-                        entry.path,
+                        // Default fetch rely on main user
+                        String.format(entry.path, options.getInstanceUser()),
                         entry.type,
                         entry.logName);
             }
diff --git a/src/com/android/tradefed/device/cloud/LaunchCvdHelper.java b/src/com/android/tradefed/device/cloud/LaunchCvdHelper.java
index 5c18cfe..d7dea2a 100644
--- a/src/com/android/tradefed/device/cloud/LaunchCvdHelper.java
+++ b/src/com/android/tradefed/device/cloud/LaunchCvdHelper.java
@@ -29,12 +29,32 @@
      * @return The created command line;
      */
     public static List<String> createSimpleDeviceCommand(String username, boolean daemon) {
+        return createSimpleDeviceCommand(username, daemon, true, true);
+    }
+
+    /**
+     * Create the command line to start an additional device for a user.
+     *
+     * @param username The user that will run the device.
+     * @param daemon Whether or not to start the device as a daemon.
+     * @param alwaysCreateUserData Whether or not to create the userdata partition
+     * @param blankDataImage whether or not to create the data image.
+     * @return The created command line;
+     */
+    public static List<String> createSimpleDeviceCommand(
+            String username, boolean daemon, boolean alwaysCreateUserData, boolean blankDataImage) {
         List<String> command = new ArrayList<>();
         command.add("sudo -u " + username);
         command.add("/home/" + username + "/bin/launch_cvd");
         command.add("-data_policy");
-        command.add("always_create");
-        command.add("-blank_data_image_mb");
+        if (alwaysCreateUserData) {
+            command.add("always_create");
+        } else {
+            command.add("create_if_missing");
+        }
+        if (blankDataImage) {
+            command.add("-blank_data_image_mb");
+        }
         command.add("8000");
         if (daemon) {
             command.add("-daemon");
diff --git a/src/com/android/tradefed/device/cloud/MultiUserSetupUtil.java b/src/com/android/tradefed/device/cloud/MultiUserSetupUtil.java
index 3d4ccf9..9c2e577 100644
--- a/src/com/android/tradefed/device/cloud/MultiUserSetupUtil.java
+++ b/src/com/android/tradefed/device/cloud/MultiUserSetupUtil.java
@@ -58,6 +58,64 @@
         return null;
     }
 
+    /** Create the 'cvd-XX' user on the remote device if missing. */
+    public static CommandResult addExtraCvdUser(
+            int userId,
+            GceAvdInfo remoteInstance,
+            TestDeviceOptions options,
+            IRunUtil runUtil,
+            long timeoutMs) {
+        String useridString = getUserNumber(userId);
+        String username = String.format("cvd-%s", useridString);
+        String createUserCommand =
+                "sudo useradd " + username + " -G plugdev -m -s /bin/bash -p '*'";
+        CommandResult createUserRes =
+                RemoteSshUtil.remoteSshCommandExec(
+                        remoteInstance, options, runUtil, timeoutMs, createUserCommand.split(" "));
+        if (!CommandStatus.SUCCESS.equals(createUserRes.getStatus())) {
+            if (createUserRes
+                    .getStderr()
+                    .contains(String.format("user '%s' already exists", username))) {
+                return null;
+            }
+            return createUserRes;
+        }
+        return null;
+    }
+
+    /** Setup the tuntap interface required to start the Android devices if they are missing. */
+    public static CommandResult setupNetworkInterface(
+            int userId,
+            GceAvdInfo remoteInstance,
+            TestDeviceOptions options,
+            IRunUtil runUtil,
+            long timeoutMs) {
+        if (userId < 9) {
+            // TODO: use 'tuntap show' to check if interface exists already or not.
+            return null;
+        }
+        String useridString = getUserNumber(userId);
+        String mtap = String.format("cvd-mtap-%s", useridString);
+        String wtap = String.format("cvd-wtap-%s", useridString);
+        String addNetworkInterface = "sudo ip tuntap add dev %s mode tap group cvdnetwork";
+        String mtapCommand = String.format(addNetworkInterface, mtap);
+        CommandResult addNetworkInterfaceRes =
+                RemoteSshUtil.remoteSshCommandExec(
+                        remoteInstance, options, runUtil, timeoutMs, mtapCommand.split(" "));
+        if (!CommandStatus.SUCCESS.equals(addNetworkInterfaceRes.getStatus())) {
+            return addNetworkInterfaceRes;
+        }
+
+        String wtapCommand = String.format(addNetworkInterface, wtap);
+        addNetworkInterfaceRes =
+                RemoteSshUtil.remoteSshCommandExec(
+                        remoteInstance, options, runUtil, timeoutMs, wtapCommand.split(" "));
+        if (!CommandStatus.SUCCESS.equals(addNetworkInterfaceRes.getStatus())) {
+            return addNetworkInterfaceRes;
+        }
+        return null;
+    }
+
     /** Setup a new remote user on an existing Cuttlefish VM. */
     public static CommandResult prepareRemoteHomeDir(
             String mainRootUser,
@@ -107,4 +165,9 @@
     public static String getChownCommand(String username) {
         return "find /home/" + username + " | sudo xargs chown " + username;
     }
+
+    /** Returns the user id string version that follow the remote device notation. */
+    public static String getUserNumber(int userId) {
+        return (userId > 9) ? Integer.toString(userId) : "0" + Integer.toString(userId);
+    }
 }
diff --git a/src/com/android/tradefed/device/cloud/NestedRemoteDevice.java b/src/com/android/tradefed/device/cloud/NestedRemoteDevice.java
index 3c10300..9b36eca 100644
--- a/src/com/android/tradefed/device/cloud/NestedRemoteDevice.java
+++ b/src/com/android/tradefed/device/cloud/NestedRemoteDevice.java
@@ -21,6 +21,7 @@
 import com.android.tradefed.device.IDeviceMonitor;
 import com.android.tradefed.device.IDeviceStateMonitor;
 import com.android.tradefed.device.TestDevice;
+import com.android.tradefed.device.cloud.CommonLogRemoteFileUtil.KnownLogFileEntry;
 import com.android.tradefed.invoker.RemoteInvocationExecution;
 import com.android.tradefed.log.ITestLogger;
 import com.android.tradefed.log.LogUtil.CLog;
@@ -34,6 +35,8 @@
 import com.google.common.base.Joiner;
 
 import java.io.File;
+import java.text.SimpleDateFormat;
+import java.util.Date;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
@@ -55,6 +58,14 @@
         IP_TO_USER.put("127.0.0.1:6524", "vsoc-05");
         IP_TO_USER.put("127.0.0.1:6525", "vsoc-06");
         IP_TO_USER.put("127.0.0.1:6526", "vsoc-07");
+        IP_TO_USER.put("127.0.0.1:6527", "vsoc-08");
+        IP_TO_USER.put("127.0.0.1:6528", "vsoc-09");
+        IP_TO_USER.put("127.0.0.1:6529", "vsoc-10");
+        IP_TO_USER.put("127.0.0.1:6530", "vsoc-11");
+        IP_TO_USER.put("127.0.0.1:6531", "vsoc-12");
+        IP_TO_USER.put("127.0.0.1:6532", "vsoc-13");
+        IP_TO_USER.put("127.0.0.1:6533", "vsoc-14");
+        IP_TO_USER.put("127.0.0.1:6534", "vsoc-15");
     }
 
     /** When calling launch_cvd, the launcher.log is populated. */
@@ -92,8 +103,11 @@
         }
         // Synchronize this so multiple reset do not occur at the same time inside one VM.
         synchronized (NestedRemoteDevice.class) {
-            // Restart the device
-            List<String> createCommand = LaunchCvdHelper.createSimpleDeviceCommand(username, true);
+            // Log the common files before restarting otherwise they are lost
+            logDebugFiles(logger, username);
+            // Restart the device without re-creating the data partitions.
+            List<String> createCommand =
+                    LaunchCvdHelper.createSimpleDeviceCommand(username, true, false, false);
             CommandResult createRes =
                     getRunUtil()
                             .runTimedCmd(
@@ -114,6 +128,32 @@
         }
     }
 
+    /**
+     * Log the runtime files of the virtual device before resetting it since they will be deleted.
+     */
+    private void logDebugFiles(ITestLogger logger, String username) {
+        List<KnownLogFileEntry> toFetch =
+                CommonLogRemoteFileUtil.KNOWN_FILES_TO_FETCH.get(getOptions().getInstanceType());
+        if (toFetch != null) {
+            SimpleDateFormat formatter = new SimpleDateFormat("HH:mm:ss");
+            for (KnownLogFileEntry entry : toFetch) {
+                File toLog = new File(String.format(entry.path, username));
+                if (!toLog.exists()) {
+                    continue;
+                }
+                try (FileInputStreamSource source = new FileInputStreamSource(toLog)) {
+                    logger.testLog(
+                            String.format(
+                                    "before_reset_%s_%s_%s",
+                                    toLog.getName(), username, formatter.format(new Date())),
+                            entry.type,
+                            source);
+                }
+            }
+        }
+        logBugreport(String.format("before_reset_%s_bugreport", username), logger);
+    }
+
     /** TODO: Re-run the target_preparation. */
     private boolean reInitDevice(IBuildInfo info) throws DeviceNotAvailableException {
         // Reset recovery since it's a new device
diff --git a/src/com/android/tradefed/invoker/InvocationExecution.java b/src/com/android/tradefed/invoker/InvocationExecution.java
index 1bba3af..abf19dc 100644
--- a/src/com/android/tradefed/invoker/InvocationExecution.java
+++ b/src/com/android/tradefed/invoker/InvocationExecution.java
@@ -438,6 +438,7 @@
         UnexecutedTestReporterThread reporterThread =
                 new UnexecutedTestReporterThread(listener, remainingTests);
         Runtime.getRuntime().addShutdownHook(reporterThread);
+        TestInvocation.printStageDelimiter(Stage.TEST, false);
         try {
             for (IRemoteTest test : config.getTests()) {
                 // For compatibility of those receivers, they are assumed to be single device alloc.
@@ -494,6 +495,7 @@
                 remainingTests.remove(test);
             }
         } finally {
+            TestInvocation.printStageDelimiter(Stage.TEST, true);
             // TODO: Look if this can be improved to DeviceNotAvailableException too.
             Runtime.getRuntime().removeShutdownHook(reporterThread);
         }
diff --git a/src/com/android/tradefed/invoker/RemoteInvocationExecution.java b/src/com/android/tradefed/invoker/RemoteInvocationExecution.java
index c128e87..3051db9 100644
--- a/src/com/android/tradefed/invoker/RemoteInvocationExecution.java
+++ b/src/com/android/tradefed/invoker/RemoteInvocationExecution.java
@@ -15,6 +15,7 @@
  */
 package com.android.tradefed.invoker;
 
+import com.android.annotations.VisibleForTesting;
 import com.android.tradefed.build.BuildRetrievalError;
 import com.android.tradefed.build.IBuildInfo;
 import com.android.tradefed.build.StubBuildProvider;
@@ -41,6 +42,7 @@
 import com.android.tradefed.result.LogDataType;
 import com.android.tradefed.result.proto.FileProtoResultReporter;
 import com.android.tradefed.result.proto.ProtoResultParser;
+import com.android.tradefed.result.proto.SummaryProtoResultReporter;
 import com.android.tradefed.targetprep.BuildError;
 import com.android.tradefed.targetprep.TargetSetupError;
 import com.android.tradefed.util.CommandResult;
@@ -62,6 +64,7 @@
 import java.util.Arrays;
 import java.util.HashSet;
 import java.util.List;
+import java.util.concurrent.Semaphore;
 
 /** Implementation of {@link InvocationExecution} that drives a remote execution. */
 public class RemoteInvocationExecution extends InvocationExecution {
@@ -69,22 +72,24 @@
     public static final long PUSH_TF_TIMEOUT = 150000L;
     public static final long PULL_RESULT_TIMEOUT = 180000L;
     public static final long REMOTE_PROCESS_RUNNING_WAIT = 15000L;
-    public static final long LAUNCH_EXTRA_DEVICE = 7 * 60 * 1000L;
+    public static final long LAUNCH_EXTRA_DEVICE = 10 * 60 * 1000L;
     public static final long NEW_USER_TIMEOUT = 5 * 60 * 1000L;
     public static final String REMOTE_VM_VARIABLE = "REMOTE_VM_ENV";
 
     public static final String REMOTE_USER_DIR = "/home/{$USER}/";
     public static final String PROTO_RESULT_NAME = "output.pb";
+    public static final String PROTO_SUMMARY_NAME = "summary-output.pb";
     public static final String STDOUT_FILE = "screen-VM_tradefed-stdout.txt";
     public static final String STDERR_FILE = "screen-VM_tradefed-stderr.txt";
     public static final String REMOTE_CONFIG = "configuration";
     public static final String GLOBAL_REMOTE_CONFIG = "global-remote-configuration";
+    public static final String SHARDING_DEVICE_SETUP_TIME = "sharding-device-setup-ms";
 
     private static final int MAX_CONNECTION_REFUSED_COUNT = 3;
     private static final int MAX_PUSH_TF_ATTEMPTS = 3;
+    private static final int MAX_WORKER_THREAD = 3;
 
     private String mRemoteTradefedDir = null;
-    private String mRemoteFinalResult = null;
     private String mRemoteAdbPath = null;
 
     @Override
@@ -127,56 +132,42 @@
         if (config.getCommandOptions().getShardCount() != null
                 && config.getCommandOptions().getShardIndex() == null) {
             if (config.getCommandOptions().getShardCount() > 1) {
+                boolean parallel = config.getCommandOptions().shouldUseParallelRemoteSetup();
+                long startTime = System.currentTimeMillis();
                 // For each device after the first one we need to start a new device.
-                for (int i = 2; i < config.getCommandOptions().getShardCount() + 1; i++) {
-                    String username = String.format("vsoc-0%s", i);
-                    CommandResult userSetup =
-                            MultiUserSetupUtil.prepareRemoteUser(
-                                    username, info, options, runUtil, NEW_USER_TIMEOUT);
-                    if (userSetup != null) {
-                        String errorMsg =
-                                String.format("Failed to setup user: %s", userSetup.getStderr());
-                        CLog.e(errorMsg);
-                        listener.invocationFailed(new RuntimeException(errorMsg));
-                        return;
+                if (!parallel) {
+                    for (int i = 2; i < config.getCommandOptions().getShardCount() + 1; i++) {
+                        boolean res = startDevice(listener, i, info, options, runUtil, null);
+                        if (!res) {
+                            return;
+                        }
+                    }
+                } else {
+                    // Parallel setup of devices
+                    Semaphore token = new Semaphore(MAX_WORKER_THREAD);
+                    List<StartDeviceThread> threads = new ArrayList<>();
+                    for (int i = 2; i < config.getCommandOptions().getShardCount() + 1; i++) {
+                        StartDeviceThread sdt =
+                                new StartDeviceThread(listener, i, info, options, runUtil, token);
+                        threads.add(sdt);
+                        sdt.start();
                     }
 
-                    CommandResult homeDirSetup =
-                            MultiUserSetupUtil.prepareRemoteHomeDir(
-                                    options.getInstanceUser(),
-                                    username,
-                                    info,
-                                    options,
-                                    runUtil,
-                                    NEW_USER_TIMEOUT);
-                    if (homeDirSetup != null) {
-                        String errorMsg =
-                                String.format(
-                                        "Failed to setup home dir: %s", homeDirSetup.getStderr());
-                        CLog.e(errorMsg);
-                        listener.invocationFailed(new RuntimeException(errorMsg));
-                        return;
+                    boolean res = true;
+                    for (StartDeviceThread t : threads) {
+                        t.join();
+                        res = res & t.getFinalStatus();
                     }
-
-                    List<String> startCommand =
-                            LaunchCvdHelper.createSimpleDeviceCommand(username, true);
-                    CommandResult startDeviceRes =
-                            GceManager.remoteSshCommandExecution(
-                                    info,
-                                    options,
-                                    runUtil,
-                                    LAUNCH_EXTRA_DEVICE,
-                                    Joiner.on(" ").join(startCommand));
-                    if (!CommandStatus.SUCCESS.equals(startDeviceRes.getStatus())) {
-                        String errorMsg =
-                                String.format(
-                                        "Failed to start %s: %s",
-                                        username, startDeviceRes.getStderr());
-                        CLog.e(errorMsg);
-                        listener.invocationFailed(new RuntimeException(errorMsg));
+                    if (!res) {
                         return;
                     }
                 }
+
+                // Log the overhead to start the device
+                long elapsedTime = System.currentTimeMillis() - startTime;
+                context.getBuildInfos()
+                        .get(0)
+                        .addBuildAttribute(SHARDING_DEVICE_SETUP_TIME, Long.toString(elapsedTime));
             }
         }
 
@@ -227,28 +218,10 @@
                         info, options, runUtil, 120000L, "ls", "-l", mRemoteTradefedDir);
         CLog.d("stdout: %s", listRemoteDir.getStdout());
         CLog.d("stderr: %s", listRemoteDir.getStderr());
-        mRemoteFinalResult = mRemoteTradefedDir + PROTO_RESULT_NAME;
 
-        // Setup the remote reporting to a proto file
-        FileProtoResultReporter reporter = new FileProtoResultReporter();
-        reporter.setFileOutput(new File(mRemoteFinalResult));
-        config.setTestInvocationListener(reporter);
-
-        for (IDeviceConfiguration deviceConfig : config.getDeviceConfig()) {
-            deviceConfig.getDeviceRequirements().setSerial();
-            if (deviceConfig.getDeviceRequirements() instanceof DeviceSelectionOptions) {
-                ((DeviceSelectionOptions) deviceConfig.getDeviceRequirements())
-                        .setDeviceTypeRequested(null);
-            }
-        }
-
-        File configFile = FileUtil.createTempFile(config.getName(), ".xml");
+        File configFile = createRemoteConfig(config, listener, mRemoteTradefedDir);
         File globalConfig = null;
-        config.dumpXml(new PrintWriter(configFile));
         try {
-            try (InputStreamSource source = new FileInputStreamSource(configFile)) {
-                listener.testLog(REMOTE_CONFIG, LogDataType.XML, source);
-            }
             CLog.d("Pushing Tradefed XML configuration to remote.");
             boolean resultPush =
                     RemoteFileUtil.pushFileToRemote(
@@ -384,22 +357,6 @@
                 isStillRunning(
                         currentInvocationListener, configFile, info, options, runUtil, config);
 
-        File resultFile = null;
-        if (!stillRunning) {
-            resultFile =
-                    RemoteFileUtil.fetchRemoteFile(
-                            info, options, runUtil, PULL_RESULT_TIMEOUT, mRemoteFinalResult);
-            if (resultFile == null) {
-                currentInvocationListener.invocationFailed(
-                        new RuntimeException(
-                                String.format(
-                                        "Could not find remote result file at %s",
-                                        mRemoteFinalResult)));
-            } else {
-                CLog.d("Fetched remote result file!");
-            }
-        }
-
         // Fetch the logs
         File stdoutFile =
                 RemoteFileUtil.fetchRemoteFile(
@@ -427,12 +384,14 @@
             }
         }
 
-        if (resultFile != null) {
-            // Report result to listener.
-            ProtoResultParser parser =
-                    new ProtoResultParser(currentInvocationListener, context, false, "remote-");
-            parser.processFinalizedProto(TestRecordProtoUtil.readFromFile(resultFile));
-        }
+        fetchAndProcessResults(
+                stillRunning,
+                currentInvocationListener,
+                context,
+                info,
+                options,
+                runUtil,
+                mRemoteTradefedDir);
     }
 
     private boolean isStillRunning(
@@ -547,4 +506,230 @@
                 LogDataType.TEXT,
                 "full_adb.log");
     }
+
+    /**
+     * Create the configuration that will run in the remote VM.
+     *
+     * @param config The main {@link IConfiguration}.
+     * @param logger A logger where to save the XML configuration for debugging.
+     * @param resultDirPath the remote result dir where results should be saved.
+     * @return A file containing the dumped remote XML configuration.
+     * @throws IOException
+     */
+    @VisibleForTesting
+    File createRemoteConfig(IConfiguration config, ITestLogger logger, String resultDirPath)
+            throws IOException {
+        // Setup the remote reporting to a proto file
+        List<ITestInvocationListener> reporters = new ArrayList<>();
+        FileProtoResultReporter protoReporter = new FileProtoResultReporter();
+        protoReporter.setFileOutput(new File(resultDirPath + PROTO_RESULT_NAME));
+        reporters.add(protoReporter);
+        // Setup a summary reporter
+        SummaryProtoResultReporter summaryReporter = new SummaryProtoResultReporter();
+        summaryReporter.setFileOutput(new File(resultDirPath + PROTO_SUMMARY_NAME));
+        reporters.add(summaryReporter);
+
+        config.setTestInvocationListeners(reporters);
+
+        for (IDeviceConfiguration deviceConfig : config.getDeviceConfig()) {
+            deviceConfig.getDeviceRequirements().setSerial();
+            if (deviceConfig.getDeviceRequirements() instanceof DeviceSelectionOptions) {
+                ((DeviceSelectionOptions) deviceConfig.getDeviceRequirements())
+                        .setDeviceTypeRequested(null);
+            }
+        }
+
+        // Dump and log the configuration
+        File configFile = FileUtil.createTempFile(config.getName(), ".xml");
+        config.dumpXml(new PrintWriter(configFile));
+        try (InputStreamSource source = new FileInputStreamSource(configFile)) {
+            logger.testLog(REMOTE_CONFIG, LogDataType.XML, source);
+        }
+        return configFile;
+    }
+
+    private void fetchAndProcessResults(
+            boolean wasStillRunning,
+            ITestInvocationListener invocationListener,
+            IInvocationContext context,
+            GceAvdInfo info,
+            TestDeviceOptions options,
+            IRunUtil runUtil,
+            String resultDirPath)
+            throws InvalidProtocolBufferException, IOException {
+        File resultFile = null;
+        if (wasStillRunning) {
+            CLog.d("Remote invocation was still running. No result can be pulled.");
+            return;
+        }
+        resultFile =
+                RemoteFileUtil.fetchRemoteFile(
+                        info,
+                        options,
+                        runUtil,
+                        PULL_RESULT_TIMEOUT,
+                        resultDirPath + PROTO_RESULT_NAME);
+        if (resultFile == null) {
+            invocationListener.invocationFailed(
+                    new RuntimeException(
+                            String.format(
+                                    "Could not find remote result file at %s",
+                                    resultDirPath + PROTO_RESULT_NAME)));
+            return;
+        }
+        CLog.d("Fetched remote result file!");
+        // Report result to listener.
+        try {
+            ProtoResultParser parser =
+                    new ProtoResultParser(invocationListener, context, false, "remote-");
+            parser.processFinalizedProto(TestRecordProtoUtil.readFromFile(resultFile));
+        } finally {
+            FileUtil.deleteFile(resultFile);
+        }
+    }
+
+    /**
+     * Method that handles starting an extra Android Virtual Device inside a given remote VM.
+     *
+     * @param listener The invocation {@link ITestInvocationListener}.
+     * @param userId The username id to associate the device with.
+     * @param info The {@link GceAvdInfo} describing the remote VM.
+     * @param options The {@link TestDeviceOptions} of the virtual device.
+     * @param runUtil A {@link IRunUtil} to run host commands
+     * @return True if the device is started successfully, false otherwise.
+     */
+    private boolean startDevice(
+            ITestInvocationListener listener,
+            int userId,
+            GceAvdInfo info,
+            TestDeviceOptions options,
+            IRunUtil runUtil,
+            Semaphore token)
+            throws InterruptedException {
+        String useridString = MultiUserSetupUtil.getUserNumber(userId);
+        String username = String.format("vsoc-%s", useridString);
+        CommandResult userSetup =
+                MultiUserSetupUtil.prepareRemoteUser(
+                        username, info, options, runUtil, NEW_USER_TIMEOUT);
+        if (userSetup != null) {
+            String errorMsg = String.format("Failed to setup user: %s", userSetup.getStderr());
+            CLog.e(errorMsg);
+            listener.invocationFailed(new RuntimeException(errorMsg));
+            return false;
+        }
+
+        CommandResult homeDirSetup =
+                MultiUserSetupUtil.prepareRemoteHomeDir(
+                        options.getInstanceUser(),
+                        username,
+                        info,
+                        options,
+                        runUtil,
+                        NEW_USER_TIMEOUT);
+        if (homeDirSetup != null) {
+            String errorMsg =
+                    String.format("Failed to setup home dir: %s", homeDirSetup.getStderr());
+            CLog.e(errorMsg);
+            listener.invocationFailed(new RuntimeException(errorMsg));
+            return false;
+        }
+
+        // Create the cvd user if missing
+        CommandResult cvdSetup =
+                MultiUserSetupUtil.addExtraCvdUser(
+                        userId, info, options, runUtil, NEW_USER_TIMEOUT);
+        if (cvdSetup != null) {
+            String errorMsg = String.format("Failed to setup user: %s", cvdSetup.getStderr());
+            CLog.e(errorMsg);
+            listener.invocationFailed(new RuntimeException(errorMsg));
+            return false;
+        }
+
+        // Setup the tuntap interface if needed
+        CommandResult tapSetup =
+                MultiUserSetupUtil.setupNetworkInterface(
+                        userId, info, options, runUtil, NEW_USER_TIMEOUT);
+        if (tapSetup != null) {
+            String errorMsg =
+                    String.format("Failed to setup network interface: %s", tapSetup.getStderr());
+            CLog.e(errorMsg);
+            listener.invocationFailed(new RuntimeException(errorMsg));
+            return false;
+        }
+
+        List<String> startCommand = LaunchCvdHelper.createSimpleDeviceCommand(username, true);
+        if (token != null) {
+            token.acquire();
+        }
+        CommandResult startDeviceRes = null;
+        try {
+            startDeviceRes =
+                    GceManager.remoteSshCommandExecution(
+                            info,
+                            options,
+                            runUtil,
+                            LAUNCH_EXTRA_DEVICE,
+                            Joiner.on(" ").join(startCommand));
+        } finally {
+            if (token != null) {
+                token.release();
+            }
+        }
+        if (!CommandStatus.SUCCESS.equals(startDeviceRes.getStatus())) {
+            String errorMsg =
+                    String.format("Failed to start %s: %s", username, startDeviceRes.getStderr());
+            CLog.e(errorMsg);
+            listener.invocationFailed(new RuntimeException(errorMsg));
+            return false;
+        }
+        return true;
+    }
+
+    /** Thread class that allows to start a device asynchronously. */
+    private class StartDeviceThread extends Thread {
+
+        private ITestInvocationListener mListener;
+        private int mUserId;
+        private GceAvdInfo mInfo;
+        private TestDeviceOptions mOptions;
+        private IRunUtil mRunUtil;
+        private Semaphore mToken;
+
+        private boolean mFinalResult = false;
+
+        public StartDeviceThread(
+                ITestInvocationListener listener,
+                int userId,
+                GceAvdInfo info,
+                TestDeviceOptions options,
+                IRunUtil runUtil,
+                Semaphore token) {
+            super();
+            setDaemon(true);
+            setName(String.format("start-device-thread-vsoc-%s", userId));
+            mListener = listener;
+            mUserId = userId;
+            mInfo = info;
+            mOptions = options;
+            mRunUtil = runUtil;
+            mToken = token;
+        }
+
+        @Override
+        public void run() {
+            try {
+                mFinalResult = startDevice(mListener, mUserId, mInfo, mOptions, mRunUtil, mToken);
+            } catch (InterruptedException e) {
+                CLog.e(e);
+            }
+        }
+
+        /**
+         * Returns the final status of the startDevice. Returns true if it succeeded, false
+         * otherwise.
+         */
+        boolean getFinalStatus() {
+            return mFinalResult;
+        }
+    }
 }
diff --git a/src/com/android/tradefed/invoker/TestInvocation.java b/src/com/android/tradefed/invoker/TestInvocation.java
index 9f6a12b..6a31ff5 100644
--- a/src/com/android/tradefed/invoker/TestInvocation.java
+++ b/src/com/android/tradefed/invoker/TestInvocation.java
@@ -717,6 +717,7 @@
                             context.getSerials());
                     // Log the chunk of parent host_log before sharding
                     reportHostLog(listener, config, TRADEFED_LOG_NAME + BEFORE_SHARDING_SUFFIX);
+                    config.getLogSaver().invocationEnded(0L);
                     return;
                 }
             }
@@ -803,4 +804,16 @@
                 return new InvocationExecution();
         }
     }
+
+    /** Prints a delimiter for a given Stage of the invocation. */
+    public static void printStageDelimiter(Stage phase, boolean end) {
+        String startEnd = end ? "ENDING" : "STARTING";
+        String message = String.format("===== %s PHASE %s =====", phase, startEnd);
+        String limit = "";
+        for (int i = 0; i < message.length(); i++) {
+            limit += "=";
+        }
+        String finalFormat = String.format("\n%s\n%s\n%s", limit, message, limit);
+        CLog.d(finalFormat);
+    }
 }
diff --git a/src/com/android/tradefed/invoker/shard/ShardHelper.java b/src/com/android/tradefed/invoker/shard/ShardHelper.java
index 64e326c..4aa1c06 100644
--- a/src/com/android/tradefed/invoker/shard/ShardHelper.java
+++ b/src/com/android/tradefed/invoker/shard/ShardHelper.java
@@ -106,6 +106,7 @@
         ShardMasterResultForwarder resultCollector =
                 new ShardMasterResultForwarder(buildMasterShardListeners(config), expectedShard);
 
+        config.getLogSaver().invocationStarted(context);
         resultCollector.invocationStarted(context);
         synchronized (shardableTests) {
             // When shardCount is available only create 1 poller per shard
diff --git a/src/com/android/tradefed/result/IResultSummaryReceiver.java b/src/com/android/tradefed/result/IResultSummaryReceiver.java
new file mode 100644
index 0000000..23644e8
--- /dev/null
+++ b/src/com/android/tradefed/result/IResultSummaryReceiver.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2019 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.
+ */
+package com.android.tradefed.result;
+
+import com.android.tradefed.result.proto.SummaryRecordProto.SummaryRecord;
+
+/**
+ * Interface describing an {@link ITestInvocationListener} that can receive a summary of the
+ * invocation only instead of the full results.
+ */
+public interface IResultSummaryReceiver {
+
+    /**
+     * Callback where the {@link SummaryRecord} of the results is passed. Will always be called
+     * before {@link ITestInvocationListener#invocationEnded(long)}.
+     *
+     * @param summary a {@link SummaryRecord} containing the summary of the invocation.
+     */
+    public void setResultSummary(SummaryRecord summary);
+}
diff --git a/src/com/android/tradefed/result/LogSaverResultForwarder.java b/src/com/android/tradefed/result/LogSaverResultForwarder.java
index 6e379a2..14d9f84 100644
--- a/src/com/android/tradefed/result/LogSaverResultForwarder.java
+++ b/src/com/android/tradefed/result/LogSaverResultForwarder.java
@@ -18,12 +18,14 @@
 
 import com.android.tradefed.invoker.IInvocationContext;
 import com.android.tradefed.log.LogUtil.CLog;
+import com.android.tradefed.result.proto.SummaryRecordProto.SummaryRecord;
 
 import java.io.IOException;
 import java.util.List;
 
 /** A {@link ResultForwarder} for saving logs with the global file saver. */
-public class LogSaverResultForwarder extends ResultForwarder implements ILogSaverListener {
+public class LogSaverResultForwarder extends ResultForwarder
+        implements ILogSaverListener, IResultSummaryReceiver {
 
     ILogSaver mLogSaver;
 
@@ -132,4 +134,20 @@
             }
         }
     }
+
+    /** {@inheritDoc} */
+    @Override
+    public void setResultSummary(SummaryRecord summary) {
+        for (ITestInvocationListener listener : getListeners()) {
+            try {
+                // Forward the logAssociation call
+                if (listener instanceof IResultSummaryReceiver) {
+                    ((IResultSummaryReceiver) listener).setResultSummary(summary);
+                }
+            } catch (RuntimeException e) {
+                CLog.e("Failed to setResultSummary on %s", listener);
+                CLog.e(e);
+            }
+        }
+    }
 }
\ No newline at end of file
diff --git a/src/com/android/tradefed/result/proto/ProtoResultParser.java b/src/com/android/tradefed/result/proto/ProtoResultParser.java
index 83f9216..3363053 100644
--- a/src/com/android/tradefed/result/proto/ProtoResultParser.java
+++ b/src/com/android/tradefed/result/proto/ProtoResultParser.java
@@ -31,6 +31,7 @@
 import com.android.tradefed.result.proto.LogFileProto.LogFileInfo;
 import com.android.tradefed.result.proto.TestRecordProto.ChildReference;
 import com.android.tradefed.result.proto.TestRecordProto.TestRecord;
+import com.android.tradefed.testtype.suite.ModuleDefinition;
 
 import com.google.common.base.Strings;
 import com.google.protobuf.Any;
@@ -254,6 +255,16 @@
         try {
             IInvocationContext moduleContext =
                     InvocationContext.fromProto(anyDescription.unpack(Context.class));
+            String message = "Test module started proto";
+            if (moduleContext.getAttributes().containsKey(ModuleDefinition.MODULE_ID)) {
+                message +=
+                        (": "
+                                + moduleContext
+                                        .getAttributes()
+                                        .getUniqueMap()
+                                        .get(ModuleDefinition.MODULE_ID));
+            }
+            log(message);
             mListener.testModuleStarted(moduleContext);
         } catch (InvalidProtocolBufferException e) {
             throw new RuntimeException(e);
@@ -281,16 +292,14 @@
 
     private void handleTestRunStart(TestRecord runProto) {
         String id = runProto.getTestRecordId();
-        log("Test run started proto: %s", id);
-        //if (runProto.getAttemptId() != 0) {
+        log(
+                "Test run started proto: %s. Expected tests: %s. Attempt: %s",
+                id, runProto.getNumExpectedChildren(), runProto.getAttemptId());
         mListener.testRunStarted(
                 id,
                 (int) runProto.getNumExpectedChildren(),
                 (int) runProto.getAttemptId(),
                 timeStampToMillis(runProto.getStartTime()));
-        /*} else {
-            mListener.testRunStarted(id, (int) runProto.getNumExpectedChildren(), 0, timeStampToMillis(runProto.getStartTime()));
-        }*/
     }
 
     private void handleTestRunEnd(TestRecord runProto) {
diff --git a/src/com/android/tradefed/result/suite/XmlSuiteResultFormatter.java b/src/com/android/tradefed/result/suite/XmlSuiteResultFormatter.java
index 2db6a79..1d8e981 100644
--- a/src/com/android/tradefed/result/suite/XmlSuiteResultFormatter.java
+++ b/src/com/android/tradefed/result/suite/XmlSuiteResultFormatter.java
@@ -35,6 +35,7 @@
 import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
 import com.google.common.xml.XmlEscapers;
+import com.google.gson.Gson;
 
 import org.xmlpull.v1.XmlPullParser;
 import org.xmlpull.v1.XmlPullParserException;
@@ -103,6 +104,9 @@
 
     private static final String RESULT_ATTR = "result";
     private static final String RESULT_TAG = "Result";
+    private static final String RUN_HISTORY = "run_history";
+    private static final String RUN_HISTORY_TAG = "RunHistory";
+    private static final String RUN_TAG = "Run";
     private static final String RUNTIME_ATTR = "runtime";
     private static final String SCREENSHOT_TAG = "Screenshot";
     private static final String SKIPPED_ATTR = "skipped";
@@ -116,11 +120,16 @@
 
     private static final String LOG_FILE_NAME_ATTR = "file_name";
 
+    /** Helper object for JSON conversion. */
+    private static final class RunHistory {
+        public long startTime;
+        public long endTime;
+    }
+
     /**
      * Allows to add some attributes to the <Result> tag via {@code serializer.attribute}.
      *
-     * @param serializer
-     * @throws IOException
+     * @param serializer The object that serializes an XML suite result.
      */
     public void addSuiteAttributes(XmlSerializer serializer)
             throws IllegalArgumentException, IllegalStateException, IOException {
@@ -132,7 +141,7 @@
      *
      * @param parser The parser where to read the attributes from.
      * @param context The {@link IInvocationContext} where to put the attributes.
-     * @throws XmlPullParserException
+     * @throws XmlPullParserException When XmlPullParser fails.
      */
     public void parseSuiteAttributes(XmlPullParser parser, IInvocationContext context)
             throws XmlPullParserException {
@@ -142,11 +151,8 @@
     /**
      * Allows to add some attributes to the <Build> tag via {@code serializer.attribute}.
      *
-     * @param serializer
-     * @param holder
-     * @throws IllegalArgumentException
-     * @throws IllegalStateException
-     * @throws IOException
+     * @param serializer The object that serializes an XML suite result.
+     * @param holder An object that contains information to be written to the suite result.
      */
     public void addBuildInfoAttributes(XmlSerializer serializer, SuiteResultHolder holder)
             throws IllegalArgumentException, IllegalStateException, IOException {
@@ -158,7 +164,7 @@
      *
      * @param parser The parser where to read the attributes from.
      * @param context The {@link IInvocationContext} where to put the attributes.
-     * @throws XmlPullParserException
+     * @throws XmlPullParserException When XmlPullParser fails.
      */
     public void parseBuildInfoAttributes(XmlPullParser parser, IInvocationContext context)
             throws XmlPullParserException {
@@ -237,6 +243,21 @@
         addBuildInfoAttributes(serializer, holder);
         serializer.endTag(NS, BUILD_TAG);
 
+        // Run History
+        String runHistoryJson = holder.context.getAttributes().getUniqueMap().get(RUN_HISTORY);
+        if (runHistoryJson != null) {
+            serializer.startTag(NS, RUN_HISTORY_TAG);
+            Gson gson = new Gson();
+            RunHistory[] runHistories = gson.fromJson(runHistoryJson, RunHistory[].class);
+            for (RunHistory runHistory : runHistories) {
+                serializer.startTag(NS, RUN_TAG);
+                serializer.attribute(NS, START_TIME_ATTR, String.valueOf(runHistory.startTime));
+                serializer.attribute(NS, END_TIME_ATTR, String.valueOf(runHistory.endTime));
+                serializer.endTag(NS, RUN_TAG);
+            }
+            serializer.endTag(NS, RUN_HISTORY_TAG);
+        }
+
         // Summary
         serializer.startTag(NS, SUMMARY_TAG);
         serializer.attribute(NS, PASS_ATTR, Long.toString(holder.passedTests));
@@ -460,6 +481,16 @@
             parser.require(XmlPullParser.END_TAG, NS, BUILD_TAG);
 
             parser.nextTag();
+            boolean hasRunHistoryTag = true;
+            try {
+                parser.require(XmlPullParser.START_TAG, NS, RUN_HISTORY_TAG);
+            } catch (XmlPullParserException e) {
+                hasRunHistoryTag = false;
+            }
+            if (hasRunHistoryTag) {
+                handleRunHistoryLevel(parser);
+            }
+
             parser.require(XmlPullParser.START_TAG, NS, SUMMARY_TAG);
 
             invocation.completeModules =
@@ -490,6 +521,18 @@
         return invocation;
     }
 
+    /** Handle the parsing and replay of all run history information. */
+    private void handleRunHistoryLevel(XmlPullParser parser)
+            throws IOException, XmlPullParserException {
+        while (parser.nextTag() == XmlPullParser.START_TAG) {
+            parser.require(XmlPullParser.START_TAG, NS, RUN_TAG);
+            parser.nextTag();
+            parser.require(XmlPullParser.END_TAG, NS, RUN_TAG);
+        }
+        parser.require(XmlPullParser.END_TAG, NS, RUN_HISTORY_TAG);
+        parser.nextTag();
+    }
+
     /**
      * Handle the parsing and replay of all the information inside a module (class, method,
      * failures).
diff --git a/src/com/android/tradefed/sandbox/TradefedSandbox.java b/src/com/android/tradefed/sandbox/TradefedSandbox.java
index a518f95..dfa544c 100644
--- a/src/com/android/tradefed/sandbox/TradefedSandbox.java
+++ b/src/com/android/tradefed/sandbox/TradefedSandbox.java
@@ -195,7 +195,10 @@
                     getTradefedSandboxEnvironment(
                             context,
                             config,
-                            QuotationAwareTokenizer.tokenizeLine(config.getCommandLine()));
+                            QuotationAwareTokenizer.tokenizeLine(
+                                    config.getCommandLine(),
+                                    /** no logging */
+                                    false));
         } catch (ConfigurationException e) {
             return e;
         }
diff --git a/src/com/android/tradefed/suite/checker/UserChecker.java b/src/com/android/tradefed/suite/checker/UserChecker.java
index d23b88c..aa84de1 100644
--- a/src/com/android/tradefed/suite/checker/UserChecker.java
+++ b/src/com/android/tradefed/suite/checker/UserChecker.java
@@ -142,7 +142,7 @@
         DeviceUserState(ITestDevice device) throws DeviceNotAvailableException {
             mCurrentUser = device.getCurrentUser();
             mUsers = device.listUsers();
-            mUserRunningStates = new HashMap(mUsers.size());
+            mUserRunningStates = new HashMap<>(mUsers.size());
             for (Integer userId : mUsers) {
                 mUserRunningStates.put(userId, device.isUserRunning(userId));
             }
diff --git a/src/com/android/tradefed/targetprep/AoaTargetPreparer.java b/src/com/android/tradefed/targetprep/AoaTargetPreparer.java
index 3402882..4e331a3 100644
--- a/src/com/android/tradefed/targetprep/AoaTargetPreparer.java
+++ b/src/com/android/tradefed/targetprep/AoaTargetPreparer.java
@@ -137,6 +137,12 @@
     )
     private long mDeviceTimeout = 60 * 1000;
 
+    @Option(
+        name = "wait-for-device-online",
+        description = "Checks whether the device is online after preparation."
+    )
+    private boolean mWaitForDeviceOnline = true;
+
     @Option(name = "action", description = "AOAv2 action to perform. Can be repeated.")
     private List<String> mActions = new ArrayList<>();
 
@@ -152,11 +158,16 @@
         } catch (RuntimeException e) {
             throw new TargetSetupError(e.getMessage(), e, device.getDeviceDescriptor());
         }
+
+        if (mWaitForDeviceOnline) {
+            // Verify that the device is online after executing AOA actions
+            device.waitForDeviceOnline();
+        }
     }
 
     // Connect to device using its serial number and perform actions
     private void configure(String serialNumber) throws DeviceNotAvailableException {
-        try (UsbHelper usb = new UsbHelper();
+        try (UsbHelper usb = getUsbHelper();
                 AoaDevice device =
                         usb.getAoaDevice(serialNumber, Duration.ofMillis(mDeviceTimeout))) {
             if (device == null) {
@@ -167,6 +178,11 @@
         }
     }
 
+    @VisibleForTesting
+    UsbHelper getUsbHelper() {
+        return new UsbHelper();
+    }
+
     // Parse and execute an action
     @VisibleForTesting
     void execute(AoaDevice device, String input) {
diff --git a/src/com/android/tradefed/targetprep/CrashCollector.java b/src/com/android/tradefed/targetprep/CrashCollector.java
index a1770d1..63f80e8 100644
--- a/src/com/android/tradefed/targetprep/CrashCollector.java
+++ b/src/com/android/tradefed/targetprep/CrashCollector.java
@@ -99,6 +99,10 @@
         clearTestFileName();
         addTestFileName(mCrashCollectorPath);
         super.setUp(device, buildInfo);
+        if (getFailedToPushFiles().contains(mCrashCollectorPath)) {
+            CLog.w("Failed to push crash collector binary. Skipping the collection.");
+            return;
+        }
         String crashCollectorPath = String.format("/data/%s/%s",
                 mCrashCollectorPath, mCrashCollectorBinary);
         device.executeShellCommand("chmod 755 " + crashCollectorPath);
diff --git a/src/com/android/tradefed/targetprep/CreateUserPreparer.java b/src/com/android/tradefed/targetprep/CreateUserPreparer.java
new file mode 100644
index 0000000..c379be4
--- /dev/null
+++ b/src/com/android/tradefed/targetprep/CreateUserPreparer.java
@@ -0,0 +1,75 @@
+/*
+ * Copyright (C) 2019 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.
+ */
+package com.android.tradefed.targetprep;
+
+import com.android.tradefed.build.IBuildInfo;
+import com.android.tradefed.device.DeviceNotAvailableException;
+import com.android.tradefed.device.ITestDevice;
+import com.android.tradefed.device.TestDevice;
+import com.android.tradefed.log.LogUtil.CLog;
+
+/** Target preparer for creating user and cleaning it up at the end. */
+public class CreateUserPreparer extends BaseTargetPreparer implements ITargetCleaner {
+
+    private static final String TF_CREATED_USER = "tf_created_user";
+
+    private Integer mOriginalUser = null;
+    private Integer mCreatedUserId = null;
+
+    @Override
+    public void setUp(ITestDevice device, IBuildInfo buildInfo)
+            throws TargetSetupError, BuildError, DeviceNotAvailableException {
+        mOriginalUser = device.getCurrentUser();
+        if (mOriginalUser == TestDevice.INVALID_USER_ID) {
+            mOriginalUser = null;
+            throw new TargetSetupError(
+                    "Failed to get the current user.", device.getDeviceDescriptor());
+        }
+        try {
+            mCreatedUserId = device.createUser(TF_CREATED_USER);
+        } catch (IllegalStateException e) {
+            throw new TargetSetupError("Failed to create user.", e, device.getDeviceDescriptor());
+        }
+        if (!device.switchUser(mCreatedUserId)) {
+            throw new TargetSetupError(
+                    String.format("Failed to switch to user '%s'", mCreatedUserId),
+                    device.getDeviceDescriptor());
+        }
+    }
+
+    @Override
+    public void tearDown(ITestDevice device, IBuildInfo buildInfo, Throwable e)
+            throws DeviceNotAvailableException {
+        if (mCreatedUserId == null) {
+            return;
+        }
+        if (mOriginalUser == null) {
+            return;
+        }
+        if (e instanceof DeviceNotAvailableException) {
+            CLog.d("Skipping teardown due to dnae: %s", e.getMessage());
+            return;
+        }
+        if (!device.switchUser(mOriginalUser)) {
+            CLog.e("Failed to switch back to original user '%s'", mOriginalUser);
+        }
+        if (!device.removeUser(mCreatedUserId)) {
+            CLog.e(
+                    "Failed to delete user %s on device %s",
+                    mCreatedUserId, device.getSerialNumber());
+        }
+    }
+}
diff --git a/src/com/android/tradefed/targetprep/DeviceCleaner.java b/src/com/android/tradefed/targetprep/DeviceCleaner.java
index 3018d7d..4d109a1 100644
--- a/src/com/android/tradefed/targetprep/DeviceCleaner.java
+++ b/src/com/android/tradefed/targetprep/DeviceCleaner.java
@@ -130,7 +130,7 @@
     }
 
     private void turnScreenOff(ITestDevice device) throws DeviceNotAvailableException {
-        String output = device.executeShellCommand("dumpsys power");
+        String output = device.executeShellCommand("dumpsys power | grep 'mScreenOn|mInteractive'");
         int retries = 1;
         // screen on semantics have changed in newest API platform, checking for both signatures
         // to detect screen on state
@@ -140,7 +140,7 @@
             // due to framework initialization, device may not actually turn off screen
             // after boot, recheck screen status with linear backoff
             RunUtil.getDefault().sleep(SCREEN_OFF_RETRY_DELAY_MS * retries);
-            output = device.executeShellCommand("dumpsys power");
+            output = device.executeShellCommand("dumpsys power | grep 'mScreenOn|mInteractive'");
             retries++;
             if (retries > MAX_SCREEN_OFF_RETRY) {
                 CLog.w(String.format("screen still on after %d retries", retries));
diff --git a/src/com/android/tradefed/targetprep/InstallApexModuleTargetPreparer.java b/src/com/android/tradefed/targetprep/InstallApexModuleTargetPreparer.java
index 07f5368..e65f9bd 100644
--- a/src/com/android/tradefed/targetprep/InstallApexModuleTargetPreparer.java
+++ b/src/com/android/tradefed/targetprep/InstallApexModuleTargetPreparer.java
@@ -143,14 +143,15 @@
             CLog.e("Device %s is not available. Teardown() skipped.", device.getSerialNumber());
             return;
         } else {
-            for (String apkPkgName : mApkInstalled) {
-                super.uninstallPackage(device, apkPkgName);
-            }
-            device.deleteFile(APEX_DATA_DIR + "*");
-            device.deleteFile(STAGING_DATA_DIR + "*");
-            device.deleteFile(SESSION_DATA_DIR + "*");
-            if (!mTestApexInfoList.isEmpty()) {
-                device.reboot();
+            if (mTestApexInfoList.isEmpty() && getApkInstalled().isEmpty()) {
+                super.tearDown(device, buildInfo, e);
+            } else {
+                for (String apkPkgName : getApkInstalled()) {
+                    super.uninstallPackage(device, apkPkgName);
+                }
+                if (!mTestApexInfoList.isEmpty()) {
+                    cleanUpStagedAndActiveSession(device);
+                }
             }
         }
     }
@@ -412,17 +413,24 @@
     private void cleanUpStagedAndActiveSession(ITestDevice device)
             throws DeviceNotAvailableException {
         boolean reboot = false;
-        if (!device.executeShellV2Command("ls " + APEX_DATA_DIR).getStdout().isEmpty()) {
+        if (!mTestApexInfoList.isEmpty()) {
             device.deleteFile(APEX_DATA_DIR + "*");
-            reboot = true;
-        }
-        if (!device.executeShellV2Command("ls " + STAGING_DATA_DIR).getStdout().isEmpty()) {
             device.deleteFile(STAGING_DATA_DIR + "*");
-            reboot = true;
-        }
-        if (!device.executeShellV2Command("ls " + SESSION_DATA_DIR).getStdout().isEmpty()) {
             device.deleteFile(SESSION_DATA_DIR + "*");
             reboot = true;
+        } else {
+            if (!device.executeShellV2Command("ls " + APEX_DATA_DIR).getStdout().isEmpty()) {
+                device.deleteFile(APEX_DATA_DIR + "*");
+                reboot = true;
+            }
+            if (!device.executeShellV2Command("ls " + STAGING_DATA_DIR).getStdout().isEmpty()) {
+                device.deleteFile(STAGING_DATA_DIR + "*");
+                reboot = true;
+            }
+            if (!device.executeShellV2Command("ls " + SESSION_DATA_DIR).getStdout().isEmpty()) {
+                device.deleteFile(SESSION_DATA_DIR + "*");
+                reboot = true;
+            }
         }
         if (reboot) {
             device.reboot();
@@ -465,5 +473,10 @@
     protected BundletoolUtil getBundletoolUtil() {
         return mBundletoolUtil;
     }
+
+    @VisibleForTesting
+    protected List<String> getApkInstalled() {
+        return mApkInstalled;
+    }
 }
 
diff --git a/src/com/android/tradefed/targetprep/TestAppInstallSetup.java b/src/com/android/tradefed/targetprep/TestAppInstallSetup.java
index f67ff20..f1b46f7 100644
--- a/src/com/android/tradefed/targetprep/TestAppInstallSetup.java
+++ b/src/com/android/tradefed/targetprep/TestAppInstallSetup.java
@@ -370,6 +370,9 @@
                     device.getDeviceDescriptor());
         }
         if (mCleanup) {
+            if (mPackagesInstalled == null) {
+                mPackagesInstalled = new ArrayList<>();
+            }
             mPackagesInstalled.addAll(packageNames);
         }
     }
diff --git a/src/com/android/tradefed/targetprep/TestFilePushSetup.java b/src/com/android/tradefed/targetprep/TestFilePushSetup.java
index 06f9b5d..8cee953 100644
--- a/src/com/android/tradefed/targetprep/TestFilePushSetup.java
+++ b/src/com/android/tradefed/targetprep/TestFilePushSetup.java
@@ -31,7 +31,9 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.HashSet;
 import java.util.List;
+import java.util.Set;
 
 /**
  * A {@link ITargetPreparer} that pushes one or more files/dirs from a {@link
@@ -63,6 +65,8 @@
             + "when searching for files to push")
     private AltDirBehavior mAltDirBehavior = AltDirBehavior.FALLBACK;
 
+    private Set<String> mFailedToPush = new HashSet<>();
+
     /**
      * Adds a file to the list of items to push
      *
@@ -155,10 +159,11 @@
                             "Could not find test file %s directory in extracted tests.zip",
                             fileName), device.getDeviceDescriptor());
                 } else {
-                    CLog.w(String.format(
-                            "Could not find test file %s directory in extracted tests.zip, but" +
-                            "will continue test setup as throw-if-not-found is set to false",
-                            fileName));
+                    CLog.w(
+                            "Could not find test file %s directory in extracted tests.zip, but will"
+                                    + " continue test setup as throw-if-not-found is set to false",
+                            fileName);
+                    mFailedToPush.add(fileName);
                     continue;
                 }
             }
@@ -197,6 +202,14 @@
         mAltDirBehavior = behavior;
     }
 
+    /**
+     * Returns the set of files that failed to be pushed. Can only be used if 'throw-if-not-found'
+     * is false otherwise the first failed push will throw an exception.
+     */
+    protected Set<String> getFailedToPushFiles() {
+        return mFailedToPush;
+    }
+
     static String getDevicePathFromUserData(String path) {
         return ArrayUtil.join(FileListingService.FILE_SEPARATOR,
                 "", FileListingService.DIRECTORY_DATA, path);
diff --git a/src/com/android/tradefed/testtype/AndroidJUnitTest.java b/src/com/android/tradefed/testtype/AndroidJUnitTest.java
index d9602b0..bc70bb6 100644
--- a/src/com/android/tradefed/testtype/AndroidJUnitTest.java
+++ b/src/com/android/tradefed/testtype/AndroidJUnitTest.java
@@ -39,9 +39,12 @@
 import java.io.File;
 import java.util.ArrayList;
 import java.util.Collection;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.HashSet;
 import java.util.List;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
 import java.util.Set;
 
 /**
@@ -64,6 +67,8 @@
     private static final String INCLUDE_PACKAGE_INST_ARGS_KEY = "package";
     /** instrumentation test runner argument key used for excluding a package */
     private static final String EXCLUDE_PACKAGE_INST_ARGS_KEY = "notPackage";
+    /** instrumentation test runner argument key used for including a test regex */
+    private static final String INCLUDE_REGEX_INST_ARGS_KEY = "tests_regex";
     /** instrumentation test runner argument key used for adding annotation filter */
     private static final String ANNOTATION_INST_ARGS_KEY = "annotation";
     /** instrumentation test runner argument key used for adding notAnnotation filter */
@@ -366,14 +371,19 @@
         List<String> notClassArg = new ArrayList<String>();
         List<String> packageArg = new ArrayList<String>();
         List<String> notPackageArg = new ArrayList<String>();
+        List<String> regexArg = new ArrayList<String>();
         for (String test : mIncludeFilters) {
-            if (isClassOrMethod(test)) {
+            if (isRegex(test)) {
+                regexArg.add(test);
+            } else if (isClassOrMethod(test)) {
                 classArg.add(test);
             } else {
                 packageArg.add(test);
             }
         }
         for (String test : mExcludeFilters) {
+            // tests_regex doesn't support exclude-filter. Therefore, only check if the filter is
+            // for class/method or package.
             if (isClassOrMethod(test)) {
                 notClassArg.add(test);
             } else {
@@ -396,6 +406,16 @@
             runner.addInstrumentationArg(EXCLUDE_PACKAGE_INST_ARGS_KEY,
                     ArrayUtil.join(",", notPackageArg));
         }
+        if (!regexArg.isEmpty()) {
+            String regexFilter;
+            if (regexArg.size() == 1) {
+                regexFilter = regexArg.get(0);
+            } else {
+                Collections.sort(regexArg);
+                regexFilter = "\"(" + ArrayUtil.join("|", regexArg) + ")\"";
+            }
+            runner.addInstrumentationArg(INCLUDE_REGEX_INST_ARGS_KEY, regexFilter);
+        }
         if (!mIncludeAnnotation.isEmpty()) {
             runner.addInstrumentationArg(ANNOTATION_INST_ARGS_KEY,
                     ArrayUtil.join(",", mIncludeAnnotation));
@@ -499,6 +519,24 @@
         return false;
     }
 
+    /** Return if a string is a regex for filter. */
+    @VisibleForTesting
+    public boolean isRegex(String filter) {
+        // If filter contains any special regex character, return true.
+        // Throw RuntimeException if the regex is invalid.
+        if (Pattern.matches(".*[\\?\\*\\^\\$\\(\\)\\[\\]\\{\\}\\|\\\\].*", filter)) {
+            try {
+                Pattern.compile(filter);
+            } catch (PatternSyntaxException e) {
+                CLog.e("Filter %s is not a valid regular expression string.", filter);
+                throw new RuntimeException(e);
+            }
+            return true;
+        }
+
+        return false;
+    }
+
     /**
      * Helper to return if the runner is one that support sharding.
      */
diff --git a/src/com/android/tradefed/testtype/suite/ITestSuite.java b/src/com/android/tradefed/testtype/suite/ITestSuite.java
index f74ea47..b44c3c8 100644
--- a/src/com/android/tradefed/testtype/suite/ITestSuite.java
+++ b/src/com/android/tradefed/testtype/suite/ITestSuite.java
@@ -29,12 +29,14 @@
 import com.android.tradefed.device.ITestDevice;
 import com.android.tradefed.device.NullDevice;
 import com.android.tradefed.device.StubDevice;
+import com.android.tradefed.device.cloud.NestedRemoteDevice;
 import com.android.tradefed.device.metric.CollectorHelper;
 import com.android.tradefed.device.metric.IMetricCollector;
 import com.android.tradefed.device.metric.IMetricCollectorReceiver;
 import com.android.tradefed.invoker.IInvocationContext;
 import com.android.tradefed.invoker.shard.token.ITokenRequest;
 import com.android.tradefed.invoker.shard.token.TokenProperty;
+import com.android.tradefed.log.ITestLogger;
 import com.android.tradefed.log.LogUtil.CLog;
 import com.android.tradefed.metrics.proto.MetricMeasurement.Metric;
 import com.android.tradefed.result.ITestInvocationListener;
@@ -283,6 +285,12 @@
     private boolean mIntraModuleSharding = true;
 
     @Option(
+        name = "isolated-module",
+        description = "Whether or not to attempt the module isolation between modules"
+    )
+    private boolean mIsolatedModule = false;
+
+    @Option(
         name = "reboot-before-test",
         description = "Reboot the device before the test suite starts."
     )
@@ -585,6 +593,8 @@
                     // execution
                     listener.testModuleEnded();
                 }
+                // Module isolation routine
+                moduleIsolation(mContext, listener);
             }
         } catch (DeviceNotAvailableException e) {
             CLog.e(
@@ -605,6 +615,31 @@
     }
 
     /**
+     * Routine that attempt to reset a device between modules in order to provide isolation.
+     *
+     * @param context The invocation context.
+     * @param logger A logger where extra logs can be saved.
+     * @throws DeviceNotAvailableException
+     */
+    private void moduleIsolation(IInvocationContext context, ITestLogger logger)
+            throws DeviceNotAvailableException {
+        // TODO: we can probably make it smarter: Did any test ran for example?
+        ITestDevice device = context.getDevices().get(0);
+        if (mIsolatedModule && (device instanceof NestedRemoteDevice)) {
+            boolean res =
+                    ((NestedRemoteDevice) device)
+                            .resetVirtualDevice(logger, context.getBuildInfos().get(0));
+            if (!res) {
+                String serial = device.getSerialNumber();
+                throw new DeviceNotAvailableException(
+                        String.format(
+                                "Failed to reset the AVD '%s' during module isolation.", serial),
+                        serial);
+            }
+        }
+    }
+
+    /**
      * Helper method that handle running a single module logic.
      *
      * @param module The {@link ModuleDefinition} to be ran.
diff --git a/src/com/android/tradefed/testtype/suite/SuiteModuleLoader.java b/src/com/android/tradefed/testtype/suite/SuiteModuleLoader.java
index 4e94331..dbc3d9f 100644
--- a/src/com/android/tradefed/testtype/suite/SuiteModuleLoader.java
+++ b/src/com/android/tradefed/testtype/suite/SuiteModuleLoader.java
@@ -235,9 +235,7 @@
                         && !config.getConfigurationDescription()
                                 .getSuiteTags()
                                 .contains(suiteTag)) {
-                    CLog.d(
-                            "Configuration %s does not include the suite-tag '%s'. Ignoring it.",
-                            configFullName, suiteTag);
+                    // Do not print here, it could leave several hundred lines of logs.
                     continue;
                 }
 
diff --git a/src/com/android/tradefed/util/FileUtil.java b/src/com/android/tradefed/util/FileUtil.java
index ec4897c..9d919e9 100644
--- a/src/com/android/tradefed/util/FileUtil.java
+++ b/src/com/android/tradefed/util/FileUtil.java
@@ -175,8 +175,7 @@
     }
 
     public static boolean chmod(File file, String perms) {
-        Log.d(LOG_TAG, String.format("Attempting to chmod %s to %s",
-                file.getAbsolutePath(), perms));
+        // No need to print, runUtil already prints the command
         CommandResult result =
                 RunUtil.getDefault().runTimedCmd(10 * 1000, sChmod, perms, file.getAbsolutePath());
         return result.getStatus().equals(CommandStatus.SUCCESS);
diff --git a/src/com/android/tradefed/util/GCSCommon.java b/src/com/android/tradefed/util/GCSCommon.java
index 8b4d8e8..c1d738d 100644
--- a/src/com/android/tradefed/util/GCSCommon.java
+++ b/src/com/android/tradefed/util/GCSCommon.java
@@ -15,6 +15,7 @@
  */
 package com.android.tradefed.util;
 
+import com.android.tradefed.host.HostOptions;
 import com.android.tradefed.log.LogUtil.CLog;
 
 import com.google.api.client.googleapis.auth.oauth2.GoogleCredential;
@@ -32,6 +33,8 @@
  * GCSFileUploader}.
  */
 public abstract class GCSCommon {
+    /** This is the key for {@link HostOptions}'s service-account-json-key-file option. */
+    private static final String GCS_JSON_KEY = "gcs-json-key";
 
     protected static final int DEFAULT_TIMEOUT = 10 * 60 * 1000; // 10minutes
 
@@ -49,10 +52,9 @@
         try {
             if (mStorage == null) {
                 if (mJsonKeyFile != null && mJsonKeyFile.exists()) {
-                    CLog.d("Using json key file %s.", mJsonKeyFile);
                     credential =
-                            GoogleApiClientUtil.createCredentialFromJsonKeyFile(
-                                    mJsonKeyFile, scopes);
+                            GoogleApiClientUtil.createCredential(
+                                    scopes, mJsonKeyFile, GCS_JSON_KEY);
                 } else {
                     CLog.d("Using local authentication.");
                     try {
diff --git a/src/com/android/tradefed/util/GoogleApiClientUtil.java b/src/com/android/tradefed/util/GoogleApiClientUtil.java
index 5108fbb..e8a720c 100644
--- a/src/com/android/tradefed/util/GoogleApiClientUtil.java
+++ b/src/com/android/tradefed/util/GoogleApiClientUtil.java
@@ -15,6 +15,8 @@
  */
 package com.android.tradefed.util;
 
+import com.android.tradefed.config.GlobalConfiguration;
+import com.android.tradefed.host.HostOptions;
 import com.android.tradefed.log.LogUtil.CLog;
 
 import com.google.api.client.googleapis.auth.oauth2.GoogleCredential;
@@ -26,19 +28,31 @@
 import com.google.api.client.http.HttpUnsuccessfulResponseHandler;
 import com.google.api.client.json.jackson2.JacksonFactory;
 import com.google.api.client.util.ExponentialBackOff;
+import com.google.common.annotations.VisibleForTesting;
 
 import java.io.File;
 import java.io.FileInputStream;
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.security.GeneralSecurityException;
+import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collection;
+import java.util.List;
 
 /** Utils for create Google API client. */
 public class GoogleApiClientUtil {
 
     public static final String APP_NAME = "tradefed";
-  
+    private static GoogleApiClientUtil sInstance = null;
+
+    private static GoogleApiClientUtil getInstance() {
+        if (sInstance == null) {
+            sInstance = new GoogleApiClientUtil();
+        }
+        return sInstance;
+    }
+
     /**
      * Create credential from json key file.
      *
@@ -51,6 +65,12 @@
      */
     public static GoogleCredential createCredentialFromJsonKeyFile(
             File file, Collection<String> scopes) throws IOException, GeneralSecurityException {
+        return getInstance().doCreateCredentialFromJsonKeyFile(file, scopes);
+    }
+
+    @VisibleForTesting
+    GoogleCredential doCreateCredentialFromJsonKeyFile(File file, Collection<String> scopes)
+            throws IOException, GeneralSecurityException {
         return GoogleCredential.fromStream(
                         new FileInputStream(file),
                         GoogleNetHttpTransport.newTrustedTransport(),
@@ -59,6 +79,87 @@
     }
 
     /**
+     * Try to create credential with different key files or from local host.
+     *
+     * <p>1. If primaryKeyFile is set, try to use it to create credential. 2. Try to get
+     * corresponding key files from {@link HostOptions}. 3. Try to use backup key files. 4. Use
+     * local default credential.
+     *
+     * @param scopes scopes for the credential.
+     * @param primaryKeyFile the primary json key file; it can be null.
+     * @param hostOptionKeyFileName {@link HostOptions}'service-account-json-key-file option's key;
+     *     it can be null.
+     * @param backupKeyFiles backup key files.
+     * @return a {@link GoogleCredential}
+     * @throws IOException
+     * @throws GeneralSecurityException
+     */
+    public static GoogleCredential createCredential(
+            Collection<String> scopes,
+            File primaryKeyFile,
+            String hostOptionKeyFileName,
+            File... backupKeyFiles)
+            throws IOException, GeneralSecurityException {
+        return getInstance()
+                .doCreateCredential(scopes, primaryKeyFile, hostOptionKeyFileName, backupKeyFiles);
+    }
+
+    @VisibleForTesting
+    GoogleCredential doCreateCredential(
+            Collection<String> scopes,
+            File primaryKeyFile,
+            String hostOptionKeyFileName,
+            File... backupKeyFiles)
+            throws IOException, GeneralSecurityException {
+        List<File> keyFiles = new ArrayList<File>();
+        if (primaryKeyFile != null) {
+            keyFiles.add(primaryKeyFile);
+        }
+        File hostOptionKeyFile = null;
+        if (hostOptionKeyFileName != null) {
+            try {
+                hostOptionKeyFile =
+                        GlobalConfiguration.getInstance()
+                                .getHostOptions()
+                                .getServiceAccountJsonKeyFiles()
+                                .get(hostOptionKeyFileName);
+                if (hostOptionKeyFile != null) {
+                    keyFiles.add(hostOptionKeyFile);
+                }
+            } catch (IllegalStateException e) {
+                CLog.d("Global configuration haven't been initialized.");
+            }
+        }
+        keyFiles.addAll(Arrays.asList(backupKeyFiles));
+        for (File keyFile : keyFiles) {
+            if (keyFile != null) {
+                if (keyFile.exists() && keyFile.canRead()) {
+                    CLog.d("Using %s.", keyFile.getAbsolutePath());
+                    return doCreateCredentialFromJsonKeyFile(keyFile, scopes);
+                } else {
+                    CLog.i("No access to %s.", keyFile.getAbsolutePath());
+                }
+            }
+        }
+        return doCreateDefaultCredential(scopes);
+    }
+
+    @VisibleForTesting
+    GoogleCredential doCreateDefaultCredential(Collection<String> scopes) throws IOException {
+        try {
+            CLog.d("Using local authentication.");
+            return GoogleCredential.getApplicationDefault().createScoped(scopes);
+        } catch (IOException e) {
+            CLog.e(
+                    "Try 'gcloud auth application-default login' to login for "
+                            + "personal account; Or 'export "
+                            + "GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json' "
+                            + "for service account.");
+            throw e;
+        }
+    }
+
+    /**
      * Create credential from p12 file for service account.
      *
      * @deprecated It's better to use json key file, since p12 is deprecated by Google App Engine.
diff --git a/src/com/android/tradefed/util/IRunUtil.java b/src/com/android/tradefed/util/IRunUtil.java
index 4113588..3a36b7d 100644
--- a/src/com/android/tradefed/util/IRunUtil.java
+++ b/src/com/android/tradefed/util/IRunUtil.java
@@ -46,6 +46,11 @@
          * Cancel the operation.
          */
         public void cancel();
+
+        /** Returns the command associated with the runnable. */
+        public default List<String> getCommand() {
+            return null;
+        }
     }
 
     /**
diff --git a/src/com/android/tradefed/util/RunUtil.java b/src/com/android/tradefed/util/RunUtil.java
index 593559b..fd27843 100644
--- a/src/com/android/tradefed/util/RunUtil.java
+++ b/src/com/android/tradefed/util/RunUtil.java
@@ -28,6 +28,7 @@
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
 import java.util.HashSet;
@@ -168,7 +169,8 @@
                 createProcessBuilder(command),
                 stdout,
                 stderr,
-                /* inputRedirect= */ null);
+                /* inputRedirect= */ null,
+                false);
     }
 
     /** {@inheritDoc} */
@@ -252,7 +254,8 @@
                         createProcessBuilder(command),
                         /* stdoutStream= */ null,
                         /* stderrStream= */ null,
-                        inputRedirect);
+                        inputRedirect,
+                        true);
         CommandStatus status = runTimed(timeout, osRunnable, true);
         CommandResult result = osRunnable.getResult();
         result.setStatus(status);
@@ -264,7 +267,7 @@
      */
     @Override
     public CommandResult runTimedCmdSilently(final long timeout, final String... command) {
-        RunnableResult osRunnable = new RunnableResult(null, createProcessBuilder(command));
+        RunnableResult osRunnable = new RunnableResult(null, createProcessBuilder(command), false);
         CommandStatus status = runTimed(timeout, osRunnable, false);
         CommandResult result = osRunnable.getResult();
         result.setStatus(status);
@@ -338,9 +341,11 @@
         RunnableNotifier runThread = new RunnableNotifier(runnable, logErrors);
         if (logErrors) {
             if (timeout > 0l) {
-                CLog.d("Running command with timeout: %dms", timeout);
+                CLog.d(
+                        "Running command %s with timeout: %s",
+                        runnable.getCommand(), TimeUtil.formatElapsedTime(timeout));
             } else {
-                CLog.d("Running command without timeout.");
+                CLog.d("Running command %s without timeout.", runnable.getCommand());
             }
         }
         CommandStatus status = CommandStatus.TIMED_OUT;
@@ -567,9 +572,14 @@
         private boolean mCreatedStderrStream = false;
         private final Object mLock = new Object();
         private boolean mCancelled = false;
+        private boolean mLogErrors = true;
 
         RunnableResult(final String input, final ProcessBuilder processBuilder) {
-            this(input, processBuilder, null, null, null);
+            this(input, processBuilder, null, null, null, true);
+        }
+
+        RunnableResult(final String input, final ProcessBuilder processBuilder, boolean logErrors) {
+            this(input, processBuilder, null, null, null, logErrors);
         }
 
         /**
@@ -584,9 +594,11 @@
                 final ProcessBuilder processBuilder,
                 OutputStream stdoutStream,
                 OutputStream stderrStream,
-                File inputRedirect) {
+                File inputRedirect,
+                boolean logErrors) {
             mProcessBuilder = processBuilder;
             mInput = input;
+            mLogErrors = logErrors;
 
             mInputRedirect = inputRedirect;
             if (mInputRedirect != null) {
@@ -606,6 +618,11 @@
             mStdErr = stderrStream != null ? stderrStream : new ByteArrayOutputStream();
         }
 
+        @Override
+        public List<String> getCommand() {
+            return new ArrayList<>(mProcessBuilder.command());
+        }
+
         public CommandResult getResult() {
             return mCommandResult;
         }
@@ -627,7 +644,6 @@
                     return false;
                 }
                 mExecutionThread = Thread.currentThread();
-                CLog.d("Running %s", mProcessBuilder.command());
                 mProcess = startProcess();
                 if (mInput != null) {
                     BufferedOutputStream processStdin =
@@ -697,7 +713,7 @@
 
             if (rc != null && rc == 0) {
                 return true;
-            } else {
+            } else if (mLogErrors) {
                 CLog.d("%s command failed. return code %d", mProcessBuilder.command(), rc);
             }
             return false;
diff --git a/src/com/android/tradefed/util/UserUtil.java b/src/com/android/tradefed/util/UserUtil.java
index 608b84c..cdab730 100644
--- a/src/com/android/tradefed/util/UserUtil.java
+++ b/src/com/android/tradefed/util/UserUtil.java
@@ -77,8 +77,9 @@
             case SECONDARY:
                 switchToSecondaryUser(device);
                 return;
+            default:
+                throw new RuntimeException("userType case not covered: " + userType);
         }
-        throw new RuntimeException("userType case not covered: " + userType);
     }
 
     /**
diff --git a/tests/src/com/android/tradefed/UnitTests.java b/tests/src/com/android/tradefed/UnitTests.java
index 18a0b8d..fe55b89 100644
--- a/tests/src/com/android/tradefed/UnitTests.java
+++ b/tests/src/com/android/tradefed/UnitTests.java
@@ -182,6 +182,7 @@
 import com.android.tradefed.targetprep.AoaTargetPreparerTest;
 import com.android.tradefed.targetprep.AppSetupTest;
 import com.android.tradefed.targetprep.BuildInfoAttributePreparerTest;
+import com.android.tradefed.targetprep.CreateUserPreparerTest;
 import com.android.tradefed.targetprep.DefaultTestsZipInstallerTest;
 import com.android.tradefed.targetprep.DeviceFlashPreparerTest;
 import com.android.tradefed.targetprep.DeviceSetupTest;
@@ -296,6 +297,7 @@
 import com.android.tradefed.util.FileUtilTest;
 import com.android.tradefed.util.FixedByteArrayOutputStreamTest;
 import com.android.tradefed.util.GCSFileDownloaderTest;
+import com.android.tradefed.util.GoogleApiClientUtilTest;
 import com.android.tradefed.util.HprofAllocSiteParserTest;
 import com.android.tradefed.util.JUnitXmlParserTest;
 import com.android.tradefed.util.KeyguardControllerStateTest;
@@ -567,6 +569,7 @@
     AoaTargetPreparerTest.class,
     AppSetupTest.class,
     BuildInfoAttributePreparerTest.class,
+    CreateUserPreparerTest.class,
     DefaultTestsZipInstallerTest.class,
     DeviceFlashPreparerTest.class,
     DeviceSetupTest.class,
@@ -728,6 +731,7 @@
     FileUtilTest.class,
     FixedByteArrayOutputStreamTest.class,
     GCSFileDownloaderTest.class,
+    GoogleApiClientUtilTest.class,
     HprofAllocSiteParserTest.class,
     JUnitXmlParserTest.class,
     KeyguardControllerStateTest.class,
diff --git a/tests/src/com/android/tradefed/config/ArgsOptionParserTest.java b/tests/src/com/android/tradefed/config/ArgsOptionParserTest.java
index f05c099..4013cde 100644
--- a/tests/src/com/android/tradefed/config/ArgsOptionParserTest.java
+++ b/tests/src/com/android/tradefed/config/ArgsOptionParserTest.java
@@ -373,6 +373,21 @@
     }
 
     /**
+     * Test passing an single argument for an object that has one option specified, which is the
+     * empty string.
+     */
+    public void testParse_oneMapArg_emptyString() throws ConfigurationException {
+        MapStringOptionSource object = new MapStringOptionSource();
+        ArgsOptionParser parser = new ArgsOptionParser(object);
+        final String expectedKey = "abc";
+        final String expectedValue = "";
+        parser.parse(new String[] {"--my_option", expectedKey + "="});
+        assertNotNull(object.mMyOption);
+        assertEquals(1, object.mMyOption.size());
+        assertEquals(expectedValue, object.mMyOption.get(expectedKey));
+    }
+
+    /**
      * Test passing an single argument for an object that has one option specified.
      */
     public void testParseMapArg_mismatchKeyType() throws ConfigurationException {
diff --git a/tests/src/com/android/tradefed/device/cloud/NestedRemoteDeviceTest.java b/tests/src/com/android/tradefed/device/cloud/NestedRemoteDeviceTest.java
index 88c94bd..1248d98 100644
--- a/tests/src/com/android/tradefed/device/cloud/NestedRemoteDeviceTest.java
+++ b/tests/src/com/android/tradefed/device/cloud/NestedRemoteDeviceTest.java
@@ -20,9 +20,11 @@
 
 import com.android.ddmlib.IDevice;
 import com.android.tradefed.build.BuildInfo;
+import com.android.tradefed.config.OptionSetter;
 import com.android.tradefed.device.DeviceNotAvailableException;
 import com.android.tradefed.device.IDeviceMonitor;
 import com.android.tradefed.device.IDeviceStateMonitor;
+import com.android.tradefed.device.TestDeviceOptions;
 import com.android.tradefed.log.ITestLogger;
 import com.android.tradefed.util.CommandResult;
 import com.android.tradefed.util.CommandStatus;
@@ -46,18 +48,26 @@
     private ITestLogger mMockLogger;
 
     @Before
-    public void setUp() {
+    public void setUp() throws Exception {
         mMockIDevice = Mockito.mock(IDevice.class);
         mMockStateMonitor = Mockito.mock(IDeviceStateMonitor.class);
         mMockMonitor = Mockito.mock(IDeviceMonitor.class);
         mMockRunUtil = Mockito.mock(IRunUtil.class);
         mMockLogger = Mockito.mock(ITestLogger.class);
+        TestDeviceOptions options = new TestDeviceOptions();
+        OptionSetter setter = new OptionSetter(options);
+        setter.setOptionValue(TestDeviceOptions.INSTANCE_TYPE_OPTION, "CUTTLEFISH");
         mDevice =
                 new NestedRemoteDevice(mMockIDevice, mMockStateMonitor, mMockMonitor) {
                     @Override
                     protected IRunUtil getRunUtil() {
                         return mMockRunUtil;
                     }
+
+                    @Override
+                    public TestDeviceOptions getOptions() {
+                        return options;
+                    }
                 };
     }
 
diff --git a/tests/src/com/android/tradefed/invoker/RemoteInvocationExecutionTest.java b/tests/src/com/android/tradefed/invoker/RemoteInvocationExecutionTest.java
index 23d02e0..7427f71 100644
--- a/tests/src/com/android/tradefed/invoker/RemoteInvocationExecutionTest.java
+++ b/tests/src/com/android/tradefed/invoker/RemoteInvocationExecutionTest.java
@@ -16,19 +16,35 @@
 package com.android.tradefed.invoker;
 
 import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
 import static org.junit.Assert.assertTrue;
+import static org.mockito.Mockito.verify;
 
 import com.android.tradefed.build.BuildRetrievalError;
 import com.android.tradefed.build.IBuildInfo;
 import com.android.tradefed.build.StubBuildProvider;
 import com.android.tradefed.config.Configuration;
+import com.android.tradefed.config.ConfigurationFactory;
+import com.android.tradefed.config.DeviceConfigurationHolder;
 import com.android.tradefed.config.IConfiguration;
+import com.android.tradefed.config.IDeviceConfiguration;
 import com.android.tradefed.config.OptionSetter;
+import com.android.tradefed.device.DeviceSelectionOptions;
+import com.android.tradefed.device.DeviceSelectionOptions.DeviceRequestedType;
+import com.android.tradefed.result.ITestInvocationListener;
+import com.android.tradefed.result.LogDataType;
+import com.android.tradefed.result.proto.ProtoResultReporter;
+import com.android.tradefed.result.proto.SummaryProtoResultReporter;
+import com.android.tradefed.util.FileUtil;
 
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.junit.runners.JUnit4;
+import org.mockito.Mockito;
+
+import java.io.File;
+import java.util.List;
 
 /** Unit tests for {@link RemoteInvocationExecution}. */
 @RunWith(JUnit4.class)
@@ -37,12 +53,14 @@
     private RemoteInvocationExecution mRemoteInvocation;
     private IInvocationContext mContext;
     private IConfiguration mConfiguration;
+    private ITestInvocationListener mMockListener;
 
     @Before
     public void setUp() {
         mRemoteInvocation = new RemoteInvocationExecution();
         mContext = new InvocationContext();
         mConfiguration = new Configuration("name", "description");
+        mMockListener = Mockito.mock(ITestInvocationListener.class);
     }
 
     @Test
@@ -65,4 +83,37 @@
         // The build id is carried to the remote invocation build
         assertEquals("5555", info.getBuildId());
     }
+
+    @Test
+    public void testCreateRemoteConfig() throws Exception {
+        IDeviceConfiguration deviceConfig = new DeviceConfigurationHolder();
+        DeviceSelectionOptions selection = new DeviceSelectionOptions();
+        selection.setDeviceTypeRequested(DeviceRequestedType.REMOTE_DEVICE);
+        deviceConfig.addSpecificConfig(selection);
+        mConfiguration.setDeviceConfig(deviceConfig);
+        File res = null;
+        try {
+            res = mRemoteInvocation.createRemoteConfig(mConfiguration, mMockListener, "path/");
+            IConfiguration reparse =
+                    ConfigurationFactory.getInstance()
+                            .createConfigurationFromArgs(new String[] {res.getAbsolutePath()});
+            List<ITestInvocationListener> listeners = reparse.getTestInvocationListeners();
+            assertEquals(2, listeners.size());
+            assertTrue(listeners.get(0) instanceof ProtoResultReporter);
+            assertTrue(listeners.get(1) instanceof SummaryProtoResultReporter);
+            // Ensure the requested type is reset
+            assertNull(
+                    ((DeviceSelectionOptions)
+                                    reparse.getDeviceConfig().get(0).getDeviceRequirements())
+                            .getDeviceTypeRequested());
+        } finally {
+            FileUtil.deleteFile(res);
+        }
+
+        verify(mMockListener)
+                .testLog(
+                        Mockito.eq(RemoteInvocationExecution.REMOTE_CONFIG),
+                        Mockito.eq(LogDataType.XML),
+                        Mockito.any());
+    }
 }
diff --git a/tests/src/com/android/tradefed/invoker/TestInvocationTest.java b/tests/src/com/android/tradefed/invoker/TestInvocationTest.java
index 811f9f1..c6ea271 100644
--- a/tests/src/com/android/tradefed/invoker/TestInvocationTest.java
+++ b/tests/src/com/android/tradefed/invoker/TestInvocationTest.java
@@ -1259,6 +1259,8 @@
                 .andReturn(new StubKeyStoreFactory())
                 .times(2);
         setupInvoke();
+        mMockLogSaver.invocationStarted(mStubInvocationMetadata);
+        mMockLogSaver.invocationEnded(0L);
         setupNShardInvocation(shardCount, command);
         // Ensure that the host_log gets logged after sharding.
         EasyMock.expect(mMockLogger.getLog()).andReturn(EMPTY_STREAM_SOURCE);
diff --git a/tests/src/com/android/tradefed/result/suite/XmlSuiteResultFormatterTest.java b/tests/src/com/android/tradefed/result/suite/XmlSuiteResultFormatterTest.java
index c6ed15b..ef697dd 100644
--- a/tests/src/com/android/tradefed/result/suite/XmlSuiteResultFormatterTest.java
+++ b/tests/src/com/android/tradefed/result/suite/XmlSuiteResultFormatterTest.java
@@ -103,6 +103,8 @@
         File res = mFormatter.writeResults(mResultHolder, mResultDir);
         String content = FileUtil.readStringFromFile(res);
         assertXmlContainsNode(content, "Result");
+        // Verify that RunHistory tag should not exist if run history is empty.
+        assertXmlNotContainNode(content, "Result/RunHistory");
         // Verify that the summary has been populated
         assertXmlContainsNode(content, "Result/Summary");
         assertXmlContainsAttribute(content, "Result/Summary", "pass", "2");
@@ -499,6 +501,43 @@
         }
     }
 
+    /** Check that run history is properly reported. */
+    @Test
+    public void testRunHistoryReporting() throws Exception {
+        final String RUN_HISTORY =
+                "[{\"startTime\":10000000000000,\"endTime\":10000000100000},"
+                        + "{\"startTime\":10000000200000,\"endTime\":10000000300000}]";
+        mResultHolder.context = mContext;
+        mResultHolder.context.addInvocationAttribute("run_history", RUN_HISTORY);
+
+        Collection<TestRunResult> runResults = new ArrayList<>();
+        runResults.add(createFakeResult("module1", 2, 1, 0, 0));
+        mResultHolder.runResults = runResults;
+
+        Map<String, IAbi> modulesAbi = new HashMap<>();
+        modulesAbi.put("module1", new Abi("armeabi-v7a", "32"));
+        mResultHolder.modulesAbi = modulesAbi;
+
+        mResultHolder.completeModules = 1;
+        mResultHolder.totalModules = 1;
+        mResultHolder.passedTests = 1;
+        mResultHolder.failedTests = 0;
+        mResultHolder.startTime = 0L;
+        mResultHolder.endTime = 10L;
+        File res = mFormatter.writeResults(mResultHolder, mResultDir);
+        String content = FileUtil.readStringFromFile(res);
+
+        assertXmlContainsAttribute(content, "Result/Build", "run_history", RUN_HISTORY);
+        assertXmlContainsNode(content, "Result/RunHistory");
+        assertXmlContainsAttribute(content, "Result/RunHistory/Run", "start", "10000000000000");
+        assertXmlContainsAttribute(content, "Result/RunHistory/Run", "end", "10000000100000");
+        assertXmlContainsAttribute(content, "Result/RunHistory/Run", "start", "10000000200000");
+        assertXmlContainsAttribute(content, "Result/RunHistory/Run", "end", "10000000300000");
+        // Test that we can read back the information.
+        SuiteResultHolder holder = mFormatter.parseResults(mResultDir, false);
+        assertEquals(RUN_HISTORY, holder.context.getAttributes().getUniqueMap().get("run_history"));
+    }
+
     private TestRunResult createResultWithLog(String runName, int count, LogDataType type) {
         TestRunResult fakeRes = new TestRunResult();
         fakeRes.testRunStarted(runName, count);
@@ -596,6 +635,22 @@
         return nodes;
     }
 
+    /** Assert that the XML does not contain a node matching the given xPathExpression. */
+    private void assertXmlNotContainNode(String xml, String xPathExpression)
+            throws XPathExpressionException {
+        NodeList nodes = getXmlNodes(xml, xPathExpression);
+        assertNotNull(
+                String.format("XML '%s' returned null for xpath '%s'.", xml, xPathExpression),
+                nodes);
+        assertEquals(
+                String.format(
+                        "XML '%s' should have returned at least 1 node for xpath '%s', "
+                                + "but returned %s nodes instead.",
+                        xml, xPathExpression, nodes.getLength()),
+                0,
+                nodes.getLength());
+    }
+
     /**
      * Assert that the XML contains a node matching the given xPathExpression and that the node has
      * a given value.
diff --git a/tests/src/com/android/tradefed/targetprep/AoaTargetPreparerTest.java b/tests/src/com/android/tradefed/targetprep/AoaTargetPreparerTest.java
index 23039df..b35d7e3 100644
--- a/tests/src/com/android/tradefed/targetprep/AoaTargetPreparerTest.java
+++ b/tests/src/com/android/tradefed/targetprep/AoaTargetPreparerTest.java
@@ -15,34 +15,94 @@
  */
 package com.android.tradefed.targetprep;
 
+import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.verifyNoMoreInteractions;
+import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.when;
 
 import com.android.helper.aoa.AoaDevice;
+import com.android.helper.aoa.UsbHelper;
+import com.android.tradefed.build.IBuildInfo;
+import com.android.tradefed.config.ConfigurationException;
+import com.android.tradefed.config.OptionSetter;
+import com.android.tradefed.device.DeviceNotAvailableException;
+import com.android.tradefed.device.TestDevice;
 
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
-import org.junit.runners.JUnit4;
+import org.mockito.Mock;
+import org.mockito.Spy;
+import org.mockito.junit.MockitoJUnitRunner;
 
 import java.awt.*;
 import java.time.Duration;
 
 /** Unit tests for {@link AoaTargetPreparer} */
-@RunWith(JUnit4.class)
+@RunWith(MockitoJUnitRunner.class)
 public class AoaTargetPreparerTest {
 
-    private AoaTargetPreparer mPreparer;
+    @Spy private AoaTargetPreparer mPreparer;
+    private OptionSetter mOptionSetter;
 
-    private AoaDevice mDevice;
+    @Mock private TestDevice mTestDevice;
+    @Mock private IBuildInfo mBuildInfo;
+    @Mock private UsbHelper mUsb;
+    @Mock private AoaDevice mDevice;
 
     @Before
-    public void setUp() {
-        mDevice = mock(AoaDevice.class);
-        mPreparer = new AoaTargetPreparer();
+    public void setUp() throws ConfigurationException {
+        when(mUsb.getAoaDevice(any(), any())).thenReturn(mDevice);
+        doReturn(mUsb).when(mPreparer).getUsbHelper();
+        mOptionSetter = new OptionSetter(mPreparer);
+    }
+
+    @Test
+    public void testSetUp()
+            throws TargetSetupError, DeviceNotAvailableException, ConfigurationException {
+        mOptionSetter.setOptionValue("action", "wake");
+        mOptionSetter.setOptionValue("device-timeout", "1s");
+        mOptionSetter.setOptionValue("wait-for-device-online", "true");
+
+        mPreparer.setUp(mTestDevice, mBuildInfo);
+        // fetched device, executed actions, and verified status
+        verify(mUsb, times(1)).getAoaDevice(any(), eq(Duration.ofSeconds(1L)));
+        verify(mPreparer, times(1)).execute(eq(mDevice), eq("wake"));
+        verify(mTestDevice, times(1)).waitForDeviceOnline();
+    }
+
+    @Test
+    public void testSetUp_noActions() throws TargetSetupError, DeviceNotAvailableException {
+        mPreparer.setUp(mTestDevice, mBuildInfo);
+        // no-op if no actions provided
+        verifyZeroInteractions(mUsb);
+        verify(mPreparer, never()).execute(eq(mDevice), any());
+        verifyZeroInteractions(mTestDevice);
+    }
+
+    @Test(expected = DeviceNotAvailableException.class)
+    public void testSetUp_noDevice()
+            throws TargetSetupError, DeviceNotAvailableException, ConfigurationException {
+        mOptionSetter.setOptionValue("action", "wake");
+        when(mUsb.getAoaDevice(any(), any())).thenReturn(null); // device not found or incompatible
+        mPreparer.setUp(mTestDevice, mBuildInfo);
+    }
+
+    @Test
+    public void testSetUp_skipDeviceCheck()
+            throws TargetSetupError, DeviceNotAvailableException, ConfigurationException {
+        mOptionSetter.setOptionValue("action", "wake");
+        mOptionSetter.setOptionValue("wait-for-device-online", "false"); // device check disabled
+
+        mPreparer.setUp(mTestDevice, mBuildInfo);
+        // actions executed, but status check skipped
+        verify(mPreparer, times(1)).execute(eq(mDevice), eq("wake"));
+        verify(mTestDevice, never()).waitForDeviceOnline();
     }
 
     @Test
diff --git a/tests/src/com/android/tradefed/targetprep/CreateUserPreparerTest.java b/tests/src/com/android/tradefed/targetprep/CreateUserPreparerTest.java
new file mode 100644
index 0000000..b96d6bd
--- /dev/null
+++ b/tests/src/com/android/tradefed/targetprep/CreateUserPreparerTest.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2019 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.
+ */
+package com.android.tradefed.targetprep;
+
+import static org.junit.Assert.fail;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+import com.android.tradefed.device.ITestDevice;
+import com.android.tradefed.device.NativeDevice;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mockito;
+
+/** Unit tests for {@link CreateUserPreparer}. */
+@RunWith(JUnit4.class)
+public class CreateUserPreparerTest {
+
+    private CreateUserPreparer mPreparer;
+    private ITestDevice mMockDevice;
+
+    @Before
+    public void setUp() {
+        mMockDevice = Mockito.mock(ITestDevice.class);
+        mPreparer = new CreateUserPreparer();
+    }
+
+    @Test
+    public void testSetUp_tearDown() throws Exception {
+        doReturn(10).when(mMockDevice).getCurrentUser();
+        doReturn(5).when(mMockDevice).createUser(Mockito.any());
+        doReturn(true).when(mMockDevice).switchUser(5);
+        mPreparer.setUp(mMockDevice, null);
+
+        doReturn(true).when(mMockDevice).removeUser(5);
+        doReturn(true).when(mMockDevice).switchUser(10);
+        mPreparer.tearDown(mMockDevice, null, null);
+    }
+
+    @Test
+    public void testSetUp_tearDown_noCurrent() throws Exception {
+        doReturn(NativeDevice.INVALID_USER_ID).when(mMockDevice).getCurrentUser();
+        try {
+            mPreparer.setUp(mMockDevice, null);
+            fail("Should have thrown an exception.");
+        } catch (TargetSetupError expected) {
+            // Expected
+        }
+
+        mPreparer.tearDown(mMockDevice, null, null);
+        verify(mMockDevice, never()).removeUser(Mockito.anyInt());
+        verify(mMockDevice, never()).switchUser(Mockito.anyInt());
+    }
+
+    @Test
+    public void testSetUp_failed() throws Exception {
+        doThrow(new IllegalStateException("failed to create"))
+                .when(mMockDevice)
+                .createUser(Mockito.any());
+
+        try {
+            mPreparer.setUp(mMockDevice, null);
+            fail("Should have thrown an exception.");
+        } catch (TargetSetupError expected) {
+            // Expected
+        }
+    }
+
+    @Test
+    public void testTearDown_only() throws Exception {
+        mPreparer.tearDown(mMockDevice, null, null);
+
+        verify(mMockDevice, never()).removeUser(Mockito.anyInt());
+    }
+}
diff --git a/tests/src/com/android/tradefed/targetprep/InstallApexModuleTargetPreparerTest.java b/tests/src/com/android/tradefed/targetprep/InstallApexModuleTargetPreparerTest.java
index 4932ece..869577a 100644
--- a/tests/src/com/android/tradefed/targetprep/InstallApexModuleTargetPreparerTest.java
+++ b/tests/src/com/android/tradefed/targetprep/InstallApexModuleTargetPreparerTest.java
@@ -23,6 +23,7 @@
 
 import com.android.tradefed.build.IBuildInfo;
 import com.android.tradefed.command.remote.DeviceDescriptor;
+import com.android.tradefed.config.OptionSetter;
 import com.android.tradefed.device.ITestDevice;
 import com.android.tradefed.device.ITestDevice.ApexInfo;
 import com.android.tradefed.util.CommandResult;
@@ -57,10 +58,13 @@
     private File mFakeApkApks;
     private File mFakeApexApks;
     private File mBundletoolJar;
+    private OptionSetter mSetter;
     private static final String APEX_PACKAGE_NAME = "com.android.FAKE_APEX_PACKAGE_NAME";
     private static final String APK_PACKAGE_NAME = "com.android.FAKE_APK_PACKAGE_NAME";
     private static final String SPLIT_APEX_PACKAGE_NAME =
             "com.android.SPLIT_FAKE_APEX_PACKAGE_NAME";
+    private static final String SPLIT_APK_PACKAGE_NAME =
+        "com.android.SPLIT_FAKE_APK_PACKAGE_NAME";
     private static final String APEX_PACKAGE_KEYWORD = "FAKE_APEX_PACKAGE_NAME";
     private static final long APEX_VERSION = 1;
     private static final String APEX_NAME = "fakeApex.apex";
@@ -129,9 +133,14 @@
                         if (testAppFile.getName().endsWith(".apex")) {
                             return APEX_PACKAGE_NAME;
                         }
-                        if (testAppFile.getName().endsWith(".apk")) {
+                        if (testAppFile.getName().endsWith(".apk") &&
+                            !testAppFile.getName().contains("Split")) {
                             return APK_PACKAGE_NAME;
                         }
+                        if (testAppFile.getName().endsWith(".apk") &&
+                            testAppFile.getName().contains("Split")) {
+                            return SPLIT_APK_PACKAGE_NAME;
+                        }
                         return null;
                     }
 
@@ -147,6 +156,9 @@
                         return apexInfo;
                     }
                 };
+
+        mSetter = new OptionSetter(mInstallApexModuleTargetPreparer);
+        mSetter.setOptionValue("cleanup-apks", "true");
     }
 
     @After
@@ -292,6 +304,121 @@
     }
 
     @Test
+    public void testSetupAndTearDown_SingleApk() throws Exception {
+        mInstallApexModuleTargetPreparer.addTestFileName(APK_NAME);
+        mMockDevice.deleteFile(APEX_DATA_DIR + "*");
+        EasyMock.expectLastCall().times(1);
+        mMockDevice.deleteFile(SESSION_DATA_DIR + "*");
+        EasyMock.expectLastCall().times(1);
+        mMockDevice.deleteFile(STAGING_DATA_DIR + "*");
+        EasyMock.expectLastCall().times(1);
+        CommandResult res = new CommandResult();
+        res.setStdout("test.apex");
+        EasyMock.expect(mMockDevice.executeShellV2Command("ls " + APEX_DATA_DIR)).andReturn(res);
+        EasyMock.expect(mMockDevice.executeShellV2Command("ls " + SESSION_DATA_DIR)).andReturn(res);
+        EasyMock.expect(mMockDevice.executeShellV2Command("ls " + STAGING_DATA_DIR)).andReturn(res);
+        mMockDevice.reboot();
+        EasyMock.expectLastCall();
+        EasyMock.expect(mMockDevice.installPackage((File) EasyMock.anyObject(), EasyMock.eq(true)))
+                .andReturn(null)
+                .once();
+        EasyMock.expect(mMockDevice.uninstallPackage(APK_PACKAGE_NAME)).andReturn(null).once();
+
+        EasyMock.replay(mMockBuildInfo, mMockDevice);
+        mInstallApexModuleTargetPreparer.setUp(mMockDevice, mMockBuildInfo);
+        mInstallApexModuleTargetPreparer.tearDown(mMockDevice, mMockBuildInfo, null);
+        EasyMock.verify(mMockBuildInfo, mMockDevice);
+    }
+
+    @Test
+    public void testSetupAndTearDown_ApkAndApks() throws Exception {
+        mInstallApexModuleTargetPreparer.addTestFileName(APK_NAME);
+        mInstallApexModuleTargetPreparer.addTestFileName(SPLIT_APK__APKS_NAME);;
+        mFakeApkApks = File.createTempFile("fakeApk", ".apks");
+
+        File fakeSplitApkApks = File.createTempFile("ApkSplits", "");
+        fakeSplitApkApks.delete();
+        fakeSplitApkApks.mkdir();
+        File splitApk1 = File.createTempFile("fakeSplitApk1", ".apk", fakeSplitApkApks);
+        mBundletoolJar = File.createTempFile("bundletool", ".jar");
+        File splitApk2 = File.createTempFile("fakeSplitApk2", ".apk", fakeSplitApkApks);
+        try {
+            mMockDevice.deleteFile(APEX_DATA_DIR + "*");
+            EasyMock.expectLastCall().times(1);
+            mMockDevice.deleteFile(SESSION_DATA_DIR + "*");
+            EasyMock.expectLastCall().times(1);
+            mMockDevice.deleteFile(STAGING_DATA_DIR + "*");
+            EasyMock.expectLastCall().times(1);
+            CommandResult res = new CommandResult();
+            res.setStdout("test.apex");
+            EasyMock.expect(mMockDevice.executeShellV2Command("ls " + APEX_DATA_DIR))
+                .andReturn(res);
+            EasyMock.expect(mMockDevice.executeShellV2Command("ls " + SESSION_DATA_DIR))
+                .andReturn(res);
+            EasyMock.expect(mMockDevice.executeShellV2Command("ls " + STAGING_DATA_DIR))
+                .andReturn(res);
+            mMockDevice.reboot();
+            EasyMock.expectLastCall();
+            when(mMockBundletoolUtil.generateDeviceSpecFile(Mockito.any(ITestDevice.class)))
+                .thenReturn("serial.json");
+
+            assertTrue(fakeSplitApkApks != null);
+            assertTrue(mFakeApkApks != null);
+            assertEquals(2, fakeSplitApkApks.listFiles().length);
+
+            when(mMockBundletoolUtil.extractSplitsFromApks(
+                Mockito.eq(mFakeApkApks),
+                Mockito.anyString(),
+                Mockito.any(ITestDevice.class),
+                Mockito.any(IBuildInfo.class)))
+                .thenReturn(fakeSplitApkApks);
+
+            mMockDevice.waitForDeviceAvailable();
+
+            List<String> trainInstallCmd = new ArrayList<>();
+            trainInstallCmd.add("install-multi-package");
+            trainInstallCmd.add(mFakeApk.getAbsolutePath());
+            String cmd = "";
+            for (File f : fakeSplitApkApks.listFiles()) {
+                if (!cmd.isEmpty()) {
+                    cmd += ":" + f.getParentFile().getAbsolutePath() + "/" + f.getName();
+                } else {
+                    cmd += f.getParentFile().getAbsolutePath() + "/" + f.getName();
+                }
+            }
+            trainInstallCmd.add(cmd);
+            EasyMock.expect(mMockDevice.executeAdbCommand(trainInstallCmd.toArray(new String[0])))
+                .andReturn("Success")
+                .once();
+
+            EasyMock.expect(mMockDevice.uninstallPackage(APK_PACKAGE_NAME)).andReturn(null).once();
+            EasyMock.expect(mMockDevice.uninstallPackage(SPLIT_APK_PACKAGE_NAME))
+                .andReturn(null)
+                .once();
+
+            EasyMock.replay(mMockBuildInfo, mMockDevice);
+            mInstallApexModuleTargetPreparer.setUp(mMockDevice, mMockBuildInfo);
+            mInstallApexModuleTargetPreparer.tearDown(mMockDevice, mMockBuildInfo, null);
+            Mockito.verify(mMockBundletoolUtil, times(1))
+                .generateDeviceSpecFile(Mockito.any(ITestDevice.class));
+            Mockito.verify(mMockBundletoolUtil, times(1))
+                .extractSplitsFromApks(
+                    Mockito.eq(mFakeApkApks),
+                    Mockito.anyString(),
+                    Mockito.any(ITestDevice.class),
+                    Mockito.any(IBuildInfo.class));
+            EasyMock.verify(mMockBuildInfo, mMockDevice);
+            assertTrue(!mInstallApexModuleTargetPreparer.getApkInstalled().isEmpty());
+        } finally {
+            FileUtil.deleteFile(mFakeApexApks);
+            FileUtil.deleteFile(mFakeApkApks);
+            FileUtil.recursiveDelete(fakeSplitApkApks);
+            FileUtil.deleteFile(fakeSplitApkApks);
+            FileUtil.deleteFile(mBundletoolJar);
+        }
+    }
+
+    @Test
     public void testSetupAndTearDown() throws Exception {
         mInstallApexModuleTargetPreparer.addTestFileName(APEX_NAME);
         mMockDevice.deleteFile(APEX_DATA_DIR + "*");
@@ -446,7 +573,9 @@
             Set<ApexInfo> activatedApex = new HashSet<ApexInfo>();
             activatedApex.add(new ApexInfo(SPLIT_APEX_PACKAGE_NAME, 1));
             EasyMock.expect(mMockDevice.getActiveApexes()).andReturn(activatedApex);
-            EasyMock.expect(mMockDevice.uninstallPackage(APK_PACKAGE_NAME)).andReturn(null).once();
+            EasyMock.expect(mMockDevice.uninstallPackage(SPLIT_APK_PACKAGE_NAME))
+                .andReturn(null)
+                .once();
             mMockDevice.reboot();
             EasyMock.expectLastCall();
 
diff --git a/tests/src/com/android/tradefed/testtype/AndroidJUnitTestTest.java b/tests/src/com/android/tradefed/testtype/AndroidJUnitTestTest.java
index 11ea3cb..b041293 100644
--- a/tests/src/com/android/tradefed/testtype/AndroidJUnitTestTest.java
+++ b/tests/src/com/android/tradefed/testtype/AndroidJUnitTestTest.java
@@ -195,6 +195,47 @@
         EasyMock.verify(mMockRemoteRunner, mMockTestDevice);
     }
 
+    /** Test list of tests to run is filtered by include filters using regex. */
+    @Test
+    public void testRun_includeFilterSingleTestsRegex() throws Exception {
+        // expect this call
+        mMockRemoteRunner.addInstrumentationArg("tests_regex", ".*testName$");
+        setRunTestExpectations();
+        EasyMock.replay(mMockRemoteRunner, mMockTestDevice);
+        mAndroidJUnitTest.addIncludeFilter(".*testName$");
+        mAndroidJUnitTest.run(mMockListener);
+        EasyMock.verify(mMockRemoteRunner, mMockTestDevice);
+    }
+
+    /** Test list of tests to run is filtered by include filters using multiple regex. */
+    @Test
+    public void testRun_includeFilterMultipleTestsRegex() throws Exception {
+        // expect this call
+        mMockRemoteRunner.addInstrumentationArg("tests_regex", "\"(.*test2|.*testName$)\"");
+        setRunTestExpectations();
+        EasyMock.replay(mMockRemoteRunner, mMockTestDevice);
+        mAndroidJUnitTest.addIncludeFilter(".*test2");
+        mAndroidJUnitTest.addIncludeFilter(".*testName$");
+        mAndroidJUnitTest.run(mMockListener);
+        EasyMock.verify(mMockRemoteRunner, mMockTestDevice);
+    }
+
+    /** Test list of tests to run is filtered by include filters using invalid regex. */
+    @Test
+    public void testRun_includeFilterInvalidTestsRegex() throws Exception {
+        setRunTestExpectations();
+        EasyMock.replay(mMockRemoteRunner, mMockTestDevice);
+        // regex with unbalanced parenthesis.
+        mAndroidJUnitTest.addIncludeFilter("(testName");
+        try {
+            mAndroidJUnitTest.run(mMockListener);
+        } catch (RuntimeException expected) {
+            //expected.
+            return;
+        }
+        fail("RuntimeException not raised for filter with invalid regular expression.");
+    }
+
     /** Test list of tests to run is filtered by include and exclude filters. */
     @Test
     public void testRun_includeAndExcludeFilters() throws Exception {
diff --git a/tests/src/com/android/tradefed/testtype/suite/ITestSuiteTest.java b/tests/src/com/android/tradefed/testtype/suite/ITestSuiteTest.java
index 6d49557..39e072a 100644
--- a/tests/src/com/android/tradefed/testtype/suite/ITestSuiteTest.java
+++ b/tests/src/com/android/tradefed/testtype/suite/ITestSuiteTest.java
@@ -279,6 +279,8 @@
         mTestSuite.setConfiguration(mStubMainConfiguration);
         mContext = new InvocationContext();
         mTestSuite.setInvocationContext(mContext);
+        mContext.addAllocatedDevice(ConfigurationDef.DEFAULT_DEVICE_NAME, mMockDevice);
+        mContext.addDeviceBuildInfo(ConfigurationDef.DEFAULT_DEVICE_NAME, mMockBuildInfo);
         mListCollectors = new ArrayList<>();
         mListCollectors.add(new TestMetricCollector("metric1", "value1"));
         mListCollectors.add(new TestMetricCollector("metric2", "value2"));
@@ -651,6 +653,11 @@
                 "unresponsive"
                         + TestRunResult.ERROR_DIVIDER
                         + "Module test only ran 0 out of 1 expected tests.");
+        EasyMock.expect(
+                        mMockDevice.logBugreport(
+                                EasyMock.eq("module-test-failure-SERIAL-bugreport"),
+                                EasyMock.anyObject()))
+                .andReturn(true);
         mMockListener.testRunEnded(
                 EasyMock.anyLong(), EasyMock.<HashMap<String, Metric>>anyObject());
         EasyMock.expectLastCall().times(1);
@@ -775,6 +782,11 @@
                 "runtime"
                         + TestRunResult.ERROR_DIVIDER
                         + "Module test only ran 0 out of 1 expected tests.");
+        EasyMock.expect(
+                        mMockDevice.logBugreport(
+                                EasyMock.eq("module-test-failure-SERIAL-bugreport"),
+                                EasyMock.anyObject()))
+                .andReturn(true);
         mMockListener.testRunEnded(
                 EasyMock.anyLong(), EasyMock.<HashMap<String, Metric>>anyObject());
         EasyMock.expectLastCall().times(1);
diff --git a/tests/src/com/android/tradefed/testtype/suite/TfSuiteRunnerTest.java b/tests/src/com/android/tradefed/testtype/suite/TfSuiteRunnerTest.java
index 17018c8..749e49e 100644
--- a/tests/src/com/android/tradefed/testtype/suite/TfSuiteRunnerTest.java
+++ b/tests/src/com/android/tradefed/testtype/suite/TfSuiteRunnerTest.java
@@ -24,11 +24,13 @@
 import com.android.tradefed.build.IBuildInfo;
 import com.android.tradefed.build.IDeviceBuildInfo;
 import com.android.tradefed.config.Configuration;
+import com.android.tradefed.config.ConfigurationDef;
 import com.android.tradefed.config.ConfigurationException;
 import com.android.tradefed.config.IConfiguration;
 import com.android.tradefed.config.OptionSetter;
 import com.android.tradefed.device.DeviceNotAvailableException;
 import com.android.tradefed.device.ITestDevice;
+import com.android.tradefed.invoker.IInvocationContext;
 import com.android.tradefed.invoker.InvocationContext;
 import com.android.tradefed.metrics.proto.MetricMeasurement.Metric;
 import com.android.tradefed.result.ILogSaver;
@@ -210,7 +212,9 @@
         mRunner.setDevice(mock(ITestDevice.class));
         mRunner.setBuild(mock(IBuildInfo.class));
         mRunner.setSystemStatusChecker(new ArrayList<>());
-        mRunner.setInvocationContext(new InvocationContext());
+        IInvocationContext context = new InvocationContext();
+        context.addAllocatedDevice(ConfigurationDef.DEFAULT_DEVICE_NAME, mock(ITestDevice.class));
+        mRunner.setInvocationContext(context);
         // runs the expanded suite
         listener.testModuleStarted(EasyMock.anyObject());
         listener.testRunStarted(
diff --git a/tests/src/com/android/tradefed/util/GoogleApiClientUtilTest.java b/tests/src/com/android/tradefed/util/GoogleApiClientUtilTest.java
new file mode 100644
index 0000000..5324571
--- /dev/null
+++ b/tests/src/com/android/tradefed/util/GoogleApiClientUtilTest.java
@@ -0,0 +1,144 @@
+/*
+ * Copyright (C) 2019 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.
+ */
+
+package com.android.tradefed.util;
+
+import com.android.tradefed.config.GlobalConfiguration;
+import com.android.tradefed.config.OptionSetter;
+
+import com.google.api.client.googleapis.auth.oauth2.GoogleCredential;
+
+import org.junit.After;
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.BeforeClass;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mockito;
+
+import java.io.File;
+import java.io.IOException;
+import java.security.GeneralSecurityException;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+/** Unit test for {@link GoogleApiClientUtil}. */
+@RunWith(JUnit4.class)
+public class GoogleApiClientUtilTest {
+
+    private static final Collection<String> SCOPES = Collections.singleton("ascope");
+    private static final String HOST_OPTION_JSON_KEY = "host-option-json-key";
+    private File mRoot;
+    private StubGoogleApiClientUtil mUtil;
+    private File mKey;
+    private File mOldValue;
+
+    static class StubGoogleApiClientUtil extends GoogleApiClientUtil {
+
+        List<File> mKeyFiles = new ArrayList<>();
+        boolean mDefaultCredentialUsed = false;
+
+        @Override
+        GoogleCredential doCreateCredentialFromJsonKeyFile(File file, Collection<String> scopes)
+                throws IOException, GeneralSecurityException {
+            mKeyFiles.add(file);
+            return Mockito.mock(GoogleCredential.class);
+        }
+
+        @Override
+        GoogleCredential doCreateDefaultCredential(Collection<String> scopes) throws IOException {
+            mDefaultCredentialUsed = true;
+            return Mockito.mock(GoogleCredential.class);
+        }
+    }
+
+    @BeforeClass
+    public static void setUpBeforeClass() throws Exception {
+        try {
+            GlobalConfiguration.getInstance();
+        } catch (IllegalStateException e) {
+            GlobalConfiguration.createGlobalConfiguration(new String[] {});
+        }
+    }
+
+    @Before
+    public void setUp() throws IOException {
+        mUtil = new StubGoogleApiClientUtil();
+        mRoot = FileUtil.createTempDir(GoogleApiClientUtilTest.class.getName());
+        mKey = new File(mRoot, "key.json");
+        FileUtil.writeToFile("key", mKey);
+        mOldValue =
+                GlobalConfiguration.getInstance()
+                        .getHostOptions()
+                        .getServiceAccountJsonKeyFiles()
+                        .get(HOST_OPTION_JSON_KEY);
+    }
+
+    @After
+    public void tearDown() {
+        FileUtil.recursiveDelete(mRoot);
+        if (mOldValue != null) {
+            GlobalConfiguration.getInstance()
+                    .getHostOptions()
+                    .getServiceAccountJsonKeyFiles()
+                    .put(HOST_OPTION_JSON_KEY, mOldValue);
+        }
+    }
+
+    @Test
+    public void testCreateCredential() throws Exception {
+        GoogleCredential credentail = mUtil.doCreateCredential(SCOPES, mKey, null);
+        Assert.assertNotNull(credentail);
+        Assert.assertEquals(1, mUtil.mKeyFiles.size());
+        Assert.assertEquals(mKey, mUtil.mKeyFiles.get(0));
+        Assert.assertFalse(mUtil.mDefaultCredentialUsed);
+    }
+
+    @Test
+    public void testCreateCredential_useHostOptions() throws Exception {
+        OptionSetter optionSetter =
+                new OptionSetter(GlobalConfiguration.getInstance().getHostOptions());
+        optionSetter.setOptionValue(
+                "host_options:service-account-json-key-file",
+                HOST_OPTION_JSON_KEY,
+                mKey.getAbsolutePath());
+        GoogleCredential credentail = mUtil.doCreateCredential(SCOPES, null, HOST_OPTION_JSON_KEY);
+        Assert.assertNotNull(credentail);
+        Assert.assertEquals(1, mUtil.mKeyFiles.size());
+        Assert.assertEquals(mKey, mUtil.mKeyFiles.get(0));
+        Assert.assertFalse(mUtil.mDefaultCredentialUsed);
+    }
+
+    @Test
+    public void testCreateCredential_useFallbackKeyFile() throws Exception {
+        GoogleCredential credentail = mUtil.doCreateCredential(SCOPES, null, "not-exist-key", mKey);
+        Assert.assertNotNull(credentail);
+        Assert.assertEquals(1, mUtil.mKeyFiles.size());
+        Assert.assertEquals(mKey, mUtil.mKeyFiles.get(0));
+        Assert.assertFalse(mUtil.mDefaultCredentialUsed);
+    }
+
+    @Test
+    public void testCreateCredential_useDefault() throws Exception {
+        GoogleCredential credentail = mUtil.doCreateCredential(SCOPES, null, "not-exist-key");
+        Assert.assertNotNull(credentail);
+        Assert.assertEquals(0, mUtil.mKeyFiles.size());
+        Assert.assertTrue(mUtil.mDefaultCredentialUsed);
+    }
+}
diff --git a/tests/src/com/android/tradefed/util/RunUtilTest.java b/tests/src/com/android/tradefed/util/RunUtilTest.java
index ea69276..e11ebff 100644
--- a/tests/src/com/android/tradefed/util/RunUtilTest.java
+++ b/tests/src/com/android/tradefed/util/RunUtilTest.java
@@ -44,6 +44,7 @@
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.OutputStream;
+import java.util.ArrayList;
 import java.util.concurrent.TimeUnit;
 
 /** Unit tests for {@link RunUtil} */
@@ -109,6 +110,7 @@
     public void testRunTimed() throws Exception {
         IRunUtil.IRunnableResult mockRunnable = EasyMock.createStrictMock(
                 IRunUtil.IRunnableResult.class);
+        EasyMock.expect(mockRunnable.getCommand()).andReturn(new ArrayList<>());
         EasyMock.expect(mockRunnable.run()).andReturn(Boolean.TRUE);
         mockRunnable.cancel(); // always ensure execution is cancelled
         EasyMock.replay(mockRunnable);
@@ -121,6 +123,7 @@
     public void testRunTimed_failed() throws Exception {
         IRunUtil.IRunnableResult mockRunnable = EasyMock.createStrictMock(
                 IRunUtil.IRunnableResult.class);
+        EasyMock.expect(mockRunnable.getCommand()).andReturn(new ArrayList<>());
         EasyMock.expect(mockRunnable.run()).andReturn(Boolean.FALSE);
         mockRunnable.cancel(); // always ensure execution is cancelled
         EasyMock.replay(mockRunnable);
@@ -133,6 +136,7 @@
     public void testRunTimed_exception() throws Exception {
         IRunUtil.IRunnableResult mockRunnable = EasyMock.createStrictMock(
                 IRunUtil.IRunnableResult.class);
+        EasyMock.expect(mockRunnable.getCommand()).andReturn(new ArrayList<>());
         EasyMock.expect(mockRunnable.run()).andThrow(new RuntimeException());
         mockRunnable.cancel(); // cancel due to exception
         mockRunnable.cancel(); // always ensure execution is cancelled