| /* |
| * Copyright (C) 2019 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package android.server.wm.intent; |
| |
| import static java.util.stream.Collectors.toList; |
| |
| import android.content.ComponentName; |
| import android.content.Intent; |
| import android.net.Uri; |
| import android.server.wm.WindowManagerState; |
| |
| import com.google.common.collect.Lists; |
| |
| import org.json.JSONArray; |
| import org.json.JSONException; |
| import org.json.JSONObject; |
| |
| import java.util.ArrayList; |
| import java.util.Arrays; |
| import java.util.Collections; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Objects; |
| import java.util.stream.Collectors; |
| import java.util.stream.Stream; |
| |
| /** |
| * The intent tests are generated by running a series of intents and then recording the end state |
| * of the system. This class contains all the models needed to store the intents that were used to |
| * create the test case and the end states so that they can be asserted on. |
| * |
| * All test cases are serialized to JSON and stored in a single file per testcase. |
| */ |
| public class Persistence { |
| |
| /** |
| * The highest level entity in the JSON file |
| */ |
| public static class TestCase { |
| private static final String SETUP_KEY = "setup"; |
| private static final String INITIAL_STATE_KEY = "initialState"; |
| private static final String END_STATE_KEY = "endState"; |
| |
| /** |
| * Contains the {@link android.content.Intent}-s that will be launched in this test case. |
| */ |
| private final Setup mSetup; |
| |
| /** |
| * The state of the system after the {@link Setup#mInitialIntents} have been launched. |
| */ |
| private final StateDump mInitialState; |
| |
| /** |
| * The state of the system after the {@link Setup#mAct} have been launched |
| */ |
| private final StateDump mEndState; |
| |
| /** |
| * The name of the testCase, usually the file name it is stored in. |
| * Not actually persisted to json, since it is only used for presentation purposes. |
| */ |
| private final String mName; |
| |
| public TestCase(Setup setup, StateDump initialState, |
| StateDump endState, String name) { |
| mSetup = setup; |
| mInitialState = initialState; |
| mEndState = endState; |
| mName = name; |
| } |
| |
| public JSONObject toJson() throws JSONException { |
| return new JSONObject() |
| .put(SETUP_KEY, mSetup.toJson()) |
| .put(INITIAL_STATE_KEY, mInitialState.toJson()) |
| .put(END_STATE_KEY, mEndState.toJson()); |
| } |
| |
| public static TestCase fromJson(JSONObject object, |
| Map<String, IntentFlag> table, String name) throws JSONException { |
| return new TestCase(Setup.fromJson(object.getJSONObject(SETUP_KEY), table), |
| StateDump.fromJson(object.getJSONObject(INITIAL_STATE_KEY)), |
| StateDump.fromJson(object.getJSONObject(END_STATE_KEY)), name); |
| } |
| |
| public Setup getSetup() { |
| return mSetup; |
| } |
| |
| public StateDump getInitialState() { |
| return mInitialState; |
| } |
| |
| public String getName() { |
| return mName; |
| } |
| |
| public StateDump getEndState() { |
| return mEndState; |
| } |
| } |
| |
| /** |
| * Setup consists of two stages. Firstly a list of intents to bring the system in the state we |
| * want to test something in. Secondly a list of intents to bring the system to the final state. |
| */ |
| public static class Setup { |
| private static final String INITIAL_INTENT_KEY = "initialIntents"; |
| private static final String ACT_KEY = "act"; |
| /** |
| * The intent(s) used to bring the system to the initial state. |
| */ |
| private final List<GenerationIntent> mInitialIntents; |
| |
| /** |
| * The intent(s) that we actually want to test. |
| */ |
| private final List<GenerationIntent> mAct; |
| |
| public Setup(List<GenerationIntent> initialIntents, List<GenerationIntent> act) { |
| mInitialIntents = initialIntents; |
| mAct = act; |
| } |
| |
| public List<ComponentName> componentsInCase() { |
| return Stream.concat(mInitialIntents.stream(), mAct.stream()) |
| .map(GenerationIntent::getActualIntent) |
| .map(Intent::getComponent) |
| .collect(Collectors.toList()); |
| } |
| |
| public JSONObject toJson() throws JSONException { |
| return new JSONObject() |
| .put(INITIAL_INTENT_KEY, intentsToJson(mInitialIntents)) |
| .put(ACT_KEY, intentsToJson(mAct)); |
| } |
| |
| public static Setup fromJson(JSONObject object, |
| Map<String, IntentFlag> table) throws JSONException { |
| List<GenerationIntent> initialState = intentsFromJson( |
| object.getJSONArray(INITIAL_INTENT_KEY), table); |
| List<GenerationIntent> act = intentsFromJson(object.getJSONArray(ACT_KEY), table); |
| |
| return new Setup(initialState, act); |
| } |
| |
| |
| public static JSONArray intentsToJson(List<GenerationIntent> intents) |
| throws JSONException { |
| |
| JSONArray intentArray = new JSONArray(); |
| for (GenerationIntent intent : intents) { |
| intentArray.put(intent.toJson()); |
| } |
| return intentArray; |
| } |
| |
| public static List<GenerationIntent> intentsFromJson(JSONArray intentArray, |
| Map<String, IntentFlag> table) throws JSONException { |
| List<GenerationIntent> intents = new ArrayList<>(); |
| |
| for (int i = 0; i < intentArray.length(); i++) { |
| JSONObject object = (JSONObject) intentArray.get(i); |
| GenerationIntent intent = GenerationIntent.fromJson(object, table); |
| |
| intents.add(intent); |
| } |
| |
| return intents; |
| } |
| |
| public List<GenerationIntent> getInitialIntents() { |
| return mInitialIntents; |
| } |
| |
| public List<GenerationIntent> getAct() { |
| return mAct; |
| } |
| } |
| |
| /** |
| * An representation of an {@link android.content.Intent} that can be (de)serialized to / from |
| * JSON. It abstracts whether the context it should be started from is implicitly or explicitly |
| * specified. |
| */ |
| interface GenerationIntent { |
| Intent getActualIntent(); |
| |
| JSONObject toJson() throws JSONException; |
| |
| int getLaunchFromIndex(int currentPosition); |
| |
| boolean startForResult(); |
| |
| static GenerationIntent fromJson(JSONObject object, Map<String, IntentFlag> table) |
| throws JSONException { |
| if (object.has(LaunchFromIntent.LAUNCH_FROM_KEY)) { |
| return LaunchFromIntent.fromJson(object, table); |
| } else { |
| return LaunchIntent.fromJson(object, table); |
| } |
| } |
| } |
| |
| /** |
| * Representation of {@link android.content.Intent} used by the {@link LaunchSequence} api. |
| * It be can used to normally start activities, to start activities for result and Intent Flags |
| * can be added using {@link LaunchIntent#withFlags(IntentFlag...)} |
| */ |
| static class LaunchIntent implements GenerationIntent { |
| private static final String FLAGS_KEY = "flags"; |
| private static final String PACKAGE_KEY = "package"; |
| private static final String CLASS_KEY = "class"; |
| private static final String DATA_KEY = "data"; |
| private static final String START_FOR_RESULT_KEY = "startForResult"; |
| |
| private final List<IntentFlag> mIntentFlags; |
| private final ComponentName mComponentName; |
| private final String mData; |
| private final boolean mStartForResult; |
| |
| public LaunchIntent(List<IntentFlag> intentFlags, ComponentName componentName, String data, |
| boolean startForResult) { |
| mIntentFlags = intentFlags; |
| mComponentName = componentName; |
| mData = data; |
| mStartForResult = startForResult; |
| } |
| |
| @Override |
| public Intent getActualIntent() { |
| final Intent intent = new Intent().setComponent(mComponentName).setFlags(buildFlag()); |
| if (mData != null && !mData.isEmpty()) { |
| intent.setData(Uri.parse(mData)); |
| } |
| return intent; |
| } |
| |
| @Override |
| public int getLaunchFromIndex(int currentPosition) { |
| return currentPosition - 1; |
| } |
| |
| @Override |
| public boolean startForResult() { |
| return mStartForResult; |
| } |
| |
| public int buildFlag() { |
| int flag = 0; |
| for (IntentFlag intentFlag : mIntentFlags) { |
| flag |= intentFlag.flag; |
| } |
| |
| return flag; |
| } |
| |
| public String humanReadableFlags() { |
| return mIntentFlags.stream().map(IntentFlag::toString).collect( |
| Collectors.joining(" | ")); |
| } |
| |
| public static LaunchIntent fromJson(JSONObject fakeIntent, Map<String, IntentFlag> table) |
| throws JSONException { |
| List<IntentFlag> flags = IntentFlag.parse(table, fakeIntent.getString(FLAGS_KEY)); |
| |
| boolean startForResult = fakeIntent.optBoolean(START_FOR_RESULT_KEY, false); |
| String uri = fakeIntent.optString(DATA_KEY); |
| return new LaunchIntent(flags, |
| new ComponentName( |
| fakeIntent.getString(PACKAGE_KEY), |
| fakeIntent.getString(CLASS_KEY)), |
| uri, |
| startForResult); |
| } |
| |
| @Override |
| public JSONObject toJson() throws JSONException { |
| return new JSONObject().put(FLAGS_KEY, this.humanReadableFlags()) |
| .put(CLASS_KEY, this.mComponentName.getClassName()) |
| .put(PACKAGE_KEY, this.mComponentName.getPackageName()) |
| .put(START_FOR_RESULT_KEY, mStartForResult); |
| } |
| |
| public LaunchIntent withFlags(IntentFlag... flags) { |
| List<IntentFlag> intentFlags = Lists.newArrayList(mIntentFlags); |
| Collections.addAll(intentFlags, flags); |
| return new LaunchIntent(intentFlags, mComponentName, mData, mStartForResult); |
| } |
| |
| public List<IntentFlag> getIntentFlags() { |
| return mIntentFlags; |
| } |
| |
| public ComponentName getComponentName() { |
| return mComponentName; |
| } |
| } |
| |
| /** |
| * Representation of {@link android.content.Intent} used by the {@link LaunchSequence} api. |
| * It can used to normally start activities, to start activities for result and Intent Flags |
| * can |
| * be added using {@link LaunchIntent#withFlags(IntentFlag...)} just like {@link LaunchIntent} |
| * |
| * However {@link LaunchFromIntent} also supports launching from a activity earlier in the |
| * launch sequence. This can be done using {@link LaunchSequence#act} and related methods. |
| */ |
| static class LaunchFromIntent implements GenerationIntent { |
| static final String LAUNCH_FROM_KEY = "launchFrom"; |
| |
| /** |
| * The underlying {@link LaunchIntent} that we are wrapping with the launch point behaviour. |
| */ |
| private final LaunchIntent mLaunchIntent; |
| |
| /** |
| * The index in the activityLog maintained by {@link LaunchRunner}, used to retrieve the |
| * activity from the log to start this {@link LaunchIntent} from. |
| */ |
| private final int mLaunchFrom; |
| |
| LaunchFromIntent(LaunchIntent fakeIntent, int launchFrom) { |
| mLaunchIntent = fakeIntent; |
| mLaunchFrom = launchFrom; |
| } |
| |
| |
| @Override |
| public Intent getActualIntent() { |
| return mLaunchIntent.getActualIntent(); |
| } |
| |
| @Override |
| public int getLaunchFromIndex(int currentPosition) { |
| return mLaunchFrom; |
| } |
| |
| @Override |
| public boolean startForResult() { |
| return mLaunchIntent.mStartForResult; |
| } |
| |
| @Override |
| public JSONObject toJson() throws JSONException { |
| return mLaunchIntent.toJson() |
| .put(LAUNCH_FROM_KEY, mLaunchFrom); |
| } |
| |
| public static LaunchFromIntent fromJson(JSONObject object, Map<String, IntentFlag> table) |
| throws JSONException { |
| LaunchIntent fakeIntent = LaunchIntent.fromJson(object, table); |
| int launchFrom = object.optInt(LAUNCH_FROM_KEY, -1); |
| |
| return new LaunchFromIntent(fakeIntent, launchFrom); |
| } |
| |
| static List<GenerationIntent> prepareSerialisation(List<LaunchFromIntent> intents) { |
| return prepareSerialisation(intents, 0); |
| } |
| |
| // In serialized form we only want to store the launch from index if it deviates from the |
| // default, the default being the previous activity. |
| static List<GenerationIntent> prepareSerialisation(List<LaunchFromIntent> intents, |
| int base) { |
| List<GenerationIntent> serializeIntents = Lists.newArrayList(); |
| for (int i = 0; i < intents.size(); i++) { |
| LaunchFromIntent launchFromIntent = intents.get(i); |
| serializeIntents.add(launchFromIntent.forget(base + i)); |
| } |
| |
| return serializeIntents; |
| } |
| |
| public GenerationIntent forget(int currentIndex) { |
| if (mLaunchFrom == currentIndex - 1) { |
| return this.mLaunchIntent; |
| } else { |
| return this; |
| } |
| } |
| |
| public int getLaunchFrom() { |
| return mLaunchFrom; |
| } |
| } |
| |
| /** |
| * An intent flag that also stores the name of the flag. |
| * It is used to be able to put the flags in human readable form in the JSON file. |
| */ |
| static class IntentFlag { |
| /** |
| * The underlying flag, should be a value from Intent.FLAG_ACTIVITY_*. |
| */ |
| public final int flag; |
| /** |
| * The name of the flag. |
| */ |
| public final String name; |
| |
| public IntentFlag(int flag, String name) { |
| this.flag = flag; |
| this.name = name; |
| } |
| |
| public int getFlag() { |
| return flag; |
| } |
| |
| public String getName() { |
| return name; |
| } |
| |
| public int combine(IntentFlag other) { |
| return other.flag | flag; |
| } |
| |
| public static List<IntentFlag> parse(Map<String, IntentFlag> names, String flagsToParse) { |
| String[] split = flagsToParse.replaceAll("\\s", "").split("\\|"); |
| return Arrays.stream(split).map(names::get).collect(toList()); |
| } |
| |
| public String toString() { |
| return name; |
| } |
| } |
| |
| static IntentFlag flag(int flag, String name) { |
| return new IntentFlag(flag, name); |
| } |
| |
| public static class StateDump { |
| private static final String TASKS_KEY = "tasks"; |
| |
| /** |
| * The Tasks in this stack ordered from most recent to least recent. |
| */ |
| private final List<TaskState> mTasks; |
| |
| public static StateDump fromTasks(List<WindowManagerState.Task> activityTasks, |
| List<WindowManagerState.Task> baseStacks) { |
| List<TaskState> tasks = new ArrayList<>(); |
| for (WindowManagerState.Task task : trimTasks(activityTasks, baseStacks)) { |
| tasks.add(new TaskState(task)); |
| } |
| return new StateDump(tasks); |
| } |
| |
| private StateDump(List<TaskState> tasks) { |
| mTasks = tasks; |
| } |
| |
| JSONObject toJson() throws JSONException { |
| JSONArray tasks = new JSONArray(); |
| for (TaskState task : mTasks) { |
| tasks.put(task.toJson()); |
| } |
| |
| return new JSONObject().put(TASKS_KEY, tasks); |
| } |
| |
| static StateDump fromJson(JSONObject object) throws JSONException { |
| JSONArray jsonTasks = object.getJSONArray(TASKS_KEY); |
| List<TaskState> tasks = new ArrayList<>(); |
| |
| for (int i = 0; i < jsonTasks.length(); i++) { |
| tasks.add(TaskState.fromJson((JSONObject) jsonTasks.get(i))); |
| } |
| |
| return new StateDump(tasks); |
| } |
| |
| /** |
| * To make the state dump non device specific we remove every task that was present |
| * in the system before recording, by their ID. For example a task containing the launcher |
| * activity. |
| */ |
| public static List<WindowManagerState.Task> trimTasks( |
| List<WindowManagerState.Task> toTrim, |
| List<WindowManagerState.Task> trimFrom) { |
| |
| for (WindowManagerState.Task task : trimFrom) { |
| toTrim.removeIf(t -> t.getRootTaskId() == task.getRootTaskId()); |
| } |
| |
| return toTrim; |
| } |
| |
| @Override |
| public boolean equals(Object o) { |
| if (this == o) return true; |
| if (o == null || getClass() != o.getClass()) return false; |
| StateDump stateDump = (StateDump) o; |
| return Objects.equals(mTasks, stateDump.mTasks); |
| } |
| |
| @Override |
| public int hashCode() { |
| return Objects.hash(mTasks); |
| } |
| } |
| |
| public static class TaskState { |
| |
| private static final String STATE_RESUMED = "RESUMED"; |
| private static final String ACTIVITIES_KEY = "activities"; |
| |
| /** |
| * The component name of the resumedActivity in this state, empty string if there is none. |
| */ |
| private final String mResumedActivity; |
| |
| /** |
| * The activities in this task ordered from most recent to least recent. |
| */ |
| private final List<ActivityState> mActivities = new ArrayList<>(); |
| |
| private TaskState(JSONArray jsonActivities) throws JSONException { |
| String resumedActivity = ""; |
| for (int i = 0; i < jsonActivities.length(); i++) { |
| final ActivityState activity = |
| ActivityState.fromJson((JSONObject) jsonActivities.get(i)); |
| // The json file shouldn't define multiple resumed activities, but it is fine that |
| // the test will fail when comparing to the real state. |
| if (STATE_RESUMED.equals(activity.getState())) { |
| resumedActivity = activity.getName(); |
| } |
| mActivities.add(activity); |
| } |
| |
| mResumedActivity = resumedActivity; |
| } |
| |
| public TaskState(WindowManagerState.Task state) { |
| final String resumedActivity = state.getResumedActivity(); |
| mResumedActivity = resumedActivity != null ? resumedActivity : ""; |
| for (WindowManagerState.Activity activity : state.getActivities()) { |
| this.mActivities.add(new ActivityState(activity)); |
| } |
| } |
| |
| JSONObject toJson() throws JSONException { |
| JSONArray jsonActivities = new JSONArray(); |
| |
| for (ActivityState activity : mActivities) { |
| jsonActivities.put(activity.toJson()); |
| } |
| |
| return new JSONObject() |
| .put(ACTIVITIES_KEY, jsonActivities); |
| } |
| |
| static TaskState fromJson(JSONObject object) throws JSONException { |
| return new TaskState(object.getJSONArray(ACTIVITIES_KEY)); |
| } |
| |
| public List<ActivityState> getActivities() { |
| return mActivities; |
| } |
| |
| @Override |
| public boolean equals(Object o) { |
| if (this == o) return true; |
| if (o == null || getClass() != o.getClass()) return false; |
| TaskState task = (TaskState) o; |
| return Objects.equals(mResumedActivity, task.mResumedActivity) |
| && Objects.equals(mActivities, task.mActivities); |
| } |
| |
| @Override |
| public int hashCode() { |
| return Objects.hash(mResumedActivity, mActivities); |
| } |
| } |
| |
| public static class ActivityState { |
| private static final String NAME_KEY = "name"; |
| private static final String STATE_KEY = "state"; |
| /** |
| * The componentName of this activity. |
| */ |
| private final String mComponentName; |
| |
| /** |
| * The lifecycle state this activity is in. |
| */ |
| private final String mLifeCycleState; |
| |
| public ActivityState(String name, String state) { |
| mComponentName = name; |
| mLifeCycleState = state; |
| } |
| |
| public ActivityState(WindowManagerState.Activity activity) { |
| mComponentName = activity.getName(); |
| mLifeCycleState = activity.getState(); |
| } |
| |
| |
| JSONObject toJson() throws JSONException { |
| return new JSONObject().put(NAME_KEY, mComponentName).put(STATE_KEY, mLifeCycleState); |
| } |
| |
| static ActivityState fromJson(JSONObject object) throws JSONException { |
| return new ActivityState(object.getString(NAME_KEY), object.getString(STATE_KEY)); |
| } |
| |
| @Override |
| public boolean equals(Object o) { |
| if (this == o) return true; |
| if (o == null || getClass() != o.getClass()) return false; |
| ActivityState activity = (ActivityState) o; |
| return Objects.equals(mComponentName, activity.mComponentName) && |
| Objects.equals(mLifeCycleState, activity.mLifeCycleState); |
| } |
| |
| @Override |
| public int hashCode() { |
| return Objects.hash(mComponentName, mLifeCycleState); |
| } |
| |
| public String getName() { |
| return mComponentName; |
| } |
| |
| public String getState() { |
| return mLifeCycleState; |
| } |
| } |
| } |