| /* |
| * Copyright (C) 2018 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; |
| |
| import android.app.Activity; |
| import android.app.Application; |
| import android.content.BroadcastReceiver; |
| import android.content.ComponentName; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.content.IntentFilter; |
| import android.content.res.Configuration; |
| import android.content.res.Resources; |
| import android.graphics.Point; |
| import android.os.Build; |
| import android.os.Bundle; |
| import android.os.Handler; |
| import android.os.HandlerThread; |
| import android.os.Looper; |
| import android.os.Parcel; |
| import android.os.Parcelable; |
| import android.os.Process; |
| import android.os.SystemClock; |
| import android.server.wm.TestJournalProvider.TestJournalClient; |
| import android.util.ArrayMap; |
| import android.util.DisplayMetrics; |
| import android.util.Log; |
| import android.view.Display; |
| import android.view.View; |
| |
| import java.util.ArrayList; |
| import java.util.Iterator; |
| import java.util.concurrent.TimeoutException; |
| import java.util.function.Consumer; |
| |
| /** |
| * A mechanism for communication between the started activity and its caller in different package or |
| * process. Generally, a test case is the client, and the testing activity is the host. The client |
| * can control whether to send an async or sync command with response data. |
| * <p>Sample:</p> |
| * <pre> |
| * try (ActivitySessionClient client = new ActivitySessionClient(context)) { |
| * final ActivitySession session = client.startActivity( |
| * new Intent(context, TestActivity.class)); |
| * final Bundle response = session.requestOrientation( |
| * ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); |
| * Log.i("Test", "Config: " + CommandSession.getConfigInfo(response)); |
| * Log.i("Test", "Callbacks: " + CommandSession.getCallbackHistory(response)); |
| * |
| * session.startActivity(session.getOriginalLaunchIntent()); |
| * Log.i("Test", "New intent callbacks: " + session.takeCallbackHistory()); |
| * } |
| * </pre> |
| * <p>To perform custom command, use sendCommand* in {@link ActivitySession} to send the request, |
| * and the receiving side (activity) can extend {@link BasicTestActivity} or |
| * {@link CommandSessionActivity} with overriding handleCommand to do the corresponding action.</p> |
| */ |
| public final class CommandSession { |
| private static final boolean DEBUG = "eng".equals(Build.TYPE); |
| private static final String TAG = "CommandSession"; |
| |
| private static final String EXTRA_PREFIX = "s_"; |
| |
| static final String KEY_FORWARD = EXTRA_PREFIX + "key_forward"; |
| |
| private static final String KEY_CALLBACK_HISTORY = EXTRA_PREFIX + "key_callback_history"; |
| private static final String KEY_CLIENT_ID = EXTRA_PREFIX + "key_client_id"; |
| private static final String KEY_COMMAND = EXTRA_PREFIX + "key_command"; |
| private static final String KEY_CONFIG_INFO = EXTRA_PREFIX + "key_config_info"; |
| private static final String KEY_APP_CONFIG_INFO = EXTRA_PREFIX + "key_app_config_info"; |
| private static final String KEY_HOST_ID = EXTRA_PREFIX + "key_host_id"; |
| private static final String KEY_ORIENTATION = EXTRA_PREFIX + "key_orientation"; |
| private static final String KEY_REQUEST_TOKEN = EXTRA_PREFIX + "key_request_id"; |
| private static final String KEY_UID_HAS_ACCESS_ON_DISPLAY = |
| EXTRA_PREFIX + "uid_has_access_on_display"; |
| |
| private static final String COMMAND_FINISH = EXTRA_PREFIX + "command_finish"; |
| private static final String COMMAND_GET_CONFIG = EXTRA_PREFIX + "command_get_config"; |
| private static final String COMMAND_GET_APP_CONFIG = EXTRA_PREFIX + "command_get_app_config"; |
| private static final String COMMAND_ORIENTATION = EXTRA_PREFIX + "command_orientation"; |
| private static final String COMMAND_TAKE_CALLBACK_HISTORY = EXTRA_PREFIX |
| + "command_take_callback_history"; |
| private static final String COMMAND_WAIT_IDLE = EXTRA_PREFIX + "command_wait_idle"; |
| private static final String COMMAND_GET_NAME = EXTRA_PREFIX + "command_get_name"; |
| private static final String COMMAND_DISPLAY_ACCESS_CHECK = |
| EXTRA_PREFIX + "display_access_check"; |
| |
| private static final long INVALID_REQUEST_TOKEN = -1; |
| |
| private CommandSession() { |
| } |
| |
| /** Get {@link ConfigInfo} from bundle. */ |
| public static ConfigInfo getConfigInfo(Bundle data) { |
| return data.getParcelable(KEY_CONFIG_INFO); |
| } |
| |
| /** Get application {@link ConfigInfo} from bundle. */ |
| public static ConfigInfo getAppConfigInfo(Bundle data) { |
| return data.getParcelable(KEY_APP_CONFIG_INFO); |
| } |
| |
| /** Get list of {@link ActivityCallback} from bundle. */ |
| public static ArrayList<ActivityCallback> getCallbackHistory(Bundle data) { |
| return data.getParcelableArrayList(KEY_CALLBACK_HISTORY); |
| } |
| |
| /** Return non-null if the session info should forward to launch target. */ |
| public static LaunchInjector handleForward(Bundle data) { |
| if (data == null || !data.getBoolean(KEY_FORWARD)) { |
| return null; |
| } |
| |
| // Only keep the necessary data which relates to session. |
| final Bundle sessionInfo = new Bundle(data); |
| sessionInfo.remove(KEY_FORWARD); |
| for (String key : sessionInfo.keySet()) { |
| if (key != null && !key.startsWith(EXTRA_PREFIX)) { |
| sessionInfo.remove(key); |
| } |
| } |
| |
| return new LaunchInjector() { |
| @Override |
| public void setupIntent(Intent intent) { |
| intent.putExtras(sessionInfo); |
| } |
| |
| @Override |
| public void setupShellCommand(StringBuilder shellCommand) { |
| // Currently there is no use case from shell. |
| throw new UnsupportedOperationException(); |
| } |
| }; |
| } |
| |
| private static String generateId(String prefix, Object obj) { |
| return prefix + "_" + Integer.toHexString(System.identityHashCode(obj)); |
| } |
| |
| private static String commandIntentToString(Intent intent) { |
| return intent.getStringExtra(KEY_COMMAND) |
| + "@" + intent.getLongExtra(KEY_REQUEST_TOKEN, INVALID_REQUEST_TOKEN); |
| } |
| |
| /** Get an unique token to match the request and reply. */ |
| private static long generateRequestToken() { |
| return SystemClock.elapsedRealtimeNanos(); |
| } |
| |
| /** |
| * As a controller associated with the testing activity. It can only process one sync command |
| * (require response) at a time. |
| */ |
| public static class ActivitySession { |
| private final ActivitySessionClient mClient; |
| private final String mHostId; |
| private final Response mPendingResponse = new Response(); |
| // Only set when requiring response. |
| private long mPendingRequestToken = INVALID_REQUEST_TOKEN; |
| private String mPendingCommand; |
| private boolean mFinished; |
| private Intent mOriginalLaunchIntent; |
| |
| ActivitySession(ActivitySessionClient client, boolean requireReply) { |
| mClient = client; |
| mHostId = generateId("activity", this); |
| if (requireReply) { |
| mPendingRequestToken = generateRequestToken(); |
| mPendingCommand = COMMAND_WAIT_IDLE; |
| } |
| } |
| |
| /** Start the activity again. The intent must have the same filter as original one. */ |
| public void startActivity(Intent intent) { |
| if (!intent.filterEquals(mOriginalLaunchIntent)) { |
| throw new IllegalArgumentException("The intent filter is different " + intent); |
| } |
| mClient.mContext.startActivity(intent); |
| mFinished = false; |
| } |
| |
| /** |
| * Request the activity to set the given orientation. The returned bundle contains the |
| * changed config info and activity lifecycles during the change. |
| * |
| * @param orientation An orientation constant as used in |
| * {@link android.content.pm.ActivityInfo#screenOrientation}. |
| */ |
| public Bundle requestOrientation(int orientation) { |
| final Bundle data = new Bundle(); |
| data.putInt(KEY_ORIENTATION, orientation); |
| return sendCommandAndWaitReply(COMMAND_ORIENTATION, data); |
| } |
| |
| /** Get {@link ConfigInfo} of the associated activity. */ |
| public ConfigInfo getConfigInfo() { |
| return CommandSession.getConfigInfo(sendCommandAndWaitReply(COMMAND_GET_CONFIG)); |
| } |
| |
| /** Get {@link ConfigInfo} of the Application of the associated activity. */ |
| public ConfigInfo getAppConfigInfo() { |
| return CommandSession.getAppConfigInfo(sendCommandAndWaitReply(COMMAND_GET_APP_CONFIG)); |
| } |
| |
| /** |
| * Get executed callbacks of the activity since the last command. The current callback |
| * history will also be cleared. |
| */ |
| public ArrayList<ActivityCallback> takeCallbackHistory() { |
| return getCallbackHistory(sendCommandAndWaitReply(COMMAND_TAKE_CALLBACK_HISTORY, |
| null /* data */)); |
| } |
| |
| /** Get the intent that launches the activity. Null if launch from shell command. */ |
| public Intent getOriginalLaunchIntent() { |
| return mOriginalLaunchIntent; |
| } |
| |
| /** Get a name to represent this session by the original launch intent if possible. */ |
| public ComponentName getName() { |
| if (mOriginalLaunchIntent != null) { |
| final ComponentName componentName = mOriginalLaunchIntent.getComponent(); |
| if (componentName != null) { |
| return componentName; |
| } |
| } |
| return sendCommandAndWaitReply(COMMAND_GET_NAME, null /* data */) |
| .getParcelable(COMMAND_GET_NAME); |
| } |
| |
| public boolean isUidAccesibleOnDisplay() { |
| return sendCommandAndWaitReply(COMMAND_DISPLAY_ACCESS_CHECK, null).getBoolean( |
| KEY_UID_HAS_ACCESS_ON_DISPLAY); |
| } |
| |
| /** Send command to the associated activity. */ |
| public void sendCommand(String command) { |
| sendCommand(command, null /* data */); |
| } |
| |
| /** Send command with extra parameters to the associated activity. */ |
| public void sendCommand(String command, Bundle data) { |
| if (mFinished) { |
| throw new IllegalStateException("The session is finished"); |
| } |
| |
| final Intent intent = new Intent(mHostId); |
| if (data != null) { |
| intent.putExtras(data); |
| } |
| intent.putExtra(KEY_COMMAND, command); |
| mClient.mContext.sendBroadcast(intent); |
| if (DEBUG) { |
| Log.i(TAG, mClient.mClientId + " sends " + commandIntentToString(intent) |
| + " to " + mHostId); |
| } |
| } |
| |
| public Bundle sendCommandAndWaitReply(String command) { |
| return sendCommandAndWaitReply(command, null /* data */); |
| } |
| |
| /** Returns the reply data by the given command. */ |
| public Bundle sendCommandAndWaitReply(String command, Bundle data) { |
| if (data == null) { |
| data = new Bundle(); |
| } |
| |
| if (mPendingRequestToken != INVALID_REQUEST_TOKEN) { |
| throw new IllegalStateException("The previous pending request " |
| + mPendingCommand + " has not replied"); |
| } |
| mPendingRequestToken = generateRequestToken(); |
| mPendingCommand = command; |
| data.putLong(KEY_REQUEST_TOKEN, mPendingRequestToken); |
| |
| sendCommand(command, data); |
| return waitReply(); |
| } |
| |
| private Bundle waitReply() { |
| if (mPendingRequestToken == INVALID_REQUEST_TOKEN) { |
| throw new IllegalStateException("No pending request to wait"); |
| } |
| |
| if (DEBUG) Log.i(TAG, "Waiting for request " + mPendingRequestToken); |
| try { |
| return mPendingResponse.takeResult(); |
| } catch (TimeoutException e) { |
| throw new RuntimeException("Timeout on command " |
| + mPendingCommand + " with token " + mPendingRequestToken, e); |
| } finally { |
| mPendingRequestToken = INVALID_REQUEST_TOKEN; |
| mPendingCommand = null; |
| } |
| } |
| |
| // This method should run on an independent thread. |
| void receiveReply(Bundle reply) { |
| final long incomingToken = reply.getLong(KEY_REQUEST_TOKEN); |
| if (incomingToken == mPendingRequestToken) { |
| mPendingResponse.setResult(reply); |
| } else { |
| throw new IllegalStateException("Mismatched token: incoming=" + incomingToken |
| + " pending=" + mPendingRequestToken); |
| } |
| } |
| |
| /** Finish the activity that associates with this session. */ |
| public void finish() { |
| if (!mFinished) { |
| sendCommand(COMMAND_FINISH); |
| mClient.mSessions.remove(mHostId); |
| mFinished = true; |
| } |
| } |
| |
| private static class Response { |
| static final int TIMEOUT_MILLIS = 5000; |
| private volatile boolean mHasResult; |
| private Bundle mResult; |
| |
| synchronized void setResult(Bundle result) { |
| mHasResult = true; |
| mResult = result; |
| notifyAll(); |
| } |
| |
| synchronized Bundle takeResult() throws TimeoutException { |
| final long startTime = SystemClock.uptimeMillis(); |
| while (!mHasResult) { |
| try { |
| wait(TIMEOUT_MILLIS); |
| } catch (InterruptedException ignored) { |
| } |
| if (!mHasResult && (SystemClock.uptimeMillis() - startTime > TIMEOUT_MILLIS)) { |
| throw new TimeoutException("No response over " + TIMEOUT_MILLIS + "ms"); |
| } |
| } |
| |
| final Bundle result = mResult; |
| mHasResult = false; |
| mResult = null; |
| return result; |
| } |
| } |
| } |
| |
| /** For LaunchProxy to setup launch parameter that establishes session. */ |
| interface LaunchInjector { |
| void setupIntent(Intent intent); |
| void setupShellCommand(StringBuilder shellCommand); |
| } |
| |
| /** A proxy to launch activity by intent or shell command. */ |
| interface LaunchProxy { |
| void setLaunchInjector(LaunchInjector injector); |
| default Bundle getExtras() { return null; } |
| void execute(); |
| boolean shouldWaitForLaunched(); |
| } |
| |
| abstract static class DefaultLaunchProxy implements LaunchProxy { |
| LaunchInjector mLaunchInjector; |
| |
| @Override |
| public boolean shouldWaitForLaunched() { |
| return true; |
| } |
| |
| @Override |
| public void setLaunchInjector(LaunchInjector injector) { |
| mLaunchInjector = injector; |
| } |
| } |
| |
| /** Created by test case to control testing activity that implements the session protocol. */ |
| public static class ActivitySessionClient extends BroadcastReceiver implements AutoCloseable { |
| private final Context mContext; |
| private final String mClientId; |
| private final HandlerThread mThread; |
| private final ArrayMap<String, ActivitySession> mSessions = new ArrayMap<>(); |
| private boolean mClosed; |
| |
| public ActivitySessionClient(Context context) { |
| mContext = context; |
| mClientId = generateId("testcase", this); |
| mThread = new HandlerThread(mClientId); |
| mThread.start(); |
| context.registerReceiver(this, new IntentFilter(mClientId), |
| null /* broadcastPermission */, new Handler(mThread.getLooper()), |
| Context.RECEIVER_EXPORTED); |
| } |
| |
| /** Start the activity by the given intent and wait it becomes idle. */ |
| public ActivitySession startActivity(Intent intent) { |
| return startActivity(intent, null /* options */, true /* waitIdle */); |
| } |
| |
| /** |
| * Launch the activity and establish a new session. |
| * |
| * @param intent The description of the activity to start. |
| * @param options Additional options for how the Activity should be started. |
| * @param waitIdle Block in this method until the target activity is idle. |
| * @return The session to communicate with the started activity. |
| */ |
| public ActivitySession startActivity(Intent intent, Bundle options, boolean waitIdle) { |
| ensureNotClosed(); |
| final ActivitySession session = new ActivitySession(this, waitIdle); |
| mSessions.put(session.mHostId, session); |
| setupLaunchIntent(intent, waitIdle, session); |
| |
| if (!(mContext instanceof Activity)) { |
| intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); |
| } |
| mContext.startActivity(intent, options); |
| if (waitIdle) { |
| session.waitReply(); |
| } |
| return session; |
| } |
| |
| /** Launch activity via proxy that allows to inject session parameters. */ |
| public ActivitySession startActivity(LaunchProxy proxy) { |
| ensureNotClosed(); |
| final boolean waitIdle = proxy.shouldWaitForLaunched(); |
| final ActivitySession session = new ActivitySession(this, waitIdle); |
| mSessions.put(session.mHostId, session); |
| |
| proxy.setLaunchInjector(new LaunchInjector() { |
| @Override |
| public void setupIntent(Intent intent) { |
| final Bundle bundle = proxy.getExtras(); |
| if (bundle != null) { |
| intent.putExtras(bundle); |
| } |
| setupLaunchIntent(intent, waitIdle, session); |
| } |
| |
| @Override |
| public void setupShellCommand(StringBuilder commandBuilder) { |
| commandBuilder.append(" --es " + KEY_HOST_ID + " " + session.mHostId); |
| commandBuilder.append(" --es " + KEY_CLIENT_ID + " " + mClientId); |
| if (waitIdle) { |
| commandBuilder.append( |
| " --el " + KEY_REQUEST_TOKEN + " " + session.mPendingRequestToken); |
| commandBuilder.append(" --es " + KEY_COMMAND + " " + COMMAND_WAIT_IDLE); |
| } |
| } |
| }); |
| |
| proxy.execute(); |
| if (waitIdle) { |
| session.waitReply(); |
| } |
| return session; |
| } |
| |
| private void setupLaunchIntent(Intent intent, boolean waitIdle, ActivitySession session) { |
| intent.putExtra(KEY_HOST_ID, session.mHostId); |
| intent.putExtra(KEY_CLIENT_ID, mClientId); |
| if (waitIdle) { |
| intent.putExtra(KEY_REQUEST_TOKEN, session.mPendingRequestToken); |
| intent.putExtra(KEY_COMMAND, COMMAND_WAIT_IDLE); |
| } |
| session.mOriginalLaunchIntent = intent; |
| } |
| |
| public ActivitySession getLastStartedSession() { |
| if (mSessions.isEmpty()) { |
| throw new IllegalStateException("No started sessions"); |
| } |
| return mSessions.valueAt(mSessions.size() - 1); |
| } |
| |
| private void ensureNotClosed() { |
| if (mClosed) { |
| throw new IllegalStateException("This session client is closed."); |
| } |
| } |
| |
| @Override |
| public void onReceive(Context context, Intent intent) { |
| final ActivitySession session = mSessions.get(intent.getStringExtra(KEY_HOST_ID)); |
| if (DEBUG) Log.i(TAG, mClientId + " receives " + commandIntentToString(intent)); |
| if (session != null) { |
| session.receiveReply(intent.getExtras()); |
| } else { |
| Log.w(TAG, "No available session for " + commandIntentToString(intent)); |
| } |
| } |
| |
| /** Complete cleanup with finishing all associated activities. */ |
| @Override |
| public void close() { |
| close(true /* finishSession */); |
| } |
| |
| /** Cleanup except finish associated activities. */ |
| public void closeAndKeepSession() { |
| close(false /* finishSession */); |
| } |
| |
| /** |
| * Closes this client. Once a client is closed, all methods on it will throw an |
| * IllegalStateException and all responses from host are ignored. |
| * |
| * @param finishSession Whether to finish activities launched from this client. |
| */ |
| public void close(boolean finishSession) { |
| ensureNotClosed(); |
| mClosed = true; |
| if (finishSession) { |
| for (int i = mSessions.size() - 1; i >= 0; i--) { |
| mSessions.valueAt(i).finish(); |
| } |
| } |
| mContext.unregisterReceiver(this); |
| mThread.quit(); |
| } |
| } |
| |
| /** |
| * Interface definition for session host to process command from {@link ActivitySessionClient}. |
| */ |
| interface CommandReceiver { |
| /** Called when the session host is receiving command. */ |
| void receiveCommand(String command, Bundle data); |
| } |
| |
| /** The host receives command from the test client. */ |
| public static class ActivitySessionHost extends BroadcastReceiver { |
| private final Context mContext; |
| private final String mClientId; |
| private final String mHostId; |
| private CommandReceiver mCallback; |
| /** The intents received when the host activity is relaunching. */ |
| private ArrayList<Intent> mPendingIntents; |
| |
| ActivitySessionHost(Context context, String hostId, String clientId, |
| CommandReceiver callback) { |
| mContext = context; |
| mHostId = hostId; |
| mClientId = clientId; |
| mCallback = callback; |
| context.registerReceiver(this, new IntentFilter(hostId), Context.RECEIVER_EXPORTED); |
| } |
| |
| @Override |
| public void onReceive(Context context, Intent intent) { |
| if (DEBUG) { |
| Log.i(TAG, mHostId + "(" |
| + (mCallback != null |
| ? mCallback.getClass().getName() |
| : mContext.getClass().getName()) |
| + ") receives " + commandIntentToString(intent)); |
| } |
| if (mCallback == null) { |
| if (mPendingIntents == null) { |
| mPendingIntents = new ArrayList<>(); |
| } |
| mPendingIntents.add(intent); |
| return; |
| } |
| dispatchCommand(mCallback, intent); |
| } |
| |
| private static void dispatchCommand(CommandReceiver callback, Intent intent) { |
| callback.receiveCommand(intent.getStringExtra(KEY_COMMAND), intent.getExtras()); |
| } |
| |
| void reply(String command, Bundle data) { |
| final Intent intent = new Intent(mClientId); |
| intent.putExtras(data); |
| intent.putExtra(KEY_COMMAND, command); |
| intent.putExtra(KEY_HOST_ID, mHostId); |
| mContext.sendBroadcast(intent); |
| if (DEBUG) { |
| Log.i(TAG, mHostId + "(" + mContext.getClass().getSimpleName() |
| + ") replies " + commandIntentToString(intent) + " to " + mClientId); |
| } |
| } |
| |
| void setCallback(CommandReceiver callback) { |
| if (mPendingIntents != null && mCallback == null && callback != null) { |
| for (Intent intent : mPendingIntents) { |
| dispatchCommand(callback, intent); |
| } |
| mPendingIntents = null; |
| } |
| mCallback = callback; |
| } |
| |
| void destroy() { |
| mContext.unregisterReceiver(this); |
| } |
| } |
| |
| /** |
| * A map to store data by host id. The usage should be declared as static that is able to keep |
| * data after activity is relaunched. |
| */ |
| private static class StaticHostStorage<T> { |
| final ArrayMap<String, ArrayList<T>> mStorage = new ArrayMap<>(); |
| |
| void add(String hostId, T data) { |
| ArrayList<T> commands = mStorage.get(hostId); |
| if (commands == null) { |
| commands = new ArrayList<>(); |
| mStorage.put(hostId, commands); |
| } |
| commands.add(data); |
| } |
| |
| ArrayList<T> get(String hostId) { |
| return mStorage.get(hostId); |
| } |
| |
| void clear(String hostId) { |
| mStorage.remove(hostId); |
| } |
| } |
| |
| /** Store the commands which have not been handled. */ |
| private static class CommandStorage extends StaticHostStorage<Bundle> { |
| |
| /** Remove the oldest matched command and return its request token. */ |
| long consume(String hostId, String command) { |
| final ArrayList<Bundle> commands = mStorage.get(hostId); |
| if (commands != null) { |
| final Iterator<Bundle> iterator = commands.iterator(); |
| while (iterator.hasNext()) { |
| final Bundle data = iterator.next(); |
| if (command.equals(data.getString(KEY_COMMAND))) { |
| iterator.remove(); |
| return data.getLong(KEY_REQUEST_TOKEN); |
| } |
| } |
| if (commands.isEmpty()) { |
| clear(hostId); |
| } |
| } |
| return INVALID_REQUEST_TOKEN; |
| } |
| |
| boolean containsCommand(String receiverId, String command) { |
| final ArrayList<Bundle> dataList = mStorage.get(receiverId); |
| if (dataList != null) { |
| for (Bundle data : dataList) { |
| if (command.equals(data.getString(KEY_COMMAND))) { |
| return true; |
| } |
| } |
| } |
| return false; |
| } |
| } |
| |
| /** |
| * The base activity which supports the session protocol. If the caller does not use |
| * {@link ActivitySessionClient}, it behaves as a normal activity. |
| */ |
| public static class CommandSessionActivity extends Activity implements CommandReceiver { |
| /** Static command storage for across relaunch. */ |
| private static CommandStorage sCommandStorage; |
| private ActivitySessionHost mReceiver; |
| |
| /** The subclasses can disable the test journal client if its information is not used. */ |
| protected boolean mUseTestJournal = true; |
| protected TestJournalClient mTestJournalClient; |
| |
| @Override |
| protected void onCreate(Bundle savedInstanceState) { |
| super.onCreate(savedInstanceState); |
| if (mUseTestJournal) { |
| mTestJournalClient = TestJournalClient.create(this /* context */, |
| getComponentName()); |
| } |
| |
| final String hostId = getIntent().getStringExtra(KEY_HOST_ID); |
| final String clientId = getIntent().getStringExtra(KEY_CLIENT_ID); |
| if (hostId != null && clientId != null) { |
| if (sCommandStorage == null) { |
| sCommandStorage = new CommandStorage(); |
| } |
| final Object receiver = getLastNonConfigurationInstance(); |
| if (receiver instanceof ActivitySessionHost) { |
| mReceiver = (ActivitySessionHost) receiver; |
| mReceiver.setCallback(this); |
| } else { |
| mReceiver = new ActivitySessionHost(getApplicationContext(), hostId, clientId, |
| this /* callback */); |
| } |
| } |
| } |
| |
| @Override |
| protected void onDestroy() { |
| super.onDestroy(); |
| if (isChangingConfigurations()) { |
| // Detach the callback if the activity is relaunching. The callback will be |
| // associated again in onCreate. |
| if (mReceiver != null) { |
| mReceiver.setCallback(null); |
| } |
| } else if (mReceiver != null) { |
| // Clean up for real removal. |
| sCommandStorage.clear(getHostId()); |
| mReceiver.destroy(); |
| mReceiver = null; |
| } |
| if (mTestJournalClient != null) { |
| mTestJournalClient.close(); |
| } |
| } |
| |
| @Override |
| public Object onRetainNonConfigurationInstance() { |
| return mReceiver; |
| } |
| |
| @Override |
| public final void receiveCommand(String command, Bundle data) { |
| if (mReceiver == null) { |
| throw new IllegalStateException("The receiver is not created"); |
| } |
| sCommandStorage.add(getHostId(), data); |
| handleCommand(command, data); |
| } |
| |
| /** Handle the incoming command from client. */ |
| protected void handleCommand(String command, Bundle data) { |
| } |
| |
| protected final void reply(String command) { |
| reply(command, null /* data */); |
| } |
| |
| /** Reply data to client for the command. */ |
| protected final void reply(String command, Bundle data) { |
| if (mReceiver == null) { |
| throw new IllegalStateException("The receiver is not created"); |
| } |
| final long requestToke = sCommandStorage.consume(getHostId(), command); |
| if (requestToke == INVALID_REQUEST_TOKEN) { |
| throw new IllegalStateException("There is no pending command " + command); |
| } |
| if (data == null) { |
| data = new Bundle(); |
| } |
| data.putLong(KEY_REQUEST_TOKEN, requestToke); |
| mReceiver.reply(command, data); |
| } |
| |
| protected boolean hasPendingCommand(String command) { |
| return mReceiver != null && sCommandStorage.containsCommand(getHostId(), command); |
| } |
| |
| /** Returns null means this activity does support the session protocol. */ |
| final String getHostId() { |
| return mReceiver != null ? mReceiver.mHostId : null; |
| } |
| } |
| |
| /** The default implementation that supports basic commands to interact with activity. */ |
| public static class BasicTestActivity extends CommandSessionActivity { |
| /** Static callback history for across relaunch. */ |
| private static final StaticHostStorage<ActivityCallback> sCallbackStorage = |
| new StaticHostStorage<>(); |
| |
| private final String mTag = getClass().getSimpleName(); |
| protected boolean mPrintCallbackLog; |
| |
| @Override |
| protected void onCreate(Bundle savedInstanceState) { |
| super.onCreate(savedInstanceState); |
| onCallback(ActivityCallback.ON_CREATE); |
| |
| if (getHostId() != null) { |
| final int orientation = getIntent().getIntExtra(KEY_ORIENTATION, Integer.MIN_VALUE); |
| if (orientation != Integer.MIN_VALUE) { |
| setRequestedOrientation(orientation); |
| } |
| if (COMMAND_WAIT_IDLE.equals(getIntent().getStringExtra(KEY_COMMAND))) { |
| receiveCommand(COMMAND_WAIT_IDLE, getIntent().getExtras()); |
| // No need to execute again if the activity is relaunched. |
| getIntent().removeExtra(KEY_COMMAND); |
| } |
| } |
| } |
| |
| @Override |
| public void handleCommand(String command, Bundle data) { |
| switch (command) { |
| case COMMAND_ORIENTATION: |
| clearCallbackHistory(); |
| setRequestedOrientation(data.getInt(KEY_ORIENTATION)); |
| getWindow().getDecorView().postDelayed(() -> { |
| if (reportConfigIfNeeded()) { |
| Log.w(getTag(), "Fallback report. The orientation may not change."); |
| } |
| }, ActivitySession.Response.TIMEOUT_MILLIS / 2); |
| break; |
| |
| case COMMAND_GET_CONFIG: |
| runWhenIdle(() -> { |
| final Bundle replyData = new Bundle(); |
| replyData.putParcelable(KEY_CONFIG_INFO, getConfigInfo()); |
| reply(COMMAND_GET_CONFIG, replyData); |
| }); |
| break; |
| |
| case COMMAND_GET_APP_CONFIG: |
| runWhenIdle(() -> { |
| final Bundle replyData = new Bundle(); |
| replyData.putParcelable(KEY_APP_CONFIG_INFO, getAppConfigInfo()); |
| reply(COMMAND_GET_APP_CONFIG, replyData); |
| }); |
| break; |
| |
| case COMMAND_FINISH: |
| if (!isFinishing()) { |
| finish(); |
| } |
| break; |
| |
| case COMMAND_TAKE_CALLBACK_HISTORY: |
| final Bundle replyData = new Bundle(); |
| replyData.putParcelableArrayList(KEY_CALLBACK_HISTORY, getCallbackHistory()); |
| reply(command, replyData); |
| clearCallbackHistory(); |
| break; |
| |
| case COMMAND_WAIT_IDLE: |
| runWhenIdle(() -> reply(command)); |
| break; |
| |
| case COMMAND_GET_NAME: { |
| final Bundle result = new Bundle(); |
| result.putParcelable(COMMAND_GET_NAME, getComponentName()); |
| reply(COMMAND_GET_NAME, result); |
| break; |
| } |
| |
| case COMMAND_DISPLAY_ACCESS_CHECK: |
| final Bundle result = new Bundle(); |
| final boolean displayHasAccess = getDisplay().hasAccess(Process.myUid()); |
| result.putBoolean(KEY_UID_HAS_ACCESS_ON_DISPLAY, displayHasAccess); |
| reply(command, result); |
| break; |
| |
| default: |
| break; |
| } |
| } |
| |
| protected final void clearCallbackHistory() { |
| sCallbackStorage.clear(getHostId()); |
| } |
| |
| protected final ArrayList<ActivityCallback> getCallbackHistory() { |
| return sCallbackStorage.get(getHostId()); |
| } |
| |
| protected void runWhenIdle(Runnable r) { |
| Looper.getMainLooper().getQueue().addIdleHandler(() -> { |
| r.run(); |
| return false; |
| }); |
| } |
| |
| protected boolean reportConfigIfNeeded() { |
| if (!hasPendingCommand(COMMAND_ORIENTATION)) { |
| return false; |
| } |
| runWhenIdle(() -> { |
| final Bundle replyData = new Bundle(); |
| replyData.putParcelable(KEY_CONFIG_INFO, getConfigInfo()); |
| replyData.putParcelableArrayList(KEY_CALLBACK_HISTORY, getCallbackHistory()); |
| reply(COMMAND_ORIENTATION, replyData); |
| clearCallbackHistory(); |
| }); |
| return true; |
| } |
| |
| @Override |
| protected void onStart() { |
| super.onStart(); |
| onCallback(ActivityCallback.ON_START); |
| } |
| |
| @Override |
| protected void onRestart() { |
| super.onRestart(); |
| onCallback(ActivityCallback.ON_RESTART); |
| } |
| |
| @Override |
| protected void onResume() { |
| super.onResume(); |
| onCallback(ActivityCallback.ON_RESUME); |
| reportConfigIfNeeded(); |
| } |
| |
| @Override |
| protected void onPause() { |
| super.onPause(); |
| onCallback(ActivityCallback.ON_PAUSE); |
| } |
| |
| @Override |
| protected void onStop() { |
| super.onStop(); |
| onCallback(ActivityCallback.ON_STOP); |
| } |
| |
| @Override |
| protected void onDestroy() { |
| super.onDestroy(); |
| onCallback(ActivityCallback.ON_DESTROY); |
| } |
| |
| @Override |
| protected void onActivityResult(int requestCode, int resultCode, Intent data) { |
| super.onActivityResult(requestCode, resultCode, data); |
| onCallback(ActivityCallback.ON_ACTIVITY_RESULT); |
| } |
| |
| @Override |
| protected void onUserLeaveHint() { |
| super.onUserLeaveHint(); |
| onCallback(ActivityCallback.ON_USER_LEAVE_HINT); |
| } |
| |
| @Override |
| protected void onNewIntent(Intent intent) { |
| super.onNewIntent(intent); |
| onCallback(ActivityCallback.ON_NEW_INTENT); |
| } |
| |
| @Override |
| public void onConfigurationChanged(Configuration newConfig) { |
| super.onConfigurationChanged(newConfig); |
| onCallback(ActivityCallback.ON_CONFIGURATION_CHANGED); |
| reportConfigIfNeeded(); |
| } |
| |
| @Override |
| public void onMultiWindowModeChanged(boolean isInMultiWindowMode, Configuration newConfig) { |
| super.onMultiWindowModeChanged(isInMultiWindowMode, newConfig); |
| onCallback(ActivityCallback.ON_MULTI_WINDOW_MODE_CHANGED); |
| } |
| |
| @Override |
| public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode, |
| Configuration newConfig) { |
| super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig); |
| onCallback(ActivityCallback.ON_PICTURE_IN_PICTURE_MODE_CHANGED); |
| } |
| |
| @Override |
| public void onMovedToDisplay(int displayId, Configuration config) { |
| super.onMovedToDisplay(displayId, config); |
| onCallback(ActivityCallback.ON_MOVED_TO_DISPLAY); |
| } |
| |
| public void onCallback(ActivityCallback callback) { |
| if (mPrintCallbackLog) { |
| Log.i(getTag(), callback + " @ " |
| + Integer.toHexString(System.identityHashCode(this))); |
| } |
| final String hostId = getHostId(); |
| if (hostId != null) { |
| sCallbackStorage.add(hostId, callback); |
| } |
| if (mTestJournalClient != null) { |
| mTestJournalClient.addCallback(callback); |
| } |
| } |
| |
| protected void withTestJournalClient(Consumer<TestJournalClient> client) { |
| if (mTestJournalClient != null) { |
| client.accept(mTestJournalClient); |
| } |
| } |
| |
| protected String getTag() { |
| return mTag; |
| } |
| |
| /** Get configuration and display info. It should be called only after resumed. */ |
| protected ConfigInfo getConfigInfo() { |
| final View view = getWindow().getDecorView(); |
| if (!view.isAttachedToWindow()) { |
| Log.w(getTag(), "Decor view has not attached"); |
| } |
| return new ConfigInfo(view.getContext(), view.getDisplay()); |
| } |
| |
| /** Same as {@link #getConfigInfo()}, but for Application. */ |
| private ConfigInfo getAppConfigInfo() { |
| final Application application = (Application) getApplicationContext(); |
| return new ConfigInfo(application, getDisplay()); |
| } |
| } |
| |
| public enum ActivityCallback implements Parcelable { |
| ON_CREATE, |
| ON_START, |
| ON_RESUME, |
| ON_PAUSE, |
| ON_STOP, |
| ON_RESTART, |
| ON_DESTROY, |
| ON_ACTIVITY_RESULT, |
| ON_USER_LEAVE_HINT, |
| ON_NEW_INTENT, |
| ON_CONFIGURATION_CHANGED, |
| ON_MULTI_WINDOW_MODE_CHANGED, |
| ON_PICTURE_IN_PICTURE_MODE_CHANGED, |
| ON_MOVED_TO_DISPLAY, |
| ON_PICTURE_IN_PICTURE_REQUESTED; |
| |
| private static final ActivityCallback[] sValues = ActivityCallback.values(); |
| public static final int SIZE = sValues.length; |
| |
| @Override |
| public int describeContents() { |
| return 0; |
| } |
| |
| @Override |
| public void writeToParcel(final Parcel dest, final int flags) { |
| dest.writeInt(ordinal()); |
| } |
| |
| public static final Creator<ActivityCallback> CREATOR = new Creator<ActivityCallback>() { |
| @Override |
| public ActivityCallback createFromParcel(final Parcel source) { |
| return sValues[source.readInt()]; |
| } |
| |
| @Override |
| public ActivityCallback[] newArray(final int size) { |
| return new ActivityCallback[size]; |
| } |
| }; |
| } |
| |
| public static class ConfigInfo implements Parcelable { |
| public int displayId = Display.INVALID_DISPLAY; |
| public int rotation; |
| public SizeInfo sizeInfo; |
| |
| ConfigInfo() { |
| } |
| |
| public ConfigInfo(Context context, Display display) { |
| final Resources res = context.getResources(); |
| final DisplayMetrics metrics = res.getDisplayMetrics(); |
| final Configuration config = res.getConfiguration(); |
| |
| if (display != null) { |
| displayId = display.getDisplayId(); |
| rotation = display.getRotation(); |
| } |
| sizeInfo = new SizeInfo(display, metrics, config); |
| } |
| |
| public ConfigInfo(Resources res) { |
| final DisplayMetrics metrics = res.getDisplayMetrics(); |
| final Configuration config = res.getConfiguration(); |
| sizeInfo = new SizeInfo(null /* display */, metrics, config); |
| } |
| |
| @Override |
| public String toString() { |
| return "ConfigInfo: {displayId=" + displayId + " rotation=" + rotation |
| + " " + sizeInfo + "}"; |
| } |
| |
| @Override |
| public int describeContents() { |
| return 0; |
| } |
| |
| @Override |
| public void writeToParcel(Parcel dest, int flags) { |
| dest.writeInt(displayId); |
| dest.writeInt(rotation); |
| dest.writeParcelable(sizeInfo, 0 /* parcelableFlags */); |
| } |
| |
| public void readFromParcel(Parcel in) { |
| displayId = in.readInt(); |
| rotation = in.readInt(); |
| sizeInfo = in.readParcelable(SizeInfo.class.getClassLoader()); |
| } |
| |
| public static final Creator<ConfigInfo> CREATOR = new Creator<ConfigInfo>() { |
| @Override |
| public ConfigInfo createFromParcel(Parcel source) { |
| final ConfigInfo sizeInfo = new ConfigInfo(); |
| sizeInfo.readFromParcel(source); |
| return sizeInfo; |
| } |
| |
| @Override |
| public ConfigInfo[] newArray(int size) { |
| return new ConfigInfo[size]; |
| } |
| }; |
| } |
| |
| public static class SizeInfo implements Parcelable { |
| public int widthDp; |
| public int heightDp; |
| public int displayWidth; |
| public int displayHeight; |
| public int metricsWidth; |
| public int metricsHeight; |
| public int smallestWidthDp; |
| public int densityDpi; |
| public int orientation; |
| public int windowWidth; |
| public int windowHeight; |
| public int windowAppWidth; |
| public int windowAppHeight; |
| |
| SizeInfo() { |
| } |
| |
| public SizeInfo(Display display, DisplayMetrics metrics, Configuration config) { |
| if (display != null) { |
| final Point displaySize = new Point(); |
| display.getSize(displaySize); |
| displayWidth = displaySize.x; |
| displayHeight = displaySize.y; |
| } |
| |
| widthDp = config.screenWidthDp; |
| heightDp = config.screenHeightDp; |
| metricsWidth = metrics.widthPixels; |
| metricsHeight = metrics.heightPixels; |
| smallestWidthDp = config.smallestScreenWidthDp; |
| densityDpi = config.densityDpi; |
| orientation = config.orientation; |
| windowWidth = config.windowConfiguration.getBounds().width(); |
| windowHeight = config.windowConfiguration.getBounds().height(); |
| windowAppWidth = config.windowConfiguration.getAppBounds().width(); |
| windowAppHeight = config.windowConfiguration.getAppBounds().height(); |
| } |
| |
| @Override |
| public String toString() { |
| return "SizeInfo: {widthDp=" + widthDp + " heightDp=" + heightDp |
| + " displayWidth=" + displayWidth + " displayHeight=" + displayHeight |
| + " metricsWidth=" + metricsWidth + " metricsHeight=" + metricsHeight |
| + " smallestWidthDp=" + smallestWidthDp + " densityDpi=" + densityDpi |
| + " windowWidth=" + windowWidth + " windowHeight=" + windowHeight |
| + " windowAppWidth=" + windowAppWidth + " windowAppHeight=" + windowAppHeight |
| + " orientation=" + orientation + "}"; |
| } |
| |
| @Override |
| public boolean equals(Object obj) { |
| if (obj == this) { |
| return true; |
| } |
| if (!(obj instanceof SizeInfo)) { |
| return false; |
| } |
| final SizeInfo that = (SizeInfo) obj; |
| return widthDp == that.widthDp |
| && heightDp == that.heightDp |
| && displayWidth == that.displayWidth |
| && displayHeight == that.displayHeight |
| && metricsWidth == that.metricsWidth |
| && metricsHeight == that.metricsHeight |
| && smallestWidthDp == that.smallestWidthDp |
| && densityDpi == that.densityDpi |
| && orientation == that.orientation |
| && windowWidth == that.windowWidth |
| && windowHeight == that.windowHeight |
| && windowAppWidth == that.windowAppWidth |
| && windowAppHeight == that.windowAppHeight; |
| } |
| |
| @Override |
| public int hashCode() { |
| int result = 0; |
| result = 31 * result + widthDp; |
| result = 31 * result + heightDp; |
| result = 31 * result + displayWidth; |
| result = 31 * result + displayHeight; |
| result = 31 * result + metricsWidth; |
| result = 31 * result + metricsHeight; |
| result = 31 * result + smallestWidthDp; |
| result = 31 * result + densityDpi; |
| result = 31 * result + orientation; |
| result = 31 * result + windowWidth; |
| result = 31 * result + windowHeight; |
| result = 31 * result + windowAppWidth; |
| result = 31 * result + windowAppHeight; |
| return result; |
| } |
| |
| @Override |
| public int describeContents() { |
| return 0; |
| } |
| |
| @Override |
| public void writeToParcel(Parcel dest, int flags) { |
| dest.writeInt(widthDp); |
| dest.writeInt(heightDp); |
| dest.writeInt(displayWidth); |
| dest.writeInt(displayHeight); |
| dest.writeInt(metricsWidth); |
| dest.writeInt(metricsHeight); |
| dest.writeInt(smallestWidthDp); |
| dest.writeInt(densityDpi); |
| dest.writeInt(orientation); |
| dest.writeInt(windowWidth); |
| dest.writeInt(windowHeight); |
| dest.writeInt(windowAppWidth); |
| dest.writeInt(windowAppHeight); |
| } |
| |
| public void readFromParcel(Parcel in) { |
| widthDp = in.readInt(); |
| heightDp = in.readInt(); |
| displayWidth = in.readInt(); |
| displayHeight = in.readInt(); |
| metricsWidth = in.readInt(); |
| metricsHeight = in.readInt(); |
| smallestWidthDp = in.readInt(); |
| densityDpi = in.readInt(); |
| orientation = in.readInt(); |
| windowWidth = in.readInt(); |
| windowHeight = in.readInt(); |
| windowAppWidth = in.readInt(); |
| windowAppHeight = in.readInt(); |
| } |
| |
| public static final Creator<SizeInfo> CREATOR = new Creator<SizeInfo>() { |
| @Override |
| public SizeInfo createFromParcel(Parcel source) { |
| final SizeInfo sizeInfo = new SizeInfo(); |
| sizeInfo.readFromParcel(source); |
| return sizeInfo; |
| } |
| |
| @Override |
| public SizeInfo[] newArray(int size) { |
| return new SizeInfo[size]; |
| } |
| }; |
| } |
| } |