DoU profile: running tests at timestamps.

Test: make LongevityPlatformLibSamples; install; instrument with "-e
profile sample_profile" and note the start time of the tests or check
LongevitySuite entries in logcat.
Bug: 117168431

Change-Id: I3d458d20e299562f753fb680c1e94905ec3484e5
diff --git a/libraries/composer/host/res/sample_profile.textpb b/libraries/composer/host/res/sample_profile.textpb
index a80d08f..7e2496b 100644
--- a/libraries/composer/host/res/sample_profile.textpb
+++ b/libraries/composer/host/res/sample_profile.textpb
@@ -3,17 +3,17 @@
 }
 scenarios [{
     at: "00:00:10"
-    journey: "android.platform.test.scenario.calendar.FlingDayPage"
+    journey: "android.longevity.platform.samples.SimpleSuite$PassingTest"
 }, {
     at: "00:01:00"
-    journey: "android.platform.test.scenario.calendar.FlingWeekPage"
+    journey: "android.longevity.platform.samples.SimpleSuite$FailingTest"
 }, {
     at: "00:02:00"
-    journey: "android.platform.test.scenario.gmail.OpenApp"
+    journey: "android.longevity.platform.samples.SimpleSuite$PassingTest"
 }, {
     at: "00:03:00"
-    journey: "android.platform.test.scenario.quicksettings.ToggleWifiOff"
+    journey: "android.longevity.platform.samples.SimpleSuite$PassingTest"
 }, {
     at: "00:04:00"
-    journey: "android.platform.test.scenario.quicksettings.ToggleWifiOn"
+    journey: "android.longevity.platform.samples.SimpleSuite$FailingTest"
 }]
