Merge "Add setAltDirBehavior to TestAppInstallSetup" into oc-mr1-dev
diff --git a/prod-tests/src/com/android/performance/tests/EmmcPerformanceTest.java b/prod-tests/src/com/android/performance/tests/EmmcPerformanceTest.java
index 91473ac..81eebc2 100644
--- a/prod-tests/src/com/android/performance/tests/EmmcPerformanceTest.java
+++ b/prod-tests/src/com/android/performance/tests/EmmcPerformanceTest.java
@@ -376,7 +376,7 @@
             if (mTestDevice.enableAdbRoot()) {
                 String output = mTestDevice.executeShellCommand("vdc dump | grep cache");
                 CLog.d("Output from shell command 'vdc dump | grep cache': %s", output);
-                String[] segments = output.split(" ");
+                String[] segments = output.split("\\s+");
                 if (segments.length >= 3) {
                     mCache = segments[2];
                 } else {
@@ -394,10 +394,10 @@
             // Filesystem            1K-blocks Used Available Use% Mounted on
             // /dev/block/mmcblk0p34     60400   56     60344   1% /cache
             String output = mTestDevice.executeShellCommand("df cache");
-            CLog.d(String.format("Output from shell command 'df cache': %s", output));
+            CLog.d(String.format("Output from shell command 'df cache':\n%s", output));
             String[] lines = output.split("\r?\n");
             if (lines.length >= 2) {
-                String[] segments = lines[1].split(" ");
+                String[] segments = lines[1].split("\\s+");
                 if (segments.length >= 2) {
                     if (lines[0].toLowerCase().contains("1k-blocks")) {
                         mCachePartitionSize = Integer.parseInt(segments[1]) / 1024;
diff --git a/src/com/android/tradefed/build/LocalDeviceBuildProvider.java b/src/com/android/tradefed/build/LocalDeviceBuildProvider.java
index 1ac6fe4..0bbfcef 100644
--- a/src/com/android/tradefed/build/LocalDeviceBuildProvider.java
+++ b/src/com/android/tradefed/build/LocalDeviceBuildProvider.java
@@ -282,7 +282,8 @@
         this.mBuildDir = buildDir;
     }
 
-    File getTestDir() {
+    /** Returns the directory where the tests are located. */
+    public File getTestDir() {
         return mTestDir;
     }
 
diff --git a/src/com/android/tradefed/command/CommandScheduler.java b/src/com/android/tradefed/command/CommandScheduler.java
index 3cc7e32..8cdb7e2 100644
--- a/src/com/android/tradefed/command/CommandScheduler.java
+++ b/src/com/android/tradefed/command/CommandScheduler.java
@@ -36,6 +36,7 @@
 import com.android.tradefed.config.IDeviceConfiguration;
 import com.android.tradefed.config.IGlobalConfiguration;
 import com.android.tradefed.config.Option;
+import com.android.tradefed.config.SandboxConfigurationFactory;
 import com.android.tradefed.device.DeviceAllocationState;
 import com.android.tradefed.device.DeviceManager;
 import com.android.tradefed.device.DeviceNotAvailableException;
@@ -60,7 +61,6 @@
 import com.android.tradefed.result.ITestInvocationListener;
 import com.android.tradefed.result.ResultForwarder;
 import com.android.tradefed.sandbox.ISandbox;
-import com.android.tradefed.sandbox.SandboxConfigUtil;
 import com.android.tradefed.sandbox.TradefedSandbox;
 import com.android.tradefed.util.ArrayUtil;
 import com.android.tradefed.util.FileUtil;
@@ -1122,8 +1122,8 @@
         if (isCommandSandboxed(args)) {
             // Create an sandboxed configuration based on the sandbox of the scheduler.
             ISandbox sandbox = createSandbox();
-            return SandboxConfigUtil.createSandboxConfiguration(
-                    args, sandbox, getConfigFactory(), getKeyStoreClient());
+            return SandboxConfigurationFactory.getInstance()
+                    .createConfigurationFromArgs(args, getKeyStoreClient(), sandbox, new RunUtil());
         }
         return getConfigFactory().createConfigurationFromArgs(args, null, getKeyStoreClient());
     }
diff --git a/src/com/android/tradefed/config/Configuration.java b/src/com/android/tradefed/config/Configuration.java
index 60aa5e7..9e6b05f 100644
--- a/src/com/android/tradefed/config/Configuration.java
+++ b/src/com/android/tradefed/config/Configuration.java
@@ -24,6 +24,7 @@
 import com.android.tradefed.device.IDeviceRecovery;
 import com.android.tradefed.device.IDeviceSelection;
 import com.android.tradefed.device.TestDeviceOptions;
+import com.android.tradefed.device.metric.IMetricCollector;
 import com.android.tradefed.log.ILeveledLogOutput;
 import com.android.tradefed.log.StdoutLogger;
 import com.android.tradefed.profiler.ITestProfiler;
@@ -87,6 +88,7 @@
     public static final String CONFIGURATION_DESCRIPTION_TYPE_NAME = "config_desc";
     public static final String DEVICE_NAME = "device";
     public static final String TEST_PROFILER_TYPE_NAME = "test_profiler";
+    public static final String DEVICE_METRICS_COLLECTOR_TYPE_NAME = "metrics_collector";
     public static final String SANDBOX_TYPE_NAME = "sandbox";
 
     private static Map<String, ObjTypeInfo> sObjTypeMap = null;
@@ -161,6 +163,9 @@
                     CONFIGURATION_DESCRIPTION_TYPE_NAME,
                     new ObjTypeInfo(ConfigurationDescriptor.class, false));
             sObjTypeMap.put(TEST_PROFILER_TYPE_NAME, new ObjTypeInfo(ITestProfiler.class, false));
+            sObjTypeMap.put(
+                    DEVICE_METRICS_COLLECTOR_TYPE_NAME,
+                    new ObjTypeInfo(IMetricCollector.class, true));
         }
         return sObjTypeMap;
     }
@@ -211,6 +216,7 @@
         setSystemStatusCheckers(new ArrayList<ISystemStatusChecker>());
         setConfigurationDescriptor(new ConfigurationDescriptor());
         setProfiler(new StubTestProfiler());
+        setDeviceMetricCollectors(new ArrayList<>());
     }
 
     /**
@@ -349,6 +355,13 @@
                 RESULT_REPORTER_TYPE_NAME);
     }
 
+    @SuppressWarnings("unchecked")
+    @Override
+    public List<IMetricCollector> getMetricCollectors() {
+        return (List<IMetricCollector>)
+                getConfigurationObjectList(DEVICE_METRICS_COLLECTOR_TYPE_NAME);
+    }
+
     /** {@inheritDoc} */
     @SuppressWarnings("unchecked")
     @Override
@@ -642,6 +655,11 @@
         setConfigurationObjectListNoThrow(RESULT_REPORTER_TYPE_NAME, listeners);
     }
 
+    @Override
+    public void setDeviceMetricCollectors(List<IMetricCollector> collectors) {
+        setConfigurationObjectListNoThrow(DEVICE_METRICS_COLLECTOR_TYPE_NAME, collectors);
+    }
+
     /**
      * {@inheritDoc}
      */
diff --git a/src/com/android/tradefed/config/ConfigurationFactory.java b/src/com/android/tradefed/config/ConfigurationFactory.java
index d200b1f..9ce9a0e 100644
--- a/src/com/android/tradefed/config/ConfigurationFactory.java
+++ b/src/com/android/tradefed/config/ConfigurationFactory.java
@@ -268,7 +268,7 @@
 
             if (def == null || def.isStale()) {
                 def = new ConfigurationDef(configName);
-                loadConfiguration(configName, def, templateMap);
+                loadConfiguration(configName, def, null, templateMap);
                 mConfigDefMap.put(configId, def);
             } else {
                 if (templateMap != null) {
@@ -315,15 +315,19 @@
             }
         }
 
-        @Override
         /**
-         * Configs that are bundled inside the tradefed.jar can only include
-         * other configs also bundled inside tradefed.jar. However, local
-         * (external) configs can include both local (external) and bundled
-         * configs.
+         * Configs that are bundled inside the tradefed.jar can only include other configs also
+         * bundled inside tradefed.jar. However, local (external) configs can include both local
+         * (external) and bundled configs.
          */
-        public void loadIncludedConfiguration(ConfigurationDef def, String parentName, String name,
-                Map<String, String> templateMap) throws ConfigurationException {
+        @Override
+        public void loadIncludedConfiguration(
+                ConfigurationDef def,
+                String parentName,
+                String name,
+                String deviceTagObject,
+                Map<String, String> templateMap)
+                throws ConfigurationException {
 
             String config_name = name;
             if (!isBundledConfig(name)) {
@@ -361,25 +365,31 @@
                         "Circular configuration include: config '%s' is already included",
                         config_name));
             }
-            loadConfiguration(config_name, def, templateMap);
+            loadConfiguration(config_name, def, deviceTagObject, templateMap);
         }
 
         /**
          * Loads a configuration.
          *
-         * @param name the name of a built-in configuration to load or a file
-         *            path to configuration xml to load
+         * @param name the name of a built-in configuration to load or a file path to configuration
+         *     xml to load
          * @param def the loaded {@link ConfigurationDef}
-         * @param templateMap map from template-include names to their
-         *            respective concrete configuration files
-         * @throws ConfigurationException if a configuration with given
-         *             name/file path cannot be loaded or parsed
+         * @param deviceTagObject name of the current deviceTag if we are loading from a config
+         *     inside an <include>. Null otherwise.
+         * @param templateMap map from template-include names to their respective concrete
+         *     configuration files
+         * @throws ConfigurationException if a configuration with given name/file path cannot be
+         *     loaded or parsed
          */
-        void loadConfiguration(String name, ConfigurationDef def, Map<String, String> templateMap)
+        void loadConfiguration(
+                String name,
+                ConfigurationDef def,
+                String deviceTagObject,
+                Map<String, String> templateMap)
                 throws ConfigurationException {
             //Log.d(LOG_TAG, String.format("Loading configuration '%s'", name));
             BufferedInputStream bufStream = getConfigStream(name);
-            ConfigurationXmlParser parser = new ConfigurationXmlParser(this);
+            ConfigurationXmlParser parser = new ConfigurationXmlParser(this, deviceTagObject);
             parser.parse(def, name, bufStream, templateMap);
 
             // Track local config source files
@@ -414,13 +424,14 @@
     /**
      * Retrieve the {@link ConfigurationDef} for the given name
      *
-     * @param name the name of a built-in configuration to load or a file path
-     *            to configuration xml to load
+     * @param name the name of a built-in configuration to load or a file path to configuration xml
+     *     to load
      * @return {@link ConfigurationDef}
      * @throws ConfigurationException if an error occurred loading the config
      */
-    private ConfigurationDef getConfigurationDef(String name, boolean isGlobal,
-            Map<String, String> templateMap) throws ConfigurationException {
+    ConfigurationDef getConfigurationDef(
+            String name, boolean isGlobal, Map<String, String> templateMap)
+            throws ConfigurationException {
         return new ConfigLoader(isGlobal).getConfigurationDef(name, templateMap);
     }
 
diff --git a/src/com/android/tradefed/config/ConfigurationXmlParser.java b/src/com/android/tradefed/config/ConfigurationXmlParser.java
index c36f530..32e06fd 100644
--- a/src/com/android/tradefed/config/ConfigurationXmlParser.java
+++ b/src/com/android/tradefed/config/ConfigurationXmlParser.java
@@ -62,6 +62,7 @@
         private final ConfigurationDef mConfigDef;
         private final Map<String, String> mTemplateMap;
         private final String mName;
+        private final boolean mInsideParentDeviceTag;
 
         // State-holding members
         private String mCurrentConfigObject;
@@ -72,11 +73,17 @@
 
         private Boolean isLocalConfig = null;
 
-        ConfigHandler(ConfigurationDef def, String name, IConfigDefLoader loader,
+        ConfigHandler(
+                ConfigurationDef def,
+                String name,
+                IConfigDefLoader loader,
+                String parentDeviceObject,
                 Map<String, String> templateMap) {
             mName = name;
             mConfigDef = def;
             mConfigDefLoader = loader;
+            mCurrentDeviceObject = parentDeviceObject;
+            mInsideParentDeviceTag = (parentDeviceObject != null) ? true : false;
 
             if (templateMap == null) {
                 mTemplateMap = Collections.<String, String>emptyMap();
@@ -141,7 +148,7 @@
                     // if it turns out we are in multi mode, we will throw an exception.
                     mOutsideTag.add(localName);
                 }
-                //if we are inside a device object, some tags are not allowed.
+                // if we are inside a device object, some tags are not allowed.
                 if (mCurrentDeviceObject != null) {
                     if (!Configuration.doesBuiltInObjSupportMultiDevice(localName)) {
                         // Prevent some tags to be inside of a device in multi device mode.
@@ -202,13 +209,9 @@
                 if (includeName == null) {
                     throwException("Missing 'name' attribute for include");
                 }
-                if (mCurrentDeviceObject != null) {
-                    // TODO: Add this use case.
-                    throwException("<include> inside device object currently not supported.");
-                }
                 try {
-                    mConfigDefLoader.loadIncludedConfiguration(mConfigDef, mName, includeName,
-                            mTemplateMap);
+                    mConfigDefLoader.loadIncludedConfiguration(
+                            mConfigDef, mName, includeName, mCurrentDeviceObject, mTemplateMap);
                 } catch (ConfigurationException e) {
                     if (e instanceof TemplateResolutionError) {
                         throwException(String.format(INNER_TEMPLATE_INCLUDE_ERROR,
@@ -236,8 +239,8 @@
                 // Removing the used template from the map to avoid re-using it.
                 mTemplateMap.remove(templateName);
                 try {
-                    mConfigDefLoader.loadIncludedConfiguration(mConfigDef, mName, includeName,
-                            mTemplateMap);
+                    mConfigDefLoader.loadIncludedConfiguration(
+                            mConfigDef, mName, includeName, null, mTemplateMap);
                 } catch (ConfigurationException e) {
                     if (e instanceof TemplateResolutionError) {
                         throwException(String.format(INNER_TEMPLATE_INCLUDE_ERROR,
@@ -257,7 +260,8 @@
                     || GlobalConfiguration.isBuiltInObjType(localName)) {
                 mCurrentConfigObject = null;
             }
-            if (DEVICE_TAG.equals(localName)) {
+            if (DEVICE_TAG.equals(localName) && !mInsideParentDeviceTag) {
+                // Only unset if it was not the parent device tag.
                 mCurrentDeviceObject = null;
             }
         }
@@ -301,9 +305,15 @@
     }
 
     private final IConfigDefLoader mConfigDefLoader;
+    /**
+     * If we are loading a config from inside a <device> tag, this will contain the name of the
+     * current device tag to properly load in context.
+     */
+    private final String mParentDeviceObject;
 
-    ConfigurationXmlParser(IConfigDefLoader loader) {
+    ConfigurationXmlParser(IConfigDefLoader loader, String parentDeviceObject) {
         mConfigDefLoader = loader;
+        mParentDeviceObject = parentDeviceObject;
     }
 
     /**
@@ -323,8 +333,9 @@
             SAXParserFactory parserFactory = SAXParserFactory.newInstance();
             parserFactory.setNamespaceAware(true);
             SAXParser parser = parserFactory.newSAXParser();
-            ConfigHandler configHandler = new ConfigHandler(configDef, name, mConfigDefLoader,
-                    templateMap);
+            ConfigHandler configHandler =
+                    new ConfigHandler(
+                            configDef, name, mConfigDefLoader, mParentDeviceObject, templateMap);
             parser.parse(new InputSource(xmlInput), configHandler);
             checkValidMultiConfiguration(configHandler);
         } catch (ParserConfigurationException e) {
diff --git a/src/com/android/tradefed/config/GlobalConfiguration.java b/src/com/android/tradefed/config/GlobalConfiguration.java
index 80d26cb..cef3da6 100644
--- a/src/com/android/tradefed/config/GlobalConfiguration.java
+++ b/src/com/android/tradefed/config/GlobalConfiguration.java
@@ -64,13 +64,13 @@
     public static final String KEY_STORE_TYPE_NAME = "key_store";
     public static final String SHARDING_STRATEGY_TYPE_NAME = "sharding_strategy";
 
+    public static final String GLOBAL_CONFIG_VARIABLE = "TF_GLOBAL_CONFIG";
+    private static final String GLOBAL_CONFIG_FILENAME = "tf_global_config.xml";
+
     private static Map<String, ObjTypeInfo> sObjTypeMap = null;
     private static IGlobalConfiguration sInstance = null;
     private static final Object sInstanceLock = new Object();
 
-    private static final String GLOBAL_CONFIG_VARIABLE = "TF_GLOBAL_CONFIG";
-    private static final String GLOBAL_CONFIG_FILENAME = "tf_global_config.xml";
-
     // Empty embedded configuration available by default
     private static final String DEFAULT_EMPTY_CONFIG_NAME = "empty";
 
diff --git a/src/com/android/tradefed/config/IConfigDefLoader.java b/src/com/android/tradefed/config/IConfigDefLoader.java
index b235f76..d6dd9c9 100644
--- a/src/com/android/tradefed/config/IConfigDefLoader.java
+++ b/src/com/android/tradefed/config/IConfigDefLoader.java
@@ -42,8 +42,15 @@
      * @param def the {@link ConfigurationDef} to load the data into
      * @param parentName the name of the parent config
      * @param name the name of config to include
+     * @param deviceTagObject the name of the current deviceTag or null if not inside a device tag.
+     * @param templateMap the current map of template to be loaded.
      * @throws ConfigurationException if an error occurred loading the config
      */
-    void loadIncludedConfiguration(ConfigurationDef def, String parentName, String name,
-            Map<String, String> templateMap) throws ConfigurationException;
+    void loadIncludedConfiguration(
+            ConfigurationDef def,
+            String parentName,
+            String name,
+            String deviceTagObject,
+            Map<String, String> templateMap)
+            throws ConfigurationException;
 }
diff --git a/src/com/android/tradefed/config/IConfiguration.java b/src/com/android/tradefed/config/IConfiguration.java
index 88e2b43..4270b4a 100644
--- a/src/com/android/tradefed/config/IConfiguration.java
+++ b/src/com/android/tradefed/config/IConfiguration.java
@@ -22,6 +22,7 @@
 import com.android.tradefed.device.IDeviceRecovery;
 import com.android.tradefed.device.IDeviceSelection;
 import com.android.tradefed.device.TestDeviceOptions;
+import com.android.tradefed.device.metric.IMetricCollector;
 import com.android.tradefed.log.ILeveledLogOutput;
 import com.android.tradefed.profiler.ITestProfiler;
 import com.android.tradefed.result.ILogSaver;
@@ -129,6 +130,9 @@
      */
     public ITestProfiler getProfiler();
 
+    /** Gets the {@link IMetricCollector}s from the configuration. */
+    public List<IMetricCollector> getMetricCollectors();
+
     /**
      * Gets the {@link ICommandOptions} to use from the configuration.
      *
@@ -346,6 +350,9 @@
      */
     public void setTestInvocationListener(ITestInvocationListener listener);
 
+    /** Set the list of {@link IMetricCollector}s, replacing any existing values. */
+    public void setDeviceMetricCollectors(List<IMetricCollector> collectors);
+
     /**
      * Set the {@link ITestProfiler}, replacing any existing values
      *
diff --git a/src/com/android/tradefed/config/SandboxConfigurationFactory.java b/src/com/android/tradefed/config/SandboxConfigurationFactory.java
new file mode 100644
index 0000000..490413d
--- /dev/null
+++ b/src/com/android/tradefed/config/SandboxConfigurationFactory.java
@@ -0,0 +1,93 @@
+/*
+ * Copyright (C) 2017 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.config;
+
+import com.android.tradefed.log.LogUtil.CLog;
+import com.android.tradefed.sandbox.ISandbox;
+import com.android.tradefed.sandbox.SandboxConfigDump.DumpCmd;
+import com.android.tradefed.sandbox.SandboxConfigUtil;
+import com.android.tradefed.util.FileUtil;
+import com.android.tradefed.util.IRunUtil;
+import com.android.tradefed.util.keystore.IKeyStoreClient;
+
+import java.io.File;
+import java.util.Map;
+
+/** Special Configuration factory to handle creation of configurations for Sandboxing purpose. */
+public class SandboxConfigurationFactory extends ConfigurationFactory {
+
+    private static SandboxConfigurationFactory sInstance = null;
+
+    /** Get the singleton {@link IConfigurationFactory} instance. */
+    public static SandboxConfigurationFactory getInstance() {
+        if (sInstance == null) {
+            sInstance = new SandboxConfigurationFactory();
+        }
+        return sInstance;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    ConfigurationDef getConfigurationDef(
+            String name, boolean isGlobal, Map<String, String> templateMap)
+            throws ConfigurationException {
+        // TODO: Extend ConfigurationDef to possibly create a different IConfiguration type and
+        // handle more elegantly the parent/subprocess incompatibilities.
+        ConfigurationDef def = new ConfigurationDef(name);
+        new ConfigLoader(isGlobal).loadConfiguration(name, def, null, templateMap);
+        return def;
+    }
+
+    /**
+     * Create a {@link IConfiguration} based on the command line and sandbox provided.
+     *
+     * @param args the command line for the run.
+     * @param keyStoreClient the {@link IKeyStoreClient} where to load the key from.
+     * @param sandbox the {@link ISandbox} used for the run.
+     * @param runUtil the {@link IRunUtil} to run commands.
+     * @return a {@link IConfiguration} valid for the sandbox.
+     * @throws ConfigurationException
+     */
+    public IConfiguration createConfigurationFromArgs(
+            String[] args, IKeyStoreClient keyStoreClient, ISandbox sandbox, IRunUtil runUtil)
+            throws ConfigurationException {
+        IConfiguration config = null;
+        File xmlConfig = null;
+        try {
+            runUtil.unsetEnvVariable(GlobalConfiguration.GLOBAL_CONFIG_VARIABLE);
+            File tfDir = sandbox.getTradefedEnvironment(args);
+            // TODO: dump using the keystore too
+            xmlConfig =
+                    SandboxConfigUtil.dumpConfigForVersion(
+                            tfDir, runUtil, args, DumpCmd.NON_VERSIONED_CONFIG);
+            // Get the non version part of the configuration in order to do proper allocation
+            // of devices and such.
+            config =
+                    super.createConfigurationFromArgs(
+                            new String[] {xmlConfig.getAbsolutePath()}, null, keyStoreClient);
+            // Reset the command line to the original one.
+            config.setCommandLine(args);
+            config.setConfigurationObject(Configuration.SANDBOX_TYPE_NAME, sandbox);
+        } catch (ConfigurationException e) {
+            CLog.e(e);
+            sandbox.tearDown();
+            throw e;
+        } finally {
+            FileUtil.deleteFile(xmlConfig);
+        }
+        return config;
+    }
+}
diff --git a/src/com/android/tradefed/device/metric/BaseDeviceMetricCollector.java b/src/com/android/tradefed/device/metric/BaseDeviceMetricCollector.java
new file mode 100644
index 0000000..9f6fbc0
--- /dev/null
+++ b/src/com/android/tradefed/device/metric/BaseDeviceMetricCollector.java
@@ -0,0 +1,155 @@
+/*
+ * Copyright (C) 2017 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.device.metric;
+
+import com.android.ddmlib.testrunner.TestIdentifier;
+import com.android.tradefed.build.IBuildInfo;
+import com.android.tradefed.device.ITestDevice;
+import com.android.tradefed.invoker.IInvocationContext;
+import com.android.tradefed.result.ITestInvocationListener;
+import com.android.tradefed.result.InputStreamSource;
+import com.android.tradefed.result.LogDataType;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Base implementation of {@link IMetricCollector} that allows to start and stop collection on
+ * {@link #onTestRunStart(DeviceMetricData)} and {@link #onTestRunEnd(DeviceMetricData)}.
+ */
+public class BaseDeviceMetricCollector implements IMetricCollector {
+
+    private IInvocationContext mContext;
+    private ITestInvocationListener mForwarder;
+    private DeviceMetricData mRunData;
+
+    @Override
+    public ITestInvocationListener init(
+            IInvocationContext context, ITestInvocationListener listener) {
+        mContext = context;
+        mForwarder = listener;
+        return this;
+    }
+
+    @Override
+    public List<ITestDevice> getDevices() {
+        return mContext.getDevices();
+    }
+
+    @Override
+    public List<IBuildInfo> getBuildInfos() {
+        return mContext.getBuildInfos();
+    }
+
+    @Override
+    public ITestInvocationListener getInvocationListener() {
+        return mForwarder;
+    }
+
+    @Override
+    public void onTestRunStart(DeviceMetricData runData) {
+        // Does nothing
+    }
+
+    @Override
+    public void onTestRunEnd(DeviceMetricData runData) {
+        // Does nothing
+    }
+
+    /** =================================== */
+    /** Invocation Listeners for forwarding */
+    @Override
+    public final void invocationStarted(IInvocationContext context) {
+        mForwarder.invocationStarted(context);
+    }
+
+    @Override
+    public final void invocationFailed(Throwable cause) {
+        mForwarder.invocationFailed(cause);
+    }
+
+    @Override
+    public final void invocationEnded(long elapsedTime) {
+        mForwarder.invocationEnded(elapsedTime);
+    }
+
+    @Override
+    public final void testLog(String dataName, LogDataType dataType, InputStreamSource dataStream) {
+        mForwarder.testLog(dataName, dataType, dataStream);
+    }
+
+    /** Test run callbacks */
+    @Override
+    public final void testRunStarted(String runName, int testCount) {
+        mRunData = new DeviceMetricData();
+        onTestRunStart(mRunData);
+        mForwarder.testRunStarted(runName, testCount);
+    }
+
+    @Override
+    public final void testRunFailed(String errorMessage) {
+        mForwarder.testRunFailed(errorMessage);
+    }
+
+    @Override
+    public final void testRunStopped(long elapsedTime) {
+        mForwarder.testRunStopped(elapsedTime);
+    }
+
+    @Override
+    public final void testRunEnded(long elapsedTime, Map<String, String> runMetrics) {
+        onTestRunEnd(mRunData);
+        mRunData.addToMetrics(runMetrics);
+        mForwarder.testRunEnded(elapsedTime, runMetrics);
+    }
+
+    /** Test cases callbacks */
+    @Override
+    public final void testStarted(TestIdentifier test) {
+        testStarted(test, System.currentTimeMillis());
+    }
+
+    @Override
+    public final void testStarted(TestIdentifier test, long startTime) {
+        mForwarder.testStarted(test, startTime);
+    }
+
+    @Override
+    public final void testFailed(TestIdentifier test, String trace) {
+        mForwarder.testFailed(test, trace);
+    }
+
+    @Override
+    public final void testEnded(TestIdentifier test, Map<String, String> testMetrics) {
+        testEnded(test, System.currentTimeMillis(), testMetrics);
+    }
+
+    @Override
+    public final void testEnded(
+            TestIdentifier test, long endTime, Map<String, String> testMetrics) {
+        mForwarder.testEnded(test, endTime, testMetrics);
+    }
+
+    @Override
+    public final void testAssumptionFailure(TestIdentifier test, String trace) {
+        mForwarder.testAssumptionFailure(test, trace);
+    }
+
+    @Override
+    public final void testIgnored(TestIdentifier test) {
+        mForwarder.testIgnored(test);
+    }
+}
diff --git a/src/com/android/tradefed/device/metric/DeviceMetricData.java b/src/com/android/tradefed/device/metric/DeviceMetricData.java
new file mode 100644
index 0000000..d31324d
--- /dev/null
+++ b/src/com/android/tradefed/device/metric/DeviceMetricData.java
@@ -0,0 +1,46 @@
+/*
+ * Copyright (C) 2017 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.device.metric;
+
+import java.io.Serializable;
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+/**
+ * Object to hold all the data collected by metric collectors. TODO: Add the data holding and
+ * receiving of data methods.
+ */
+public class DeviceMetricData implements Serializable {
+    private static final long serialVersionUID = 1;
+
+    // TODO: expend type supports to more complex type: Object, File, etc.
+    private LinkedHashMap<String, String> mCurrentStringMetrics = new LinkedHashMap<>();
+
+    public void addStringMetric(String key, String value) {
+        mCurrentStringMetrics.put(key, value);
+    }
+
+    /**
+     * Push all the data received so far to the map of metrics that will be reported. This should
+     * also clean up the resources after pushing them.
+     *
+     * @param metrics The metrics currently available.
+     */
+    public void addToMetrics(Map<String, String> metrics) {
+        // TODO: dump all the metrics collected to the map of metrics to be reported.
+        metrics.putAll(mCurrentStringMetrics);
+    }
+}
diff --git a/src/com/android/tradefed/device/metric/IMetricCollector.java b/src/com/android/tradefed/device/metric/IMetricCollector.java
new file mode 100644
index 0000000..b6d0ef7
--- /dev/null
+++ b/src/com/android/tradefed/device/metric/IMetricCollector.java
@@ -0,0 +1,64 @@
+/*
+ * Copyright (C) 2017 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.device.metric;
+
+import com.android.tradefed.build.IBuildInfo;
+import com.android.tradefed.device.ITestDevice;
+import com.android.tradefed.invoker.IInvocationContext;
+import com.android.tradefed.result.ITestInvocationListener;
+
+import java.util.List;
+
+/**
+ * This interface will be added as a decorator when reporting tests results in order to collect
+ * matching metrics.
+ */
+public interface IMetricCollector extends ITestInvocationListener {
+
+    /**
+     * Initialization of the collector with the current context and where to forward results.
+     *
+     * @param context the {@link IInvocationContext} for the invocation in progress.
+     * @param listener the {@link ITestInvocationListener} where to put results.
+     * @return the new listener wrapping the original one.
+     */
+    public ITestInvocationListener init(
+            IInvocationContext context, ITestInvocationListener listener);
+
+    /** Returns the list of devices available in the invocation. */
+    public List<ITestDevice> getDevices();
+
+    /** Returns the list of build information available in the invocation. */
+    public List<IBuildInfo> getBuildInfos();
+
+    /** Returns the original {@link ITestInvocationListener} where we are forwarding the results. */
+    public ITestInvocationListener getInvocationListener();
+
+    /**
+     * Callback when a test run is started.
+     *
+     * @param runData the {@link DeviceMetricData} holding the data for the run.
+     */
+    public void onTestRunStart(DeviceMetricData runData);
+
+    /**
+     * Callback when a test run is ended. This should be the time for clean up.
+     *
+     * @param runData the {@link DeviceMetricData} holding the data for the run. Will be the same
+     *     object as during {@link #onTestRunStart(DeviceMetricData)}.
+     */
+    public void onTestRunEnd(DeviceMetricData runData);
+}
diff --git a/src/com/android/tradefed/device/metric/ScheduledDeviceMetricCollector.java b/src/com/android/tradefed/device/metric/ScheduledDeviceMetricCollector.java
new file mode 100644
index 0000000..7c1f9a7
--- /dev/null
+++ b/src/com/android/tradefed/device/metric/ScheduledDeviceMetricCollector.java
@@ -0,0 +1,106 @@
+/*
+ * Copyright (C) 2017 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.device.metric;
+
+import com.android.tradefed.config.Option;
+import com.android.tradefed.log.LogUtil.CLog;
+
+import java.util.Timer;
+import java.util.TimerTask;
+
+/**
+ * A {@link IMetricCollector} that allows to run a collection task periodically at a set interval.
+ */
+public abstract class ScheduledDeviceMetricCollector extends BaseDeviceMetricCollector {
+
+    @Option(
+        name = "fixed-schedule-rate",
+        description = "Schedule the timetask as a fixed schedule rate"
+    )
+    private boolean mFixedScheduleRate = false;
+
+    @Option(
+        name = "interval",
+        description = "the interval between two tasks being scheduled",
+        isTimeVal = true
+    )
+    private long mIntervalMs = 60 * 1000l;
+
+    private Timer timer;
+
+    @Override
+    public final void onTestRunStart(DeviceMetricData runData) {
+        CLog.d("starting");
+        onStart(runData);
+        timer = new Timer();
+        TimerTask timerTask =
+                new TimerTask() {
+                    @Override
+                    public void run() {
+                        try {
+                            collect(runData);
+                        } catch (InterruptedException e) {
+                            timer.cancel();
+                            Thread.currentThread().interrupt();
+                            CLog.e("Interrupted exception thrown from task:");
+                            CLog.e(e);
+                        }
+                    }
+                };
+
+        if (mFixedScheduleRate) {
+            timer.scheduleAtFixedRate(timerTask, 0, mIntervalMs);
+        } else {
+            timer.schedule(timerTask, 0, mIntervalMs);
+        }
+    }
+
+    @Override
+    public final void onTestRunEnd(DeviceMetricData runData) {
+        if (timer != null) {
+            timer.cancel();
+            timer.purge();
+        }
+        onEnd(runData);
+        CLog.d("finished");
+    }
+
+    /**
+     * Task periodically & asynchronously run during the test running.
+     *
+     * @param runData the {@link DeviceMetricData} where to put metrics.
+     * @throws InterruptedException
+     */
+    abstract void collect(DeviceMetricData runData) throws InterruptedException;
+
+    /**
+     * Executed when entering this collector.
+     *
+     * @param runData the {@link DeviceMetricData} where to put metrics.
+     */
+    void onStart(DeviceMetricData runData) {
+        // Does nothing.
+    }
+
+    /**
+     * Executed when finishing this collector.
+     *
+     * @param runData the {@link DeviceMetricData} where to put metrics.
+     */
+    void onEnd(DeviceMetricData runData) {
+        // Does nothing.
+    }
+}
diff --git a/src/com/android/tradefed/invoker/ShardListener.java b/src/com/android/tradefed/invoker/ShardListener.java
index ea0f1fb..8aa5625 100644
--- a/src/com/android/tradefed/invoker/ShardListener.java
+++ b/src/com/android/tradefed/invoker/ShardListener.java
@@ -113,7 +113,19 @@
         super.invocationEnded(elapsedTime);
         synchronized (mMasterListener) {
             logShardContent(getRunResults());
+            IInvocationContext moduleContext = null;
             for (TestRunResult runResult : getRunResults()) {
+                // Stop or start the module
+                if (moduleContext != null
+                        && !getModuleContextForRunResult(runResult).equals(moduleContext)) {
+                    mMasterListener.testModuleEnded();
+                    moduleContext = null;
+                }
+                if (moduleContext == null && getModuleContextForRunResult(runResult) != null) {
+                    moduleContext = getModuleContextForRunResult(runResult);
+                    mMasterListener.testModuleStarted(moduleContext);
+                }
+
                 mMasterListener.testRunStarted(runResult.getName(), runResult.getNumTests());
                 forwardTestResults(runResult.getTestResults());
                 if (runResult.isRunFailure()) {
@@ -121,6 +133,11 @@
                 }
                 mMasterListener.testRunEnded(runResult.getElapsedTime(), runResult.getRunMetrics());
             }
+            // Close the last module
+            if (moduleContext != null) {
+                mMasterListener.testModuleEnded();
+                moduleContext = null;
+            }
             mMasterListener.invocationEnded(elapsedTime);
         }
     }
diff --git a/src/com/android/tradefed/invoker/TestInvocation.java b/src/com/android/tradefed/invoker/TestInvocation.java
index dd9a4fb..13cdae0 100644
--- a/src/com/android/tradefed/invoker/TestInvocation.java
+++ b/src/com/android/tradefed/invoker/TestInvocation.java
@@ -32,6 +32,7 @@
 import com.android.tradefed.device.ITestDevice.RecoveryMode;
 import com.android.tradefed.device.StubDevice;
 import com.android.tradefed.device.TestDeviceState;
+import com.android.tradefed.device.metric.IMetricCollector;
 import com.android.tradefed.invoker.shard.IShardHelper;
 import com.android.tradefed.invoker.shard.ShardBuildCloner;
 import com.android.tradefed.log.ILeveledLogOutput;
@@ -762,8 +763,15 @@
      * @param listener the {@link ITestInvocationListener} of test results
      * @throws DeviceNotAvailableException
      */
-    private void runTests(IInvocationContext context, IConfiguration config,
-            ITestInvocationListener listener) throws DeviceNotAvailableException {
+    @VisibleForTesting
+    void runTests(
+            IInvocationContext context, IConfiguration config, ITestInvocationListener listener)
+            throws DeviceNotAvailableException {
+        // Wrap collectors in each other and collection will be sequential
+        for (IMetricCollector collector : config.getMetricCollectors()) {
+            listener = collector.init(context, listener);
+        }
+
         for (IRemoteTest test : config.getTests()) {
             // For compatibility of those receivers, they are assumed to be single device alloc.
             if (test instanceof IDeviceTest) {
diff --git a/src/com/android/tradefed/result/CollectingTestListener.java b/src/com/android/tradefed/result/CollectingTestListener.java
index 80d5987..edecf72 100644
--- a/src/com/android/tradefed/result/CollectingTestListener.java
+++ b/src/com/android/tradefed/result/CollectingTestListener.java
@@ -42,7 +42,10 @@
     // Uses a LinkedHashmap to have predictable iteration order
     private Map<String, TestRunResult> mRunResultsMap =
         Collections.synchronizedMap(new LinkedHashMap<String, TestRunResult>());
+    private Map<TestRunResult, IInvocationContext> mModuleContextMap =
+            Collections.synchronizedMap(new LinkedHashMap<TestRunResult, IInvocationContext>());
     private TestRunResult mCurrentResults =  new TestRunResult();
+    private IInvocationContext mCurrentModuleContext = null;
 
     /** represents sums of tests in each TestStatus state for all runs.
      * Indexed by TestStatus.ordinal() */
@@ -116,6 +119,16 @@
         mBuildInfo = buildInfo;
     }
 
+    @Override
+    public void testModuleStarted(IInvocationContext moduleContext) {
+        mCurrentModuleContext = moduleContext;
+    }
+
+    @Override
+    public void testModuleEnded() {
+        mCurrentModuleContext = null;
+    }
+
     /**
      * {@inheritDoc}
      */
@@ -130,6 +143,10 @@
             mCurrentResults.setAggregateMetrics(mIsAggregateMetrics);
 
             mRunResultsMap.put(name, mCurrentResults);
+            // track the module context associated with the results.
+            if (mCurrentModuleContext != null) {
+                mModuleContextMap.put(mCurrentResults, mCurrentModuleContext);
+            }
         }
         mCurrentResults.testRunStarted(name, numTests);
         mIsCountDirty = true;
@@ -232,6 +249,14 @@
         return mRunResultsMap.values();
     }
 
+    /**
+     * Returns the {@link IInvocationContext} of the module associated with the results or null if
+     * it was not associated with any module.
+     */
+    public IInvocationContext getModuleContextForRunResult(TestRunResult res) {
+        return mModuleContextMap.get(res);
+    }
+
     /** Returns True if the result map already has an entry for the run name. */
     public boolean hasResultFor(String runName) {
         return mRunResultsMap.containsKey(runName);
diff --git a/src/com/android/tradefed/result/ITestInvocationListener.java b/src/com/android/tradefed/result/ITestInvocationListener.java
index c8fbd7c..5e951d8 100644
--- a/src/com/android/tradefed/result/ITestInvocationListener.java
+++ b/src/com/android/tradefed/result/ITestInvocationListener.java
@@ -20,6 +20,7 @@
 import com.android.tradefed.command.ICommandScheduler;
 import com.android.tradefed.invoker.IInvocationContext;
 import com.android.tradefed.log.ITestLogger;
+import com.android.tradefed.testtype.suite.ITestSuite;
 
 import java.util.Map;
 
@@ -95,6 +96,18 @@
     }
 
     /**
+     * Reports the beginning of a module running. This callback is associated with {@link
+     * #testModuleEnded()} and is optional in the sequence. It is only used during a run that uses
+     * modules: {@link ITestSuite} based runners.
+     *
+     * @param moduleContext the {@link IInvocationContext} of the module.
+     */
+    public default void testModuleStarted(IInvocationContext moduleContext) {}
+
+    /** Reports the end of a module run. */
+    public default void testModuleEnded() {}
+
+    /**
      * {@inheritDoc}
      */
     @Override
diff --git a/src/com/android/tradefed/result/ResultForwarder.java b/src/com/android/tradefed/result/ResultForwarder.java
index 494fc7a..41a3e88 100644
--- a/src/com/android/tradefed/result/ResultForwarder.java
+++ b/src/com/android/tradefed/result/ResultForwarder.java
@@ -297,4 +297,18 @@
             }
         }
     }
+
+    @Override
+    public void testModuleStarted(IInvocationContext moduleContext) {
+        for (ITestInvocationListener listener : mListeners) {
+            listener.testModuleStarted(moduleContext);
+        }
+    }
+
+    @Override
+    public void testModuleEnded() {
+        for (ITestInvocationListener listener : mListeners) {
+            listener.testModuleEnded();
+        }
+    }
 }
diff --git a/src/com/android/tradefed/sandbox/SandboxConfigUtil.java b/src/com/android/tradefed/sandbox/SandboxConfigUtil.java
index e0c0159..5aaa4e7 100644
--- a/src/com/android/tradefed/sandbox/SandboxConfigUtil.java
+++ b/src/com/android/tradefed/sandbox/SandboxConfigUtil.java
@@ -15,18 +15,13 @@
  */
 package com.android.tradefed.sandbox;
 
-import com.android.tradefed.config.Configuration;
 import com.android.tradefed.config.ConfigurationException;
 import com.android.tradefed.config.IConfiguration;
-import com.android.tradefed.config.IConfigurationFactory;
-import com.android.tradefed.log.LogUtil.CLog;
 import com.android.tradefed.sandbox.SandboxConfigDump.DumpCmd;
 import com.android.tradefed.util.CommandResult;
 import com.android.tradefed.util.CommandStatus;
 import com.android.tradefed.util.FileUtil;
 import com.android.tradefed.util.IRunUtil;
-import com.android.tradefed.util.RunUtil;
-import com.android.tradefed.util.keystore.IKeyStoreClient;
 
 import java.io.File;
 import java.io.IOException;
@@ -75,44 +70,4 @@
         FileUtil.deleteFile(destination);
         throw new ConfigurationException(result.getStderr());
     }
-
-    /**
-     * Create a {@link IConfiguration} based on the command line and sandbox provided.
-     *
-     * @param args the command line for the run.
-     * @param sandbox the {@link ISandbox} used for the run.
-     * @param configFactory the {@link IConfigurationFactory} used to load the config on parent side
-     * @param keystore the {@link IKeyStoreClient} where to load the key from.
-     * @return a {@link IConfiguration} valid for the sandbox.
-     * @throws ConfigurationException
-     */
-    public static IConfiguration createSandboxConfiguration(
-            String args[],
-            ISandbox sandbox,
-            IConfigurationFactory configFactory,
-            IKeyStoreClient keystore)
-            throws ConfigurationException {
-        IConfiguration config = null;
-        File xmlConfig = null;
-        try {
-            File tfDir = sandbox.getTradefedEnvironment(args);
-            xmlConfig =
-                    dumpConfigForVersion(tfDir, new RunUtil(), args, DumpCmd.NON_VERSIONED_CONFIG);
-            // Get the non version part of the configuration in order to do proper allocation
-            // of devices and such.
-            config =
-                    configFactory.createConfigurationFromArgs(
-                            new String[] {xmlConfig.getAbsolutePath()}, null, keystore);
-            // Reset the command line to the original one.
-            config.setCommandLine(args);
-            config.setConfigurationObject(Configuration.SANDBOX_TYPE_NAME, sandbox);
-        } catch (ConfigurationException e) {
-            CLog.e(e);
-            sandbox.tearDown();
-            throw e;
-        } finally {
-            FileUtil.deleteFile(xmlConfig);
-        }
-        return config;
-    }
 }
diff --git a/src/com/android/tradefed/testtype/StubTest.java b/src/com/android/tradefed/testtype/StubTest.java
index 0809e41..d0d7ff8 100644
--- a/src/com/android/tradefed/testtype/StubTest.java
+++ b/src/com/android/tradefed/testtype/StubTest.java
@@ -27,6 +27,7 @@
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.LinkedHashMap;
 import java.util.List;
 
 /**
@@ -91,7 +92,7 @@
             TestIdentifier testId = new TestIdentifier("StubTest", "StubMethod");
             listener.testStarted(testId);
             listener.testEnded(testId, Collections.emptyMap());
-            listener.testRunEnded(500, Collections.emptyMap());
+            listener.testRunEnded(500, new LinkedHashMap<>());
         }
     }
 
diff --git a/src/com/android/tradefed/testtype/suite/ITestSuite.java b/src/com/android/tradefed/testtype/suite/ITestSuite.java
index cdef583..90ed34c 100644
--- a/src/com/android/tradefed/testtype/suite/ITestSuite.java
+++ b/src/com/android/tradefed/testtype/suite/ITestSuite.java
@@ -233,7 +233,7 @@
                 }
 
                 try {
-                    mContext.setModuleInvocationContext(module.getModuleInvocationContext());
+                    listener.testModuleStarted(module.getModuleInvocationContext());
                     // Populate the module context with devices and builds
                     for (String deviceName : mContext.getDeviceConfigNames()) {
                         module.getModuleInvocationContext()
@@ -245,7 +245,7 @@
                 } finally {
                     // clear out module invocation context since we are now done with module
                     // execution
-                    mContext.setModuleInvocationContext(null);
+                    listener.testModuleEnded();
                 }
             }
         } catch (DeviceNotAvailableException e) {
diff --git a/tests/res/testconfigs/multi-device-incorrect-include.xml b/tests/res/testconfigs/multi-device-incorrect-include.xml
new file mode 100644
index 0000000..ac02418
--- /dev/null
+++ b/tests/res/testconfigs/multi-device-incorrect-include.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2017 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.
+-->
+<configuration description="Multi device parsing, where an incldue also containing device is inside a device">
+    <device name="device1">
+        <!-- This is incorrect because it includes a device inside a device -->
+        <include name="multi-device-empty" />
+    </device>
+</configuration>
diff --git a/tests/res/testconfigs/test-config-multi-include.xml b/tests/res/testconfigs/test-config-multi-include.xml
new file mode 100644
index 0000000..84f6c09
--- /dev/null
+++ b/tests/res/testconfigs/test-config-multi-include.xml
@@ -0,0 +1,28 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2017 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.
+-->
+<configuration
+    description="test for multiple device receiving options, with an include">
+
+    <device name="device1" >
+        <build_provider class="com.android.tradefed.build.StubBuildProvider" />
+        <device_recovery class="com.android.tradefed.device.WaitDeviceRecovery" />
+        <target_preparer class="com.android.tradefed.targetprep.StubTargetPreparer" />
+    </device>
+    <device name="device2" >
+        <include name="mandatory-config" />
+        <target_preparer class="com.android.tradefed.targetprep.StubTargetPreparer" />
+    </device>
+</configuration>
diff --git a/tests/src/com/android/tradefed/UnitTests.java b/tests/src/com/android/tradefed/UnitTests.java
index 1b34b75..2d35fb7 100644
--- a/tests/src/com/android/tradefed/UnitTests.java
+++ b/tests/src/com/android/tradefed/UnitTests.java
@@ -45,6 +45,7 @@
 import com.android.tradefed.config.OptionCopierTest;
 import com.android.tradefed.config.OptionSetterTest;
 import com.android.tradefed.config.OptionUpdateRuleTest;
+import com.android.tradefed.config.SandboxConfigurationFactoryTest;
 import com.android.tradefed.device.BackgroundDeviceActionTest;
 import com.android.tradefed.device.CpuStatsCollectorTest;
 import com.android.tradefed.device.DeviceManagerTest;
@@ -62,7 +63,10 @@
 import com.android.tradefed.device.TopHelperTest;
 import com.android.tradefed.device.WaitDeviceRecoveryTest;
 import com.android.tradefed.device.WifiHelperTest;
+import com.android.tradefed.device.metric.ScheduledDeviceMetricCollectorTest;
+import com.android.tradefed.device.metric.BaseDeviceMetricCollectorTest;
 import com.android.tradefed.invoker.InvocationContextTest;
+import com.android.tradefed.invoker.ShardListenerTest;
 import com.android.tradefed.invoker.TestInvocationMultiTest;
 import com.android.tradefed.invoker.TestInvocationTest;
 import com.android.tradefed.invoker.shard.ShardHelperTest;
@@ -274,6 +278,7 @@
     OptionCopierTest.class,
     OptionSetterTest.class,
     OptionUpdateRuleTest.class,
+    SandboxConfigurationFactoryTest.class,
 
     // device
     BackgroundDeviceActionTest.class,
@@ -295,8 +300,13 @@
     WaitDeviceRecoveryTest.class,
     WifiHelperTest.class,
 
+    // device.metric
+    ScheduledDeviceMetricCollectorTest.class,
+    BaseDeviceMetricCollectorTest.class,
+
     // invoker
     InvocationContextTest.class,
+    ShardListenerTest.class,
     TestInvocationMultiTest.class,
     TestInvocationTest.class,
 
diff --git a/tests/src/com/android/tradefed/config/ConfigurationFactoryTest.java b/tests/src/com/android/tradefed/config/ConfigurationFactoryTest.java
index 7f0d84e..15b643f 100644
--- a/tests/src/com/android/tradefed/config/ConfigurationFactoryTest.java
+++ b/tests/src/com/android/tradefed/config/ConfigurationFactoryTest.java
@@ -16,6 +16,7 @@
 package com.android.tradefed.config;
 
 import com.android.ddmlib.Log.LogLevel;
+import com.android.tradefed.build.LocalDeviceBuildProvider;
 import com.android.tradefed.config.ConfigurationFactory.ConfigId;
 import com.android.tradefed.log.ILeveledLogOutput;
 import com.android.tradefed.log.LogUtil.CLog;
@@ -1239,6 +1240,39 @@
         assertTrue(deviceSetup2.getTestBooleanOptionFalse());
     }
 
+    /** Test that when an <include> tag is used inside a <device> tag we correctly resolve it. */
+    public void testCreateConfiguration_includeInDevice() throws Exception {
+        IConfiguration config =
+                mFactory.createConfigurationFromArgs(
+                        new String[] {"test-config-multi-include", "--test-dir", "faketestdir"});
+        assertEquals(2, config.getDeviceConfig().size());
+        IDeviceConfiguration device1 = config.getDeviceConfigByName("device1");
+        assertTrue(device1.getTargetPreparers().get(0) instanceof StubTargetPreparer);
+        // The included config in device2 loads a different build_provider
+        IDeviceConfiguration device2 = config.getDeviceConfigByName("device2");
+        assertTrue(device2.getBuildProvider() instanceof LocalDeviceBuildProvider);
+        LocalDeviceBuildProvider provider = (LocalDeviceBuildProvider) device2.getBuildProvider();
+        // command line options are properly propagated to the included object in device tag.
+        assertEquals("faketestdir", provider.getTestDir().getName());
+    }
+
+    /**
+     * Test when an <include> tag tries to load a <device> tag inside another <device> tag. This
+     * should throw an exception.
+     */
+    public void testCreateConfiguration_includeInDevice_inDevice() throws Exception {
+        try {
+            mFactory.createConfigurationFromArgs(
+                    new String[] {
+                        "multi-device-incorrect-include",
+                    });
+            fail("Should have thrown an exception.");
+        } catch (ConfigurationException expected) {
+            assertEquals(
+                    "<device> tag cannot be included inside another device", expected.getMessage());
+        }
+    }
+
     /** Test that {@link ConfigurationFactory#reorderArgs(String[])} is properly reordering args. */
     public void testReorderArgs_check_ordering() throws Throwable {
         String[] args =
diff --git a/tests/src/com/android/tradefed/config/ConfigurationXmlParserTest.java b/tests/src/com/android/tradefed/config/ConfigurationXmlParserTest.java
index 5f24da7..b1043c7 100644
--- a/tests/src/com/android/tradefed/config/ConfigurationXmlParserTest.java
+++ b/tests/src/com/android/tradefed/config/ConfigurationXmlParserTest.java
@@ -36,7 +36,7 @@
     protected void setUp() throws Exception {
         super.setUp();
         mMockLoader = EasyMock.createMock(IConfigDefLoader.class);
-        xmlParser = new ConfigurationXmlParser(mMockLoader);
+        xmlParser = new ConfigurationXmlParser(mMockLoader, null);
     }
 
     /**
@@ -162,12 +162,15 @@
     /**
      * Test parsing a include tag.
      */
-    @SuppressWarnings("unchecked")
     public void testParse_include() throws ConfigurationException {
         String includedName = "includeme";
         ConfigurationDef configDef = new ConfigurationDef("foo");
-        mMockLoader.loadIncludedConfiguration(EasyMock.eq(configDef), EasyMock.eq("foo"),
-                EasyMock.eq(includedName), (Map<String, String>) EasyMock.anyObject());
+        mMockLoader.loadIncludedConfiguration(
+                EasyMock.eq(configDef),
+                EasyMock.eq("foo"),
+                EasyMock.eq(includedName),
+                EasyMock.anyObject(),
+                EasyMock.anyObject());
         EasyMock.replay(mMockLoader);
         final String config = "<include name=\"includeme\" />";
         xmlParser.parse(configDef, "foo", getStringAsStream(config), null);
@@ -180,8 +183,8 @@
         String includedName = "non-existent";
         ConfigurationDef parent = new ConfigurationDef("name");
         ConfigurationException exception = new ConfigurationException("I don't exist");
-        mMockLoader.loadIncludedConfiguration(parent, "name", includedName,
-                Collections.<String, String>emptyMap());
+        mMockLoader.loadIncludedConfiguration(
+                parent, "name", includedName, null, Collections.<String, String>emptyMap());
         EasyMock.expectLastCall().andThrow(exception);
         EasyMock.replay(mMockLoader);
         final String config = String.format("<include name=\"%s\" />", includedName);
diff --git a/tests/src/com/android/tradefed/config/SandboxConfigurationFactoryTest.java b/tests/src/com/android/tradefed/config/SandboxConfigurationFactoryTest.java
new file mode 100644
index 0000000..c61065c
--- /dev/null
+++ b/tests/src/com/android/tradefed/config/SandboxConfigurationFactoryTest.java
@@ -0,0 +1,135 @@
+/*
+ * Copyright (C) 2017 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.config;
+
+import static org.easymock.EasyMock.eq;
+import static org.junit.Assert.*;
+
+import com.android.tradefed.sandbox.ISandbox;
+import com.android.tradefed.sandbox.SandboxConfigDump;
+import com.android.tradefed.sandbox.SandboxConfigDump.DumpCmd;
+import com.android.tradefed.util.CommandResult;
+import com.android.tradefed.util.CommandStatus;
+import com.android.tradefed.util.FileUtil;
+import com.android.tradefed.util.IRunUtil;
+import com.android.tradefed.util.keystore.StubKeyStoreClient;
+
+import org.easymock.EasyMock;
+import org.easymock.IAnswer;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.File;
+import java.io.IOException;
+
+/** Unit tests for {@link SandboxConfigurationFactory}. */
+@RunWith(JUnit4.class)
+public class SandboxConfigurationFactoryTest {
+
+    private SandboxConfigurationFactory mFactory;
+    private File mConfig;
+    private File mTmpEnvDir;
+    private ISandbox mFakeSandbox;
+    private IRunUtil mMockRunUtil;
+
+    @Before
+    public void setUp() throws IOException {
+        mFactory = SandboxConfigurationFactory.getInstance();
+        mConfig = FileUtil.createTempFile("sandbox-config-test", ".xml");
+        mTmpEnvDir = FileUtil.createTempDir("sandbox-tmp-dir");
+        mFakeSandbox = EasyMock.createMock(ISandbox.class);
+        mMockRunUtil = EasyMock.createMock(IRunUtil.class);
+    }
+
+    @After
+    public void tearDown() {
+        FileUtil.recursiveDelete(mTmpEnvDir);
+        FileUtil.deleteFile(mConfig);
+    }
+
+    private void expectDumpCmd(CommandResult res) {
+        EasyMock.expect(
+                        mMockRunUtil.runTimedCmd(
+                                EasyMock.anyLong(),
+                                eq("java"),
+                                eq("-cp"),
+                                eq(new File(mTmpEnvDir, "*").getAbsolutePath()),
+                                eq(SandboxConfigDump.class.getCanonicalName()),
+                                eq(DumpCmd.NON_VERSIONED_CONFIG.toString()),
+                                EasyMock.anyObject(),
+                                eq(mConfig.getAbsolutePath())))
+                .andAnswer(
+                        new IAnswer<CommandResult>() {
+                            @Override
+                            public CommandResult answer() throws Throwable {
+                                String resFile = (String) EasyMock.getCurrentArguments()[6];
+                                FileUtil.writeToFile(
+                                        "<configuration><test class=\"com.android.tradefed.test"
+                                                + "type.StubTest\" /></configuration>",
+                                        new File(resFile));
+                                return res;
+                            }
+                        });
+    }
+
+    /**
+     * Test that creating a configuration using a sandbox properly create a {@link IConfiguration}.
+     */
+    @Test
+    public void testCreateConfigurationFromArgs() throws ConfigurationException {
+        String[] args = new String[] {mConfig.getAbsolutePath()};
+        EasyMock.expect(mFakeSandbox.getTradefedEnvironment(EasyMock.anyObject()))
+                .andReturn(mTmpEnvDir);
+        mMockRunUtil.unsetEnvVariable(GlobalConfiguration.GLOBAL_CONFIG_VARIABLE);
+        CommandResult results = new CommandResult();
+        results.setStatus(CommandStatus.SUCCESS);
+        expectDumpCmd(results);
+        EasyMock.replay(mFakeSandbox, mMockRunUtil);
+        IConfiguration config =
+                mFactory.createConfigurationFromArgs(
+                        args, new StubKeyStoreClient(), mFakeSandbox, mMockRunUtil);
+        EasyMock.verify(mFakeSandbox, mMockRunUtil);
+        assertNotNull(config.getConfigurationObject(Configuration.SANDBOX_TYPE_NAME));
+        assertEquals(mFakeSandbox, config.getConfigurationObject(Configuration.SANDBOX_TYPE_NAME));
+    }
+
+    /** Test that when the dump config failed, we throw a ConfigurationException. */
+    @Test
+    public void testCreateConfigurationFromArgs_fail() throws ConfigurationException {
+        String[] args = new String[] {mConfig.getAbsolutePath()};
+        EasyMock.expect(mFakeSandbox.getTradefedEnvironment(EasyMock.anyObject()))
+                .andReturn(mTmpEnvDir);
+        mMockRunUtil.unsetEnvVariable(GlobalConfiguration.GLOBAL_CONFIG_VARIABLE);
+        CommandResult results = new CommandResult();
+        results.setStatus(CommandStatus.FAILED);
+        results.setStderr("I failed");
+        expectDumpCmd(results);
+        // in case of failure, tearDown is called right away for cleaning up
+        mFakeSandbox.tearDown();
+        EasyMock.replay(mFakeSandbox, mMockRunUtil);
+        try {
+            mFactory.createConfigurationFromArgs(
+                    args, new StubKeyStoreClient(), mFakeSandbox, mMockRunUtil);
+            fail("Should have thrown an exception.");
+        } catch (ConfigurationException expected) {
+            // expected
+        }
+        EasyMock.verify(mFakeSandbox, mMockRunUtil);
+    }
+}
diff --git a/tests/src/com/android/tradefed/device/metric/BaseDeviceMetricCollectorTest.java b/tests/src/com/android/tradefed/device/metric/BaseDeviceMetricCollectorTest.java
new file mode 100644
index 0000000..a1e7678
--- /dev/null
+++ b/tests/src/com/android/tradefed/device/metric/BaseDeviceMetricCollectorTest.java
@@ -0,0 +1,89 @@
+/*
+ * Copyright (C) 2017 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.device.metric;
+
+import static org.mockito.Mockito.times;
+
+import com.android.ddmlib.testrunner.TestIdentifier;
+import com.android.tradefed.invoker.IInvocationContext;
+import com.android.tradefed.invoker.InvocationContext;
+import com.android.tradefed.result.ByteArrayInputStreamSource;
+import com.android.tradefed.result.ITestInvocationListener;
+import com.android.tradefed.result.LogDataType;
+
+import org.junit.Assert;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mockito;
+
+import java.util.Collections;
+
+/** Unit tests for {@link BaseDeviceMetricCollector}. */
+@RunWith(JUnit4.class)
+public class BaseDeviceMetricCollectorTest {
+
+    private BaseDeviceMetricCollector mBase;
+    private IInvocationContext mContext;
+    private ITestInvocationListener mMockListener;
+
+    @Before
+    public void setUp() {
+        mBase = new BaseDeviceMetricCollector();
+        mContext = new InvocationContext();
+        mMockListener = Mockito.mock(ITestInvocationListener.class);
+    }
+
+    @Test
+    public void testInitAndForwarding() {
+        mBase.init(mContext, mMockListener);
+        mBase.invocationStarted(mContext);
+        mBase.testRunStarted("testRun", 1);
+        TestIdentifier test = new TestIdentifier("class", "method");
+        mBase.testStarted(test);
+        mBase.testLog("dataname", LogDataType.TEXT, new ByteArrayInputStreamSource("".getBytes()));
+        mBase.testFailed(test, "trace");
+        mBase.testAssumptionFailure(test, "trace");
+        mBase.testIgnored(test);
+        mBase.testEnded(test, Collections.emptyMap());
+        mBase.testRunFailed("test run failed");
+        mBase.testRunStopped(0l);
+        mBase.testRunEnded(0l, Collections.emptyMap());
+        mBase.invocationFailed(new Throwable());
+        mBase.invocationEnded(0l);
+
+        Mockito.verify(mMockListener, times(1)).invocationStarted(Mockito.any());
+        Mockito.verify(mMockListener, times(1)).testRunStarted("testRun", 1);
+        Mockito.verify(mMockListener, times(1)).testStarted(Mockito.eq(test), Mockito.anyLong());
+        Mockito.verify(mMockListener, times(1))
+                .testLog(Mockito.eq("dataname"), Mockito.eq(LogDataType.TEXT), Mockito.any());
+        Mockito.verify(mMockListener, times(1)).testFailed(test, "trace");
+        Mockito.verify(mMockListener, times(1)).testAssumptionFailure(test, "trace");
+        Mockito.verify(mMockListener, times(1)).testIgnored(test);
+        Mockito.verify(mMockListener, times(1))
+                .testEnded(Mockito.eq(test), Mockito.anyLong(), Mockito.eq(Collections.emptyMap()));
+        Mockito.verify(mMockListener, times(1)).testRunFailed("test run failed");
+        Mockito.verify(mMockListener, times(1)).testRunStopped(0l);
+        Mockito.verify(mMockListener, times(1)).testRunEnded(0l, Collections.emptyMap());
+        Mockito.verify(mMockListener, times(1)).invocationFailed(Mockito.any());
+        Mockito.verify(mMockListener, times(1)).invocationEnded(0l);
+
+        Assert.assertSame(mMockListener, mBase.getInvocationListener());
+        Assert.assertEquals(0, mBase.getDevices().size());
+        Assert.assertEquals(0, mBase.getBuildInfos().size());
+    }
+}
diff --git a/tests/src/com/android/tradefed/device/metric/ScheduledDeviceMetricCollectorTest.java b/tests/src/com/android/tradefed/device/metric/ScheduledDeviceMetricCollectorTest.java
new file mode 100644
index 0000000..32a5ad6
--- /dev/null
+++ b/tests/src/com/android/tradefed/device/metric/ScheduledDeviceMetricCollectorTest.java
@@ -0,0 +1,80 @@
+/*
+ * Copyright (C) 2017 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.device.metric;
+
+import static org.junit.Assert.assertTrue;
+
+import com.android.tradefed.config.OptionSetter;
+import com.android.tradefed.invoker.IInvocationContext;
+import com.android.tradefed.invoker.InvocationContext;
+import com.android.tradefed.result.ITestInvocationListener;
+import com.android.tradefed.util.RunUtil;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mockito;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/** Unit tests for {@link ScheduledDeviceMetricCollector}. */
+@RunWith(JUnit4.class)
+public class ScheduledDeviceMetricCollectorTest {
+
+    public static class TestableAsyncTimer extends ScheduledDeviceMetricCollector {
+        private int mInternalCounter = 0;
+
+        @Override
+        void collect(DeviceMetricData runData) throws InterruptedException {
+            mInternalCounter++;
+            runData.addStringMetric("key" + mInternalCounter, "value" + mInternalCounter);
+        }
+    }
+
+    private TestableAsyncTimer mBase;
+    private IInvocationContext mContext;
+    private ITestInvocationListener mMockListener;
+
+    @Before
+    public void setUp() {
+        mBase = new TestableAsyncTimer();
+        mContext = new InvocationContext();
+        mMockListener = Mockito.mock(ITestInvocationListener.class);
+    }
+
+    /** Test the periodic run of the collector once testRunStarted has been called. */
+    @Test
+    public void testSetupAndPeriodicRun() throws Exception {
+        OptionSetter setter = new OptionSetter(mBase);
+        // 100 ms interval
+        setter.setOptionValue("interval", "100");
+        Map<String, String> metrics = new HashMap<>();
+        mBase.init(mContext, mMockListener);
+        try {
+            mBase.testRunStarted("testRun", 1);
+            RunUtil.getDefault().sleep(500);
+        } finally {
+            mBase.testRunEnded(0l, metrics);
+        }
+        // We give it 500msec to run and 100msec interval we should easily have at least three
+        // iterations
+        assertTrue(metrics.containsKey("key1"));
+        assertTrue(metrics.containsKey("key2"));
+        assertTrue(metrics.containsKey("key3"));
+    }
+}
diff --git a/tests/src/com/android/tradefed/invoker/ShardListenerTest.java b/tests/src/com/android/tradefed/invoker/ShardListenerTest.java
new file mode 100644
index 0000000..705b55c
--- /dev/null
+++ b/tests/src/com/android/tradefed/invoker/ShardListenerTest.java
@@ -0,0 +1,121 @@
+/*
+ * Copyright (C) 2017 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.invoker;
+
+import com.android.ddmlib.testrunner.TestIdentifier;
+import com.android.tradefed.build.BuildInfo;
+import com.android.tradefed.device.ITestDevice;
+import com.android.tradefed.result.ITestInvocationListener;
+
+import org.easymock.EasyMock;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.util.Collections;
+
+/** Unit tests for {@link ShardListener}. */
+@RunWith(JUnit4.class)
+public class ShardListenerTest {
+    private ShardListener mShardListener;
+    private ITestInvocationListener mMockListener;
+    private IInvocationContext mContext;
+    private ITestDevice mMockDevice;
+
+    @Before
+    public void setUp() {
+        mMockListener = EasyMock.createMock(ITestInvocationListener.class);
+        mShardListener = new ShardListener(mMockListener);
+        mMockDevice = EasyMock.createMock(ITestDevice.class);
+        EasyMock.expect(mMockDevice.getSerialNumber()).andStubReturn("serial");
+        mContext = new InvocationContext();
+        mContext.addDeviceBuildInfo("default", new BuildInfo());
+        mContext.addAllocatedDevice("default", mMockDevice);
+    }
+
+    /** Ensure that all the events given to the shardlistener are replayed on invocationEnded. */
+    @Test
+    public void testBufferAndReplay() {
+        mMockListener.invocationStarted(mContext);
+        mMockListener.testRunStarted("run1", 1);
+        TestIdentifier tid = new TestIdentifier("class1", "name1");
+        mMockListener.testStarted(tid, 0l);
+        mMockListener.testEnded(tid, 0l, Collections.emptyMap());
+        mMockListener.testRunEnded(0l, Collections.emptyMap());
+        mMockListener.invocationEnded(0l);
+
+        EasyMock.replay(mMockListener, mMockDevice);
+        mShardListener.invocationStarted(mContext);
+        mShardListener.testRunStarted("run1", 1);
+        mShardListener.testStarted(tid, 0l);
+        mShardListener.testEnded(tid, 0l, Collections.emptyMap());
+        mShardListener.testRunEnded(0l, Collections.emptyMap());
+        mShardListener.invocationEnded(0l);
+        EasyMock.verify(mMockListener, mMockDevice);
+    }
+
+    /** Test that the buffering of events is properly done in respect to the modules too. */
+    @Test
+    public void testBufferAndReplay_withModule() {
+        IInvocationContext module1 = new InvocationContext();
+        IInvocationContext module2 = new InvocationContext();
+        mMockListener.invocationStarted(mContext);
+        mMockListener.testModuleStarted(module1);
+        mMockListener.testRunStarted("run1", 1);
+        TestIdentifier tid = new TestIdentifier("class1", "name1");
+        mMockListener.testStarted(tid, 0l);
+        mMockListener.testEnded(tid, 0l, Collections.emptyMap());
+        mMockListener.testRunEnded(0l, Collections.emptyMap());
+        mMockListener.testRunStarted("run2", 1);
+        mMockListener.testStarted(tid, 0l);
+        mMockListener.testEnded(tid, 0l, Collections.emptyMap());
+        mMockListener.testRunEnded(0l, Collections.emptyMap());
+        mMockListener.testModuleEnded();
+        // expectation on second module
+        mMockListener.testModuleStarted(module2);
+        mMockListener.testRunStarted("run3", 1);
+        mMockListener.testStarted(tid, 0l);
+        mMockListener.testEnded(tid, 0l, Collections.emptyMap());
+        mMockListener.testRunEnded(0l, Collections.emptyMap());
+        mMockListener.testModuleEnded();
+        mMockListener.invocationEnded(0l);
+
+        EasyMock.replay(mMockListener, mMockDevice);
+        mShardListener.invocationStarted(mContext);
+        // 1st module
+        mShardListener.testModuleStarted(module1);
+        mShardListener.testRunStarted("run1", 1);
+        mShardListener.testStarted(tid, 0l);
+        mShardListener.testEnded(tid, 0l, Collections.emptyMap());
+        mShardListener.testRunEnded(0l, Collections.emptyMap());
+        mShardListener.testRunStarted("run2", 1);
+        mShardListener.testStarted(tid, 0l);
+        mShardListener.testEnded(tid, 0l, Collections.emptyMap());
+        mShardListener.testRunEnded(0l, Collections.emptyMap());
+        mShardListener.testModuleEnded();
+        // 2nd module
+        mShardListener.testModuleStarted(module2);
+        mShardListener.testRunStarted("run3", 1);
+        mShardListener.testStarted(tid, 0l);
+        mShardListener.testEnded(tid, 0l, Collections.emptyMap());
+        mShardListener.testRunEnded(0l, Collections.emptyMap());
+        mShardListener.testModuleEnded();
+
+        mShardListener.invocationEnded(0l);
+        EasyMock.verify(mMockListener, mMockDevice);
+    }
+}
diff --git a/tests/src/com/android/tradefed/invoker/TestInvocationTest.java b/tests/src/com/android/tradefed/invoker/TestInvocationTest.java
index a8bc729..b03a479 100644
--- a/tests/src/com/android/tradefed/invoker/TestInvocationTest.java
+++ b/tests/src/com/android/tradefed/invoker/TestInvocationTest.java
@@ -18,6 +18,7 @@
 import static org.mockito.Mockito.doReturn;
 
 import com.android.ddmlib.IDevice;
+import com.android.ddmlib.testrunner.TestIdentifier;
 import com.android.tradefed.build.BuildInfo;
 import com.android.tradefed.build.BuildRetrievalError;
 import com.android.tradefed.build.IBuildInfo;
@@ -44,6 +45,9 @@
 import com.android.tradefed.device.ITestDevice.RecoveryMode;
 import com.android.tradefed.device.StubDevice;
 import com.android.tradefed.device.TestDeviceOptions;
+import com.android.tradefed.device.metric.BaseDeviceMetricCollector;
+import com.android.tradefed.device.metric.IMetricCollector;
+import com.android.tradefed.device.metric.DeviceMetricData;
 import com.android.tradefed.invoker.shard.IShardHelper;
 import com.android.tradefed.invoker.shard.ShardHelper;
 import com.android.tradefed.invoker.shard.StrictShardHelper;
@@ -72,6 +76,7 @@
 import com.android.tradefed.testtype.IRetriableTest;
 import com.android.tradefed.testtype.IShardableTest;
 import com.android.tradefed.testtype.IStrictShardableTest;
+import com.android.tradefed.testtype.StubTest;
 import com.android.tradefed.util.FileUtil;
 
 import com.google.common.util.concurrent.SettableFuture;
@@ -1657,4 +1662,54 @@
             FileUtil.recursiveDelete(tmpExternalTestsDir);
         }
     }
+
+    private class TestableCollector extends BaseDeviceMetricCollector {
+
+        private String mName;
+
+        public TestableCollector(String name) {
+            mName = name;
+        }
+
+        @Override
+        public void onTestRunEnd(DeviceMetricData runData) {
+            runData.addStringMetric(mName, mName);
+        }
+    }
+
+    /**
+     * Test that when {@link IMetricCollector} are used, they wrap and call in sequence the listener
+     * so all metrics end up on the final receiver.
+     */
+    public void testMetricCollectionChain() throws Exception {
+        IConfiguration configuration = new Configuration("test", "description");
+        StubTest test = new StubTest();
+        OptionSetter setter = new OptionSetter(test);
+        setter.setOptionValue("run-a-test", "true");
+        configuration.setTest(test);
+
+        List<IMetricCollector> collectors = new ArrayList<>();
+        collectors.add(new TestableCollector("collector1"));
+        collectors.add(new TestableCollector("collector2"));
+        collectors.add(new TestableCollector("collector3"));
+        collectors.add(new TestableCollector("collector4"));
+        configuration.setDeviceMetricCollectors(collectors);
+
+        mMockTestListener.testRunStarted("TestStub", 1);
+        TestIdentifier testId = new TestIdentifier("StubTest", "StubMethod");
+        mMockTestListener.testStarted(EasyMock.eq(testId), EasyMock.anyLong());
+        mMockTestListener.testEnded(
+                EasyMock.eq(testId), EasyMock.anyLong(), EasyMock.eq(Collections.emptyMap()));
+        Capture<Map<String, String>> captured = new Capture<>();
+        mMockTestListener.testRunEnded(EasyMock.anyLong(), EasyMock.capture(captured));
+        EasyMock.replay(mMockTestListener);
+        mTestInvocation.runTests(mStubInvocationMetadata, configuration, mMockTestListener);
+        EasyMock.verify(mMockTestListener);
+        // The collectors are called in sequence
+        List<String> listKeys = new ArrayList<>(captured.getValue().keySet());
+        assertEquals("collector4", listKeys.get(0));
+        assertEquals("collector3", listKeys.get(1));
+        assertEquals("collector2", listKeys.get(2));
+        assertEquals("collector1", listKeys.get(3));
+    }
 }
diff --git a/tests/src/com/android/tradefed/testtype/suite/ITestSuiteMultiTest.java b/tests/src/com/android/tradefed/testtype/suite/ITestSuiteMultiTest.java
index d2bafe1..0dc0fda 100644
--- a/tests/src/com/android/tradefed/testtype/suite/ITestSuiteMultiTest.java
+++ b/tests/src/com/android/tradefed/testtype/suite/ITestSuiteMultiTest.java
@@ -111,7 +111,7 @@
         mTestSuite.setInvocationContext(mContext);
 
         mTestSuite.setSystemStatusChecker(new ArrayList<>());
-
+        mMockListener.testModuleStarted(EasyMock.anyObject());
         mMockListener.testRunStarted("test1", 2);
         TestIdentifier test1 =
                 new TestIdentifier(MultiDeviceStubTest.class.getSimpleName(), "test0");
@@ -122,7 +122,7 @@
         mMockListener.testStarted(test2, 0l);
         mMockListener.testEnded(test2, 5l, Collections.emptyMap());
         mMockListener.testRunEnded(EasyMock.anyLong(), EasyMock.anyObject());
-
+        mMockListener.testModuleEnded();
         EasyMock.replay(
                 mMockListener, mMockBuildInfo1, mMockBuildInfo2, mMockDevice1, mMockDevice2);
         mTestSuite.run(mMockListener);
diff --git a/tests/src/com/android/tradefed/testtype/suite/ITestSuiteTest.java b/tests/src/com/android/tradefed/testtype/suite/ITestSuiteTest.java
index 3750c4d..f691558 100644
--- a/tests/src/com/android/tradefed/testtype/suite/ITestSuiteTest.java
+++ b/tests/src/com/android/tradefed/testtype/suite/ITestSuiteTest.java
@@ -166,11 +166,13 @@
 
     /** Helper to expect the test run callback. */
     private void expectTestRun(ITestInvocationListener listener) {
+        listener.testModuleStarted(EasyMock.anyObject());
         listener.testRunStarted(TEST_CONFIG_NAME, 1);
         TestIdentifier test = new TestIdentifier(EMPTY_CONFIG, EMPTY_CONFIG);
         listener.testStarted(test, 0);
         listener.testEnded(test, 5, Collections.emptyMap());
         listener.testRunEnded(EasyMock.anyLong(), EasyMock.anyObject());
+        listener.testModuleEnded();
     }
 
     /** Test for {@link ITestSuite#run(ITestInvocationListener)}. */
@@ -307,11 +309,13 @@
         setter.setOptionValue("skip-all-system-status-check", "true");
         setter.setOptionValue("reboot-per-module", "true");
         EasyMock.expect(mMockDevice.getProperty("ro.build.type")).andReturn("user");
+        mMockListener.testModuleStarted(EasyMock.anyObject());
         mMockListener.testRunStarted(TEST_CONFIG_NAME, 1);
         EasyMock.expectLastCall().times(1);
         mMockListener.testRunFailed("Module test only ran 0 out of 1 expected tests.");
         mMockListener.testRunEnded(EasyMock.anyLong(), EasyMock.anyObject());
         EasyMock.expectLastCall().times(1);
+        mMockListener.testModuleEnded();
         replayMocks();
         mTestSuite.run(mMockListener);
         verifyMocks();
@@ -352,11 +356,13 @@
         setter.setOptionValue("skip-all-system-status-check", "true");
         setter.setOptionValue("reboot-per-module", "true");
         EasyMock.expect(mMockDevice.getProperty("ro.build.type")).andReturn("user");
+        mMockListener.testModuleStarted(EasyMock.anyObject());
         mMockListener.testRunStarted(TEST_CONFIG_NAME, 1);
         EasyMock.expectLastCall().times(1);
         mMockListener.testRunFailed("Module test only ran 0 out of 1 expected tests.");
         mMockListener.testRunEnded(EasyMock.anyLong(), EasyMock.anyObject());
         EasyMock.expectLastCall().times(1);
+        mMockListener.testModuleEnded();
         replayMocks();
         mTestSuite.run(mMockListener);
         verifyMocks();
diff --git a/tests/src/com/android/tradefed/testtype/suite/TfSuiteRunnerTest.java b/tests/src/com/android/tradefed/testtype/suite/TfSuiteRunnerTest.java
index b67d878..240e508 100644
--- a/tests/src/com/android/tradefed/testtype/suite/TfSuiteRunnerTest.java
+++ b/tests/src/com/android/tradefed/testtype/suite/TfSuiteRunnerTest.java
@@ -182,8 +182,10 @@
         mRunner.setSystemStatusChecker(new ArrayList<>());
         mRunner.setInvocationContext(new InvocationContext());
         // runs the expanded suite
+        listener.testModuleStarted(EasyMock.anyObject());
         listener.testRunStarted("suite/stub1", 0);
         listener.testRunEnded(EasyMock.anyLong(), EasyMock.anyObject());
+        listener.testModuleEnded();
         EasyMock.replay(listener);
         mRunner.run(listener);
         EasyMock.verify(listener);