blob: 47e1ac4b94451e759b394fc29ddb4098889bc063 [file] [log] [blame]
/*
* 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;
}
}
}