diff --git a/libraries/composer/host/src/android/host/test/composer/Profile.java b/libraries/composer/host/src/android/host/test/composer/Profile.java
index 51ff7b9..51c7032 100644
--- a/libraries/composer/host/src/android/host/test/composer/Profile.java
+++ b/libraries/composer/host/src/android/host/test/composer/Profile.java
@@ -27,6 +27,10 @@
  * An extension of {@link android.host.test.composer.ProfileBase} for host-side testing.
  */
 public class Profile extends ProfileBase<Map<String, String>> {
+    public Profile(Map<String, String> args) {
+        super(args);
+    }
+
     @Override
     protected Configuration getConfigurationArgument(Map<String, String> args) {
         if (!args.containsKey(PROFILE_OPTION_NAME)) {
diff --git a/libraries/composer/host/src/android/host/test/composer/ProfileBase.java b/libraries/composer/host/src/android/host/test/composer/ProfileBase.java
index 2424b9a..628ecb4 100644
--- a/libraries/composer/host/src/android/host/test/composer/ProfileBase.java
+++ b/libraries/composer/host/src/android/host/test/composer/ProfileBase.java
@@ -22,16 +22,21 @@
 import org.junit.runner.Runner;
 
 import java.lang.IllegalArgumentException;
+import java.text.SimpleDateFormat;
+import java.text.ParseException;
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Comparator;
 import java.util.List;
+import java.util.Locale;
 import java.util.Map;
+import java.util.TimeZone;
 import java.util.function.Function;
 import java.util.stream.Collectors;
 
 /**
- * A {@link Compose} function base class for taking a profile and returning the tests in the specified sequence.
+ * A {@link Compose} function base class for taking a profile and returning the tests in the
+ * specified sequence.
  */
 public abstract class ProfileBase<T> implements Compose<T, Runner> {
     protected static final String PROFILE_OPTION_NAME = "profile";
@@ -39,8 +44,34 @@
 
     private static final String LOG_TAG = ProfileBase.class.getSimpleName();
 
-    // Store the configuration to be read by the test runner.
-    private Configuration mConfiguration;
+    // Parser for parsing "at" timestamps in profiles.
+    private static final SimpleDateFormat TIMESTAMP_FORMATTER = new SimpleDateFormat("HH:mm:ss");
+
+    // Keeps track of the current scenario to run.
+    private int mScenarioIndex = 0;
+    // A list of scenarios in the order that they will be run.
+    private List<Scenario> mOrderedScenariosList;
+    // Timestamp when the test run starts, defaults to time when the ProfileBase object is
+    // constructed. Can be overridden by {@link setTestRunStartTimeMillis}.
+    // TODO(b/118843085): Clarify whether timestamps are relative to run start time or device clock.
+    private long mRunStartTimeMillis = System.currentTimeMillis();
+
+    public ProfileBase(T args) {
+        super();
+        // Set the timestamp parser to UTC to get test timstamps as "time elapsed since zero".
+        TIMESTAMP_FORMATTER.setTimeZone(TimeZone.getTimeZone("UTC"));
+
+        // Load configuration from arguments and stored the list of scenarios sorted according to
+        // their timestamps.
+        Configuration config = getConfigurationArgument(args);
+        if (config == null) {
+            return;
+        }
+        mOrderedScenariosList = new ArrayList<Scenario>(config.getScenariosList());
+        if (config.hasScheduled()) {
+            Collections.sort(mOrderedScenariosList, new ScenarioTimestampComparator());
+        }
+    }
 
     // Comparator for sorting timstamped CUJs.
     private static class ScenarioTimestampComparator implements Comparator<Scenario> {
@@ -55,19 +86,11 @@
 
     @Override
     public List<Runner> apply(T args, List<Runner> input) {
-        mConfiguration = getConfigurationArgument(args);
-        if (mConfiguration == null) {
+        Configuration config = getConfigurationArgument(args);
+        if (config == null) {
             return input;
         }
-        return getTestSequenceFromConfiguration(mConfiguration, input);
-    }
-
-    public boolean isConfigurationLoaded() {
-        return (mConfiguration == null);
-    }
-
-    public Configuration getLoadedConfiguration() {
-        return mConfiguration;
+        return getTestSequenceFromConfiguration(config, input);
     }
 
     protected List<Runner> getTestSequenceFromConfiguration(
@@ -77,13 +100,9 @@
                         Collectors.toMap(
                                 r -> r.getDescription().getDisplayName(), Function.identity()));
         logInfo(LOG_TAG, String.format(
-                "Available scenarios: %s",
+                "Available journeys: %s",
                 nameToRunner.keySet().stream().collect(Collectors.joining(", "))));
-        List<Scenario> scenarios = new ArrayList<Scenario>(config.getScenariosList());
-        if (config.hasScheduled()) {
-            Collections.sort(scenarios, new ScenarioTimestampComparator());
-        }
-        List<Runner> result = scenarios
+        List<Runner> result = mOrderedScenariosList
                 .stream()
                 .map(Configuration.Scenario::getJourney)
                 .map(
@@ -93,7 +112,8 @@
                             } else {
                                 throw new IllegalArgumentException(
                                         String.format(
-                                                "Journey %s in profile does not exist.",
+                                                "Journey %s in profile not found. "
+                                                + "Check logcat to see available journeys.",
                                                 journeyName));
                             }
                         })
@@ -108,6 +128,55 @@
     }
 
     /**
+     * Enables classes using the profile composer to set the test run start time.
+     */
+    public void setTestRunStartTimeMillis(long timestamp) {
+        mRunStartTimeMillis = timestamp;
+    }
+
+    /**
+     * Called by suite runners to signal that a scenario/test has ended; increments the scenario
+     * index.
+     */
+    public void scenarioEnded() {
+        mScenarioIndex += 1;
+    }
+
+    /**
+     * Returns true if there is a next scheduled scenario to run. If no profile is supplied, returns
+     * false.
+     */
+    public boolean hasNextScheduledScenario() {
+        return (mOrderedScenariosList != null) && (mScenarioIndex < mOrderedScenariosList.size());
+    }
+
+    /**
+     * Returns time in milliseconds until the next scenario.
+     */
+    public long getMillisecondsUntilNextScenario() {
+        Scenario nextScenario = mOrderedScenariosList.get(mScenarioIndex);
+        if (nextScenario.hasAt()) {
+            try {
+                long startTimeMillis = TIMESTAMP_FORMATTER.parse(nextScenario.getAt()).getTime();
+                // Time in milliseconds from the start of the test run to the current point in time.
+                long currentTimeMillis = System.currentTimeMillis() - mRunStartTimeMillis;
+                // If the next test should not start yet, sleep until its start time. Otherwise,
+                // start it immediately.
+                // TODO(b/118495360): Deal with the IfLate situation.
+                if (startTimeMillis > currentTimeMillis) {
+                    return startTimeMillis - currentTimeMillis;
+                }
+            } catch (ParseException e) {
+                throw new IllegalArgumentException(
+                        String.format("Time %s from scenario %s could not be parsed",
+                                nextScenario.getAt(), nextScenario.getJourney()));
+            }
+        }
+        // For non-scheduled profiles (not a priority at this point), simply return 0.
+        return 0L;
+    }
+
+    /**
      * Parses the arguments, reads the configuration file and returns the Configuraiton object.
      *
      * If no profile option is found in the arguments, function should return null, in which case
diff --git a/libraries/composer/host/tests/src/android/host/test/composer/ProfileTest.java b/libraries/composer/host/tests/src/android/host/test/composer/ProfileTest.java
index 764218c..8f785b3 100644
--- a/libraries/composer/host/tests/src/android/host/test/composer/ProfileTest.java
+++ b/libraries/composer/host/tests/src/android/host/test/composer/ProfileTest.java
@@ -30,6 +30,10 @@
 @RunWith(JUnit4.class)
 public class ProfileTest extends ProfileTestBase<Map<String, String>> {
     protected class TestableProfile extends ProfileBase<Map<String, String>> {
+        public TestableProfile(Map<String, String> args) {
+            super(args);
+        }
+
         @Override
         protected Configuration getConfigurationArgument(Map<String, String> args) {
             return TEST_CONFIGS.get(args.get(PROFILE_OPTION_NAME));
@@ -37,8 +41,8 @@
     }
 
     @Override
-    protected ProfileBase<Map<String, String>> getProfile() {
-        return new TestableProfile();
+    protected ProfileBase<Map<String, String>> getProfile(Map<String, String> args) {
+        return new TestableProfile(args);
     }
 
     @Override
diff --git a/libraries/composer/host/tests/src/android/host/test/composer/ProfileTestBase.java b/libraries/composer/host/tests/src/android/host/test/composer/ProfileTestBase.java
index 10ed8cf..9b2d403 100644
--- a/libraries/composer/host/tests/src/android/host/test/composer/ProfileTestBase.java
+++ b/libraries/composer/host/tests/src/android/host/test/composer/ProfileTestBase.java
@@ -138,7 +138,8 @@
             "android.platform.test.scenario.calendar.FlingWeekPage",
             "android.platform.test.scenario.calendar.FlingDayPage");
 
-        List<Runner> output = getProfile().apply(getArguments(VALID_CONFIG_KEY), mMockInput);
+        List<Runner> output = getProfile(getArguments(VALID_CONFIG_KEY))
+                .apply(getArguments(VALID_CONFIG_KEY), mMockInput);
         List<String> outputDescriptions = output.stream().map(r ->
                 r.getDescription().getDisplayName()).collect(Collectors.toList());
         boolean respected = outputDescriptions.equals(expectedJourneyOrder);
@@ -153,12 +154,12 @@
     public void testProfileWithInvalidScenarioThrows() {
         // An exception about nonexistent user journey should be thrown.
         exceptionThrown.expect(IllegalArgumentException.class);
-        exceptionThrown.expectMessage("does not exist");
+        exceptionThrown.expectMessage("not found");
         exceptionThrown.expectMessage("invalid");
 
         // Attempt to apply a profile with invalid CUJ; the above exception should be thrown.
-        List<Runner> output = getProfile().apply(
-                getArguments(CONFIG_WITH_INVALID_JOURNEY_KEY), mMockInput);
+        List<Runner> output = getProfile(getArguments(CONFIG_WITH_INVALID_JOURNEY_KEY))
+                .apply(getArguments(CONFIG_WITH_INVALID_JOURNEY_KEY), mMockInput);
     }
 
     /**
@@ -173,11 +174,11 @@
 
         // Attempt to apply a scheduled profile with missing timestamps; the above exception should
         // be thrown.
-        List<Runner> output = getProfile().apply(
-                getArguments(CONFIG_WITH_MISSING_TIMESTAMPS_KEY), mMockInput);
+        List<Runner> output = getProfile(getArguments(CONFIG_WITH_MISSING_TIMESTAMPS_KEY))
+                .apply(getArguments(CONFIG_WITH_MISSING_TIMESTAMPS_KEY), mMockInput);
     }
 
-    protected abstract ProfileBase<T> getProfile();
+    protected abstract ProfileBase<T> getProfile(T args);
 
     protected abstract T getArguments(String configName);
 }
diff --git a/libraries/composer/platform/src/android/platform/test/composer/Profile.java b/libraries/composer/platform/src/android/platform/test/composer/Profile.java
index b612714..78764fa 100644
--- a/libraries/composer/platform/src/android/platform/test/composer/Profile.java
+++ b/libraries/composer/platform/src/android/platform/test/composer/Profile.java
@@ -40,6 +40,11 @@
      *
      * TODO(harrytczhang@): Write tests for this logic.
      */
+
+    public Profile(Bundle args) {
+        super(args);
+    }
+
     @Override
     protected Configuration getConfigurationArgument(Bundle args) {
         // profileValue is either the name of a profile bundled with an APK or a path to a
diff --git a/libraries/composer/platform/tests/src/android/platform/test/composer/ProfileTest.java b/libraries/composer/platform/tests/src/android/platform/test/composer/ProfileTest.java
index 83b1849..fcdbed6 100644
--- a/libraries/composer/platform/tests/src/android/platform/test/composer/ProfileTest.java
+++ b/libraries/composer/platform/tests/src/android/platform/test/composer/ProfileTest.java
@@ -30,6 +30,10 @@
 @RunWith(JUnit4.class)
 public class ProfileTest extends ProfileTestBase<Bundle> {
     protected class TestableProfile extends ProfileBase<Bundle> {
+        public TestableProfile(Bundle args) {
+            super(args);
+        }
+
         @Override
         protected Configuration getConfigurationArgument(Bundle args) {
             return TEST_CONFIGS.get(args.getString(PROFILE_OPTION_NAME));
@@ -37,8 +41,8 @@
     }
 
     @Override
-    protected ProfileBase<Bundle> getProfile() {
-        return new TestableProfile();
+    protected ProfileBase<Bundle> getProfile(Bundle args) {
+        return new TestableProfile(args);
     }
 
     @Override
diff --git a/libraries/longevity/src/android/longevity/core/LongevitySuite.java b/libraries/longevity/src/android/longevity/core/LongevitySuite.java
index 355d50f..12f1fa3 100644
--- a/libraries/longevity/src/android/longevity/core/LongevitySuite.java
+++ b/libraries/longevity/src/android/longevity/core/LongevitySuite.java
@@ -94,7 +94,7 @@
         }
         // Construct and store custom runners for the full suite.
         BiFunction<Map<String, String>, List<Runner>, List<Runner>> modifier =
-                new Iterate<Runner>().andThen(new Shuffle<Runner>()).andThen(new Profile());
+                new Iterate<Runner>().andThen(new Shuffle<Runner>()).andThen(new Profile(args));
         return modifier.apply(args, builder.runners(suite, annotation.value()));
     }
 
diff --git a/libraries/longevity/src/android/longevity/platform/LongevitySuite.java b/libraries/longevity/src/android/longevity/platform/LongevitySuite.java
index aa88961..6fb8817 100644
--- a/libraries/longevity/src/android/longevity/platform/LongevitySuite.java
+++ b/libraries/longevity/src/android/longevity/platform/LongevitySuite.java
@@ -24,6 +24,7 @@
 import android.longevity.platform.listener.TimeoutTerminator;
 import android.os.BatteryManager;
 import android.os.Bundle;
+import android.os.SystemClock;
 import android.platform.test.composer.Iterate;
 import android.platform.test.composer.Shuffle;
 import android.platform.test.composer.Profile;
@@ -52,6 +53,9 @@
     private Instrumentation mInstrumentation;
     private Context mContext;
 
+    // Platform Profile instance for scheduling tests.
+    private Profile mProfile;
+
     /**
      * Takes a {@link Bundle} and maps all String K/V pairs into a {@link Map<String, String>}.
      *
@@ -90,6 +94,7 @@
         super(klass, constructClassRunners(klass, builder, arguments), toMap(arguments));
         mInstrumentation = instrumentation;
         mContext = context;
+        mProfile = new Profile(arguments);
     }
 
     /**
@@ -108,7 +113,7 @@
         }
         // Construct and store custom runners for the full suite.
         BiFunction<Bundle, List<Runner>, List<Runner>> modifier =
-                new Iterate<Runner>().andThen(new Shuffle<Runner>()).andThen(new Profile());
+                new Iterate<Runner>().andThen(new Shuffle<Runner>()).andThen(new Profile(args));
         return modifier.apply(args, builder.runners(suite, annotation.value()));
     }
 
@@ -118,10 +123,28 @@
         if (hasBattery()) {
             notifier.addListener(new BatteryTerminator(notifier, mArguments, mContext));
         }
+        // Set the test run start time in the profile composer and sleep until the first scheduled
+        // test starts. When no profile is supplied, hasNextScheduledScenario() returns false and
+        // no sleep is performed.
+        if (mProfile.hasNextScheduledScenario()) {
+            mProfile.setTestRunStartTimeMillis(System.currentTimeMillis());
+            SystemClock.sleep(mProfile.getMillisecondsUntilNextScenario());
+        }
         // Register other listeners and continue with standard longevity run.
         super.run(notifier);
     }
 
+    @Override
+    protected void runChild(Runner runner, final RunNotifier notifier) {
+        super.runChild(runner, notifier);
+        mProfile.scenarioEnded();
+        // If there are remaining scenarios, Sleep until the next one starts.
+        // When no profile is supplied, allScenariosDone() returns true and no sleep is performed.
+        if (mProfile.hasNextScheduledScenario()) {
+            SystemClock.sleep(mProfile.getMillisecondsUntilNextScenario());
+        }
+    }
+
     /**
      * Returns the platform-specific {@link TimeoutTerminator} for Android devices.
      */