1 /* 2 * Copyright (C) 2018 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License 15 */ 16 17 package android.server.wm; 18 19 import static androidx.test.InstrumentationRegistry.getInstrumentation; 20 21 import android.app.Activity; 22 import android.content.BroadcastReceiver; 23 import android.content.ComponentName; 24 import android.content.Context; 25 import android.content.Intent; 26 import android.content.IntentFilter; 27 import android.content.res.Configuration; 28 import android.content.res.Resources; 29 import android.graphics.Point; 30 import android.os.Build; 31 import android.os.Bundle; 32 import android.os.Handler; 33 import android.os.HandlerThread; 34 import android.os.Looper; 35 import android.os.Parcel; 36 import android.os.Parcelable; 37 import android.os.Process; 38 import android.os.SystemClock; 39 import android.server.wm.TestJournalProvider.TestJournalClient; 40 import android.util.ArrayMap; 41 import android.util.DisplayMetrics; 42 import android.util.Log; 43 import android.view.Display; 44 import android.view.View; 45 46 import java.util.ArrayList; 47 import java.util.Iterator; 48 import java.util.concurrent.TimeoutException; 49 import java.util.function.Consumer; 50 51 /** 52 * A mechanism for communication between the started activity and its caller in different package or 53 * process. Generally, a test case is the client, and the testing activity is the host. The client 54 * can control whether to send an async or sync command with response data. 55 * <p>Sample:</p> 56 * <pre> 57 * try (ActivitySessionClient client = new ActivitySessionClient(context)) { 58 * final ActivitySession session = client.startActivity( 59 * new Intent(context, TestActivity.class)); 60 * final Bundle response = session.requestOrientation( 61 * ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); 62 * Log.i("Test", "Config: " + CommandSession.getConfigInfo(response)); 63 * Log.i("Test", "Callbacks: " + CommandSession.getCallbackHistory(response)); 64 * 65 * session.startActivity(session.getOriginalLaunchIntent()); 66 * Log.i("Test", "New intent callbacks: " + session.takeCallbackHistory()); 67 * } 68 * </pre> 69 * <p>To perform custom command, use sendCommand* in {@link ActivitySession} to send the request, 70 * and the receiving side (activity) can extend {@link BasicTestActivity} or 71 * {@link CommandSessionActivity} with overriding handleCommand to do the corresponding action.</p> 72 */ 73 public final class CommandSession { 74 private static final boolean DEBUG = "eng".equals(Build.TYPE); 75 private static final String TAG = "CommandSession"; 76 77 private static final String EXTRA_PREFIX = "s_"; 78 79 private static final String KEY_CALLBACK_HISTORY = EXTRA_PREFIX + "key_callback_history"; 80 private static final String KEY_CLIENT_ID = EXTRA_PREFIX + "key_client_id"; 81 private static final String KEY_COMMAND = EXTRA_PREFIX + "key_command"; 82 private static final String KEY_CONFIG_INFO = EXTRA_PREFIX + "key_config_info"; 83 // TODO(b/112837428): Used for LaunchActivityBuilder#launchUsingShellCommand 84 private static final String KEY_FORWARD = EXTRA_PREFIX + "key_forward"; 85 private static final String KEY_HOST_ID = EXTRA_PREFIX + "key_host_id"; 86 private static final String KEY_ORIENTATION = EXTRA_PREFIX + "key_orientation"; 87 private static final String KEY_REQUEST_TOKEN = EXTRA_PREFIX + "key_request_id"; 88 private static final String KEY_UID_HAS_ACCESS_ON_DISPLAY = 89 EXTRA_PREFIX + "uid_has_access_on_display"; 90 91 private static final String COMMAND_FINISH = EXTRA_PREFIX + "command_finish"; 92 private static final String COMMAND_GET_CONFIG = EXTRA_PREFIX + "command_get_config"; 93 private static final String COMMAND_ORIENTATION = EXTRA_PREFIX + "command_orientation"; 94 private static final String COMMAND_TAKE_CALLBACK_HISTORY = EXTRA_PREFIX 95 + "command_take_callback_history"; 96 private static final String COMMAND_WAIT_IDLE = EXTRA_PREFIX + "command_wait_idle"; 97 private static final String COMMAND_DISPLAY_ACCESS_CHECK = 98 EXTRA_PREFIX + "display_access_check"; 99 100 private static final long INVALID_REQUEST_TOKEN = -1; 101 102 private CommandSession() { 103 } 104 105 /** Get {@link ConfigInfo} from bundle. */ 106 public static ConfigInfo getConfigInfo(Bundle data) { 107 return data.getParcelable(KEY_CONFIG_INFO); 108 } 109 110 /** Get list of {@link ActivityCallback} from bundle. */ 111 public static ArrayList<ActivityCallback> getCallbackHistory(Bundle data) { 112 return data.getParcelableArrayList(KEY_CALLBACK_HISTORY); 113 } 114 115 /** Return non-null if the session info should forward to launch target. */ 116 public static LaunchInjector handleForward(Bundle data) { 117 if (data == null || !data.getBoolean(KEY_FORWARD)) { 118 return null; 119 } 120 121 // Only keep the necessary data which relates to session. 122 final Bundle sessionInfo = new Bundle(data); 123 sessionInfo.remove(KEY_FORWARD); 124 for (String key : sessionInfo.keySet()) { 125 if (!key.startsWith(EXTRA_PREFIX)) { 126 sessionInfo.remove(key); 127 } 128 } 129 130 return new LaunchInjector() { 131 @Override 132 public void setupIntent(Intent intent) { 133 intent.putExtras(sessionInfo); 134 } 135 136 @Override 137 public void setupShellCommand(StringBuilder shellCommand) { 138 // Currently there is no use case from shell. 139 throw new UnsupportedOperationException(); 140 } 141 }; 142 } 143 144 private static String generateId(String prefix, Object obj) { 145 return prefix + "_" + Integer.toHexString(System.identityHashCode(obj)); 146 } 147 148 private static String commandIntentToString(Intent intent) { 149 return intent.getStringExtra(KEY_COMMAND) 150 + "@" + intent.getLongExtra(KEY_REQUEST_TOKEN, INVALID_REQUEST_TOKEN); 151 } 152 153 /** Get an unique token to match the request and reply. */ 154 private static long generateRequestToken() { 155 return SystemClock.elapsedRealtimeNanos(); 156 } 157 158 /** 159 * As a controller associated with the testing activity. It can only process one sync command 160 * (require response) at a time. 161 */ 162 public static class ActivitySession { 163 private final ActivitySessionClient mClient; 164 private final String mHostId; 165 private final Response mPendingResponse = new Response(); 166 // Only set when requiring response. 167 private long mPendingRequestToken = INVALID_REQUEST_TOKEN; 168 private String mPendingCommand; 169 private boolean mFinished; 170 private Intent mOriginalLaunchIntent; 171 172 ActivitySession(ActivitySessionClient client, boolean requireReply) { 173 mClient = client; 174 mHostId = generateId("activity", this); 175 if (requireReply) { 176 mPendingRequestToken = generateRequestToken(); 177 mPendingCommand = COMMAND_WAIT_IDLE; 178 } 179 } 180 181 /** Start the activity again. The intent must have the same filter as original one. */ 182 public void startActivity(Intent intent) { 183 if (!intent.filterEquals(mOriginalLaunchIntent)) { 184 throw new IllegalArgumentException("The intent filter is different " + intent); 185 } 186 mClient.mContext.startActivity(intent); 187 mFinished = false; 188 } 189 190 /** 191 * Request the activity to set the given orientation. The returned bundle contains the 192 * changed config info and activity lifecycles during the change. 193 * 194 * @param orientation An orientation constant as used in 195 * {@link android.content.pm.ActivityInfo#screenOrientation}. 196 */ 197 public Bundle requestOrientation(int orientation) { 198 final Bundle data = new Bundle(); 199 data.putInt(KEY_ORIENTATION, orientation); 200 return sendCommandAndWaitReply(COMMAND_ORIENTATION, data); 201 } 202 203 /** Get {@link ConfigInfo} of the associated activity. */ 204 public ConfigInfo getConfigInfo() { 205 return CommandSession.getConfigInfo(sendCommandAndWaitReply(COMMAND_GET_CONFIG)); 206 } 207 208 /** 209 * Get executed callbacks of the activity since the last command. The current callback 210 * history will also be cleared. 211 */ 212 public ArrayList<ActivityCallback> takeCallbackHistory() { 213 return getCallbackHistory(sendCommandAndWaitReply(COMMAND_TAKE_CALLBACK_HISTORY, 214 null /* data */)); 215 } 216 217 /** Get the intent that launches the activity. Null if launch from shell command. */ 218 public Intent getOriginalLaunchIntent() { 219 return mOriginalLaunchIntent; 220 } 221 222 /** Get a name to represent this session by the original launch intent if possible. */ 223 public String getName() { 224 if (mOriginalLaunchIntent != null) { 225 final ComponentName componentName = mOriginalLaunchIntent.getComponent(); 226 if (componentName != null) { 227 return componentName.flattenToShortString(); 228 } 229 return mOriginalLaunchIntent.toString(); 230 } 231 return "Activity"; 232 } 233 234 public boolean isUidAccesibleOnDisplay() { 235 return sendCommandAndWaitReply(COMMAND_DISPLAY_ACCESS_CHECK, null).getBoolean( 236 KEY_UID_HAS_ACCESS_ON_DISPLAY); 237 } 238 239 /** Send command to the associated activity. */ 240 public void sendCommand(String command) { 241 sendCommand(command, null /* data */); 242 } 243 244 /** Send command with extra parameters to the associated activity. */ 245 public void sendCommand(String command, Bundle data) { 246 if (mFinished) { 247 throw new IllegalStateException("The session is finished"); 248 } 249 250 final Intent intent = new Intent(mHostId); 251 if (data != null) { 252 intent.putExtras(data); 253 } 254 intent.putExtra(KEY_COMMAND, command); 255 mClient.mContext.sendBroadcast(intent); 256 if (DEBUG) { 257 Log.i(TAG, mClient.mClientId + " sends " + commandIntentToString(intent) 258 + " to " + mHostId); 259 } 260 } 261 262 public Bundle sendCommandAndWaitReply(String command) { 263 return sendCommandAndWaitReply(command, null /* data */); 264 } 265 266 /** Returns the reply data by the given command. */ 267 public Bundle sendCommandAndWaitReply(String command, Bundle data) { 268 if (data == null) { 269 data = new Bundle(); 270 } 271 272 if (mPendingRequestToken != INVALID_REQUEST_TOKEN) { 273 throw new IllegalStateException("The previous pending request " 274 + mPendingCommand + " has not replied"); 275 } 276 mPendingRequestToken = generateRequestToken(); 277 mPendingCommand = command; 278 data.putLong(KEY_REQUEST_TOKEN, mPendingRequestToken); 279 280 sendCommand(command, data); 281 return waitReply(); 282 } 283 284 private Bundle waitReply() { 285 if (mPendingRequestToken == INVALID_REQUEST_TOKEN) { 286 throw new IllegalStateException("No pending request to wait"); 287 } 288 289 if (DEBUG) Log.i(TAG, "Waiting for request " + mPendingRequestToken); 290 try { 291 return mPendingResponse.takeResult(); 292 } catch (TimeoutException e) { 293 throw new RuntimeException("Timeout on command " 294 + mPendingCommand + " with token " + mPendingRequestToken, e); 295 } finally { 296 mPendingRequestToken = INVALID_REQUEST_TOKEN; 297 mPendingCommand = null; 298 } 299 } 300 301 // This method should run on an independent thread. 302 void receiveReply(Bundle reply) { 303 final long incomingToken = reply.getLong(KEY_REQUEST_TOKEN); 304 if (incomingToken == mPendingRequestToken) { 305 mPendingResponse.setResult(reply); 306 } else { 307 throw new IllegalStateException("Mismatched token: incoming=" + incomingToken 308 + " pending=" + mPendingRequestToken); 309 } 310 } 311 312 /** Finish the activity that associates with this session. */ 313 public void finish() { 314 if (!mFinished) { 315 sendCommand(COMMAND_FINISH); 316 mClient.mSessions.remove(mHostId); 317 mFinished = true; 318 } 319 } 320 321 private static class Response { 322 static final int TIMEOUT_MILLIS = 5000; 323 private volatile boolean mHasResult; 324 private Bundle mResult; 325 326 synchronized void setResult(Bundle result) { 327 mHasResult = true; 328 mResult = result; 329 notifyAll(); 330 } 331 332 synchronized Bundle takeResult() throws TimeoutException { 333 final long startTime = SystemClock.uptimeMillis(); 334 while (!mHasResult) { 335 try { 336 wait(TIMEOUT_MILLIS); 337 } catch (InterruptedException ignored) { 338 } 339 if (!mHasResult && (SystemClock.uptimeMillis() - startTime > TIMEOUT_MILLIS)) { 340 throw new TimeoutException("No response over " + TIMEOUT_MILLIS + "ms"); 341 } 342 } 343 344 final Bundle result = mResult; 345 mHasResult = false; 346 mResult = null; 347 return result; 348 } 349 } 350 } 351 352 /** For LaunchProxy to setup launch parameter that establishes session. */ 353 interface LaunchInjector { 354 void setupIntent(Intent intent); 355 void setupShellCommand(StringBuilder shellCommand); 356 } 357 358 /** A proxy to launch activity by intent or shell command. */ 359 interface LaunchProxy { 360 void setLaunchInjector(LaunchInjector injector); 361 default Bundle getExtras() { return null; } 362 void execute(); 363 boolean shouldWaitForLaunched(); 364 } 365 366 /** Created by test case to control testing activity that implements the session protocol. */ 367 public static class ActivitySessionClient extends BroadcastReceiver implements AutoCloseable { 368 private final Context mContext; 369 private final String mClientId; 370 private final HandlerThread mThread; 371 private final ArrayMap<String, ActivitySession> mSessions = new ArrayMap<>(); 372 private boolean mClosed; 373 374 public ActivitySessionClient() { 375 this(getInstrumentation().getContext()); 376 } 377 378 public ActivitySessionClient(Context context) { 379 mContext = context; 380 mClientId = generateId("testcase", this); 381 mThread = new HandlerThread(mClientId); 382 mThread.start(); 383 context.registerReceiver(this, new IntentFilter(mClientId), 384 null /* broadcastPermission */, new Handler(mThread.getLooper())); 385 } 386 387 /** Start the activity by the given intent and wait it becomes idle. */ 388 public ActivitySession startActivity(Intent intent) { 389 return startActivity(intent, null /* options */, true /* waitIdle */); 390 } 391 392 /** 393 * Launch the activity and establish a new session. 394 * 395 * @param intent The description of the activity to start. 396 * @param options Additional options for how the Activity should be started. 397 * @param waitIdle Block in this method until the target activity is idle. 398 * @return The session to communicate with the started activity. 399 */ 400 public ActivitySession startActivity(Intent intent, Bundle options, boolean waitIdle) { 401 ensureNotClosed(); 402 final ActivitySession session = new ActivitySession(this, waitIdle); 403 mSessions.put(session.mHostId, session); 404 setupLaunchIntent(intent, waitIdle, session); 405 406 if (!(mContext instanceof Activity)) { 407 intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 408 } 409 mContext.startActivity(intent, options); 410 if (waitIdle) { 411 session.waitReply(); 412 } 413 return session; 414 } 415 416 /** Launch activity via proxy that allows to inject session parameters. */ 417 public ActivitySession startActivity(LaunchProxy proxy) { 418 ensureNotClosed(); 419 final boolean waitIdle = proxy.shouldWaitForLaunched(); 420 final ActivitySession session = new ActivitySession(this, waitIdle); 421 mSessions.put(session.mHostId, session); 422 423 proxy.setLaunchInjector(new LaunchInjector() { 424 @Override 425 public void setupIntent(Intent intent) { 426 final Bundle bundle = proxy.getExtras(); 427 if (bundle != null) { 428 intent.putExtras(bundle); 429 } 430 setupLaunchIntent(intent, waitIdle, session); 431 } 432 433 @Override 434 public void setupShellCommand(StringBuilder commandBuilder) { 435 commandBuilder.append(" --es " + KEY_HOST_ID + " " + session.mHostId); 436 commandBuilder.append(" --es " + KEY_CLIENT_ID + " " + mClientId); 437 if (waitIdle) { 438 commandBuilder.append( 439 " --el " + KEY_REQUEST_TOKEN + " " + session.mPendingRequestToken); 440 commandBuilder.append(" --es " + KEY_COMMAND + " " + COMMAND_WAIT_IDLE); 441 } 442 } 443 }); 444 445 proxy.execute(); 446 if (waitIdle) { 447 session.waitReply(); 448 } 449 return session; 450 } 451 452 private void setupLaunchIntent(Intent intent, boolean waitIdle, ActivitySession session) { 453 intent.putExtra(KEY_HOST_ID, session.mHostId); 454 intent.putExtra(KEY_CLIENT_ID, mClientId); 455 if (waitIdle) { 456 intent.putExtra(KEY_REQUEST_TOKEN, session.mPendingRequestToken); 457 intent.putExtra(KEY_COMMAND, COMMAND_WAIT_IDLE); 458 } 459 session.mOriginalLaunchIntent = intent; 460 } 461 462 private void ensureNotClosed() { 463 if (mClosed) { 464 throw new IllegalStateException("This session client is closed."); 465 } 466 } 467 468 @Override 469 public void onReceive(Context context, Intent intent) { 470 final ActivitySession session = mSessions.get(intent.getStringExtra(KEY_HOST_ID)); 471 if (DEBUG) Log.i(TAG, mClientId + " receives " + commandIntentToString(intent)); 472 if (session != null) { 473 session.receiveReply(intent.getExtras()); 474 } else { 475 Log.w(TAG, "No available session for " + commandIntentToString(intent)); 476 } 477 } 478 479 /** Complete cleanup with finishing all associated activities. */ 480 @Override 481 public void close() { 482 close(true /* finishSession */); 483 } 484 485 /** Cleanup except finish associated activities. */ 486 public void closeAndKeepSession() { 487 close(false /* finishSession */); 488 } 489 490 /** 491 * Closes this client. Once a client is closed, all methods on it will throw an 492 * IllegalStateException and all responses from host are ignored. 493 * 494 * @param finishSession Whether to finish activities launched from this client. 495 */ 496 public void close(boolean finishSession) { 497 ensureNotClosed(); 498 mClosed = true; 499 if (finishSession) { 500 for (int i = mSessions.size() - 1; i >= 0; i--) { 501 mSessions.valueAt(i).finish(); 502 } 503 } 504 mContext.unregisterReceiver(this); 505 mThread.quit(); 506 } 507 } 508 509 /** 510 * Interface definition for session host to process command from {@link ActivitySessionClient}. 511 */ 512 interface CommandReceiver { 513 /** Called when the session host is receiving command. */ 514 void receiveCommand(String command, Bundle data); 515 } 516 517 /** The host receives command from the test client. */ 518 public static class ActivitySessionHost extends BroadcastReceiver { 519 private final CommandReceiver mCallback; 520 private final Context mContext; 521 private final String mClientId; 522 private final String mHostId; 523 524 ActivitySessionHost(Context context, String hostId, String clientId, 525 CommandReceiver callback) { 526 mContext = context; 527 mHostId = hostId; 528 mClientId = clientId; 529 mCallback = callback; 530 context.registerReceiver(this, new IntentFilter(hostId)); 531 } 532 533 @Override 534 public void onReceive(Context context, Intent intent) { 535 if (DEBUG) { 536 Log.i(TAG, mHostId + "(" + mContext.getClass().getSimpleName() 537 + ") receives " + commandIntentToString(intent)); 538 } 539 mCallback.receiveCommand(intent.getStringExtra(KEY_COMMAND), intent.getExtras()); 540 } 541 542 void reply(String command, Bundle data) { 543 final Intent intent = new Intent(mClientId); 544 intent.putExtras(data); 545 intent.putExtra(KEY_COMMAND, command); 546 intent.putExtra(KEY_HOST_ID, mHostId); 547 mContext.sendBroadcast(intent); 548 if (DEBUG) { 549 Log.i(TAG, mHostId + "(" + mContext.getClass().getSimpleName() 550 + ") replies " + commandIntentToString(intent) + " to " + mClientId); 551 } 552 } 553 554 void destory() { 555 mContext.unregisterReceiver(this); 556 } 557 } 558 559 /** 560 * A map to store data by host id. The usage should be declared as static that is able to keep 561 * data after activity is relaunched. 562 */ 563 private static class StaticHostStorage<T> { 564 final ArrayMap<String, ArrayList<T>> mStorage = new ArrayMap<>(); 565 566 void add(String hostId, T data) { 567 ArrayList<T> commands = mStorage.get(hostId); 568 if (commands == null) { 569 commands = new ArrayList<>(); 570 mStorage.put(hostId, commands); 571 } 572 commands.add(data); 573 } 574 575 ArrayList<T> get(String hostId) { 576 return mStorage.get(hostId); 577 } 578 579 void clear(String hostId) { 580 mStorage.remove(hostId); 581 } 582 } 583 584 /** Store the commands which have not been handled. */ 585 private static class CommandStorage extends StaticHostStorage<Bundle> { 586 587 /** Remove the oldest matched command and return its request token. */ 588 long consume(String hostId, String command) { 589 final ArrayList<Bundle> commands = mStorage.get(hostId); 590 if (commands != null) { 591 final Iterator<Bundle> iterator = commands.iterator(); 592 while (iterator.hasNext()) { 593 final Bundle data = iterator.next(); 594 if (command.equals(data.getString(KEY_COMMAND))) { 595 iterator.remove(); 596 return data.getLong(KEY_REQUEST_TOKEN); 597 } 598 } 599 if (commands.isEmpty()) { 600 clear(hostId); 601 } 602 } 603 return INVALID_REQUEST_TOKEN; 604 } 605 606 boolean containsCommand(String receiverId, String command) { 607 final ArrayList<Bundle> dataList = mStorage.get(receiverId); 608 if (dataList != null) { 609 for (Bundle data : dataList) { 610 if (command.equals(data.getString(KEY_COMMAND))) { 611 return true; 612 } 613 } 614 } 615 return false; 616 } 617 } 618 619 /** 620 * The base activity which supports the session protocol. If the caller does not use 621 * {@link ActivitySessionClient}, it behaves as a normal activity. 622 */ 623 public static class CommandSessionActivity extends Activity implements CommandReceiver { 624 /** Static command storage for across relaunch. */ 625 private static CommandStorage sCommandStorage; 626 private ActivitySessionHost mReceiver; 627 628 protected TestJournalClient mTestJournalClient; 629 630 @Override 631 protected void onCreate(Bundle savedInstanceState) { 632 super.onCreate(savedInstanceState); 633 mTestJournalClient = TestJournalClient.create(this /* context */, getComponentName()); 634 635 final String hostId = getIntent().getStringExtra(KEY_HOST_ID); 636 final String clientId = getIntent().getStringExtra(KEY_CLIENT_ID); 637 if (hostId != null && clientId != null) { 638 if (sCommandStorage == null) { 639 sCommandStorage = new CommandStorage(); 640 } 641 mReceiver = new ActivitySessionHost(this /* context */, hostId, clientId, 642 this /* callback */); 643 } 644 } 645 646 @Override 647 protected void onDestroy() { 648 super.onDestroy(); 649 if (mReceiver != null) { 650 if (!isChangingConfigurations()) { 651 sCommandStorage.clear(getHostId()); 652 } 653 mReceiver.destory(); 654 } 655 if (mTestJournalClient != null) { 656 mTestJournalClient.close(); 657 } 658 } 659 660 @Override 661 public final void receiveCommand(String command, Bundle data) { 662 if (mReceiver == null) { 663 throw new IllegalStateException("The receiver is not created"); 664 } 665 sCommandStorage.add(getHostId(), data); 666 handleCommand(command, data); 667 } 668 669 /** Handle the incoming command from client. */ 670 protected void handleCommand(String command, Bundle data) { 671 } 672 673 protected final void reply(String command) { 674 reply(command, null /* data */); 675 } 676 677 /** Reply data to client for the command. */ 678 protected final void reply(String command, Bundle data) { 679 if (mReceiver == null) { 680 throw new IllegalStateException("The receiver is not created"); 681 } 682 final long requestToke = sCommandStorage.consume(getHostId(), command); 683 if (requestToke == INVALID_REQUEST_TOKEN) { 684 throw new IllegalStateException("There is no pending command " + command); 685 } 686 if (data == null) { 687 data = new Bundle(); 688 } 689 data.putLong(KEY_REQUEST_TOKEN, requestToke); 690 mReceiver.reply(command, data); 691 } 692 693 protected boolean hasPendingCommand(String command) { 694 return mReceiver != null && sCommandStorage.containsCommand(getHostId(), command); 695 } 696 697 /** Returns null means this activity does support the session protocol. */ 698 final String getHostId() { 699 return mReceiver != null ? mReceiver.mHostId : null; 700 } 701 } 702 703 /** The default implementation that supports basic commands to interact with activity. */ 704 public static class BasicTestActivity extends CommandSessionActivity { 705 /** Static callback history for across relaunch. */ 706 private static final StaticHostStorage<ActivityCallback> sCallbackStorage = 707 new StaticHostStorage<>(); 708 709 protected boolean mPrintCallbackLog; 710 711 @Override 712 protected void onCreate(Bundle savedInstanceState) { 713 super.onCreate(savedInstanceState); 714 onCallback(ActivityCallback.ON_CREATE); 715 716 if (getHostId() != null) { 717 final int orientation = getIntent().getIntExtra(KEY_ORIENTATION, Integer.MIN_VALUE); 718 if (orientation != Integer.MIN_VALUE) { 719 setRequestedOrientation(orientation); 720 } 721 if (COMMAND_WAIT_IDLE.equals(getIntent().getStringExtra(KEY_COMMAND))) { 722 receiveCommand(COMMAND_WAIT_IDLE, getIntent().getExtras()); 723 // No need to execute again if the activity is relaunched. 724 getIntent().removeExtra(KEY_COMMAND); 725 } 726 } 727 } 728 729 @Override 730 public void handleCommand(String command, Bundle data) { 731 switch (command) { 732 case COMMAND_ORIENTATION: 733 clearCallbackHistory(); 734 setRequestedOrientation(data.getInt(KEY_ORIENTATION)); 735 getWindow().getDecorView().postDelayed(() -> { 736 if (reportConfigIfNeeded()) { 737 Log.w(getTag(), "Fallback report. The orientation may not change."); 738 } 739 }, ActivitySession.Response.TIMEOUT_MILLIS / 2); 740 break; 741 742 case COMMAND_GET_CONFIG: 743 runWhenIdle(() -> { 744 final Bundle replyData = new Bundle(); 745 replyData.putParcelable(KEY_CONFIG_INFO, getConfigInfo()); 746 reply(COMMAND_GET_CONFIG, replyData); 747 }); 748 break; 749 750 case COMMAND_FINISH: 751 if (!isFinishing()) { 752 finish(); 753 } 754 break; 755 756 case COMMAND_TAKE_CALLBACK_HISTORY: 757 final Bundle replyData = new Bundle(); 758 replyData.putParcelableArrayList(KEY_CALLBACK_HISTORY, getCallbackHistory()); 759 reply(command, replyData); 760 clearCallbackHistory(); 761 break; 762 763 case COMMAND_WAIT_IDLE: 764 runWhenIdle(() -> reply(command)); 765 break; 766 767 case COMMAND_DISPLAY_ACCESS_CHECK: 768 final Bundle result = new Bundle(); 769 final boolean displayHasAccess = getDisplay().hasAccess(Process.myUid()); 770 result.putBoolean(KEY_UID_HAS_ACCESS_ON_DISPLAY, displayHasAccess); 771 reply(command, result); 772 break; 773 774 default: 775 break; 776 } 777 } 778 779 protected final void clearCallbackHistory() { 780 sCallbackStorage.clear(getHostId()); 781 } 782 783 protected final ArrayList<ActivityCallback> getCallbackHistory() { 784 return sCallbackStorage.get(getHostId()); 785 } 786 787 protected void runWhenIdle(Runnable r) { 788 Looper.getMainLooper().getQueue().addIdleHandler(() -> { 789 r.run(); 790 return false; 791 }); 792 } 793 794 protected boolean reportConfigIfNeeded() { 795 if (!hasPendingCommand(COMMAND_ORIENTATION)) { 796 return false; 797 } 798 runWhenIdle(() -> { 799 final Bundle replyData = new Bundle(); 800 replyData.putParcelable(KEY_CONFIG_INFO, getConfigInfo()); 801 replyData.putParcelableArrayList(KEY_CALLBACK_HISTORY, getCallbackHistory()); 802 reply(COMMAND_ORIENTATION, replyData); 803 clearCallbackHistory(); 804 }); 805 return true; 806 } 807 808 @Override 809 protected void onStart() { 810 super.onStart(); 811 onCallback(ActivityCallback.ON_START); 812 } 813 814 @Override 815 protected void onRestart() { 816 super.onRestart(); 817 onCallback(ActivityCallback.ON_RESTART); 818 } 819 820 @Override 821 protected void onResume() { 822 super.onResume(); 823 onCallback(ActivityCallback.ON_RESUME); 824 reportConfigIfNeeded(); 825 } 826 827 @Override 828 protected void onPause() { 829 super.onPause(); 830 onCallback(ActivityCallback.ON_PAUSE); 831 } 832 833 @Override 834 protected void onStop() { 835 super.onStop(); 836 onCallback(ActivityCallback.ON_STOP); 837 } 838 839 @Override 840 protected void onDestroy() { 841 super.onDestroy(); 842 onCallback(ActivityCallback.ON_DESTROY); 843 } 844 845 @Override 846 protected void onActivityResult(int requestCode, int resultCode, Intent data) { 847 super.onActivityResult(requestCode, resultCode, data); 848 onCallback(ActivityCallback.ON_ACTIVITY_RESULT); 849 } 850 851 @Override 852 protected void onUserLeaveHint() { 853 super.onUserLeaveHint(); 854 onCallback(ActivityCallback.ON_USER_LEAVE_HINT); 855 } 856 857 @Override 858 protected void onNewIntent(Intent intent) { 859 super.onNewIntent(intent); 860 onCallback(ActivityCallback.ON_NEW_INTENT); 861 } 862 863 @Override 864 public void onConfigurationChanged(Configuration newConfig) { 865 super.onConfigurationChanged(newConfig); 866 onCallback(ActivityCallback.ON_CONFIGURATION_CHANGED); 867 reportConfigIfNeeded(); 868 } 869 870 @Override 871 public void onMultiWindowModeChanged(boolean isInMultiWindowMode, Configuration newConfig) { 872 super.onMultiWindowModeChanged(isInMultiWindowMode, newConfig); 873 onCallback(ActivityCallback.ON_MULTI_WINDOW_MODE_CHANGED); 874 } 875 876 @Override 877 public void onPictureInPictureModeChanged(boolean isInPictureInPictureMode, 878 Configuration newConfig) { 879 super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig); 880 onCallback(ActivityCallback.ON_PICTURE_IN_PICTURE_MODE_CHANGED); 881 } 882 883 @Override 884 public void onMovedToDisplay(int displayId, Configuration config) { 885 super.onMovedToDisplay(displayId, config); 886 onCallback(ActivityCallback.ON_MOVED_TO_DISPLAY); 887 } 888 889 private void onCallback(ActivityCallback callback) { 890 if (mPrintCallbackLog) { 891 Log.i(getTag(), callback + " @ " 892 + Integer.toHexString(System.identityHashCode(this))); 893 } 894 final String hostId = getHostId(); 895 if (hostId != null) { 896 sCallbackStorage.add(hostId, callback); 897 } 898 if (mTestJournalClient != null) { 899 mTestJournalClient.addCallback(callback); 900 } 901 } 902 903 protected void withTestJournalClient(Consumer<TestJournalClient> client) { 904 if (mTestJournalClient != null) { 905 client.accept(mTestJournalClient); 906 } 907 } 908 909 protected String getTag() { 910 return getClass().getSimpleName(); 911 } 912 913 /** Get configuration and display info. It should be called only after resumed. */ 914 protected ConfigInfo getConfigInfo() { 915 final View view = getWindow().getDecorView(); 916 if (!view.isAttachedToWindow()) { 917 Log.w(getTag(), "Decor view has not attached"); 918 } 919 return new ConfigInfo(view); 920 } 921 } 922 923 public enum ActivityCallback implements Parcelable { 924 ON_CREATE, 925 ON_START, 926 ON_RESUME, 927 ON_PAUSE, 928 ON_STOP, 929 ON_RESTART, 930 ON_DESTROY, 931 ON_ACTIVITY_RESULT, 932 ON_USER_LEAVE_HINT, 933 ON_NEW_INTENT, 934 ON_CONFIGURATION_CHANGED, 935 ON_MULTI_WINDOW_MODE_CHANGED, 936 ON_PICTURE_IN_PICTURE_MODE_CHANGED, 937 ON_MOVED_TO_DISPLAY; 938 939 private static final ActivityCallback[] sValues = ActivityCallback.values(); 940 public static final int SIZE = sValues.length; 941 942 @Override 943 public int describeContents() { 944 return 0; 945 } 946 947 @Override 948 public void writeToParcel(final Parcel dest, final int flags) { 949 dest.writeInt(ordinal()); 950 } 951 952 public static final Creator<ActivityCallback> CREATOR = new Creator<ActivityCallback>() { 953 @Override 954 public ActivityCallback createFromParcel(final Parcel source) { 955 return sValues[source.readInt()]; 956 } 957 958 @Override 959 public ActivityCallback[] newArray(final int size) { 960 return new ActivityCallback[size]; 961 } 962 }; 963 } 964 965 public static class ConfigInfo implements Parcelable { 966 public int displayId = Display.INVALID_DISPLAY; 967 public int rotation; 968 public SizeInfo sizeInfo; 969 970 ConfigInfo() { 971 } 972 973 public ConfigInfo(View view) { 974 final Resources res = view.getContext().getResources(); 975 final DisplayMetrics metrics = res.getDisplayMetrics(); 976 final Configuration config = res.getConfiguration(); 977 final Display display = view.getDisplay(); 978 979 if (display != null) { 980 displayId = display.getDisplayId(); 981 rotation = display.getRotation(); 982 } 983 sizeInfo = new SizeInfo(display, metrics, config); 984 } 985 986 @Override 987 public String toString() { 988 return "ConfigInfo: {displayId=" + displayId + " rotation=" + rotation 989 + " " + sizeInfo + "}"; 990 } 991 992 @Override 993 public int describeContents() { 994 return 0; 995 } 996 997 @Override 998 public void writeToParcel(Parcel dest, int flags) { 999 dest.writeInt(displayId); 1000 dest.writeInt(rotation); 1001 dest.writeParcelable(sizeInfo, 0 /* parcelableFlags */); 1002 } 1003 1004 public void readFromParcel(Parcel in) { 1005 displayId = in.readInt(); 1006 rotation = in.readInt(); 1007 sizeInfo = in.readParcelable(SizeInfo.class.getClassLoader()); 1008 } 1009 1010 public static final Creator<ConfigInfo> CREATOR = new Creator<ConfigInfo>() { 1011 @Override 1012 public ConfigInfo createFromParcel(Parcel source) { 1013 final ConfigInfo sizeInfo = new ConfigInfo(); 1014 sizeInfo.readFromParcel(source); 1015 return sizeInfo; 1016 } 1017 1018 @Override 1019 public ConfigInfo[] newArray(int size) { 1020 return new ConfigInfo[size]; 1021 } 1022 }; 1023 } 1024 1025 public static class SizeInfo implements Parcelable { 1026 public int widthDp; 1027 public int heightDp; 1028 public int displayWidth; 1029 public int displayHeight; 1030 public int metricsWidth; 1031 public int metricsHeight; 1032 public int smallestWidthDp; 1033 public int densityDpi; 1034 public int orientation; 1035 1036 SizeInfo() { 1037 } 1038 1039 public SizeInfo(Display display, DisplayMetrics metrics, Configuration config) { 1040 if (display != null) { 1041 final Point displaySize = new Point(); 1042 display.getSize(displaySize); 1043 displayWidth = displaySize.x; 1044 displayHeight = displaySize.y; 1045 } 1046 1047 widthDp = config.screenWidthDp; 1048 heightDp = config.screenHeightDp; 1049 metricsWidth = metrics.widthPixels; 1050 metricsHeight = metrics.heightPixels; 1051 smallestWidthDp = config.smallestScreenWidthDp; 1052 densityDpi = config.densityDpi; 1053 orientation = config.orientation; 1054 } 1055 1056 @Override 1057 public String toString() { 1058 return "SizeInfo: {widthDp=" + widthDp + " heightDp=" + heightDp 1059 + " displayWidth=" + displayWidth + " displayHeight=" + displayHeight 1060 + " metricsWidth=" + metricsWidth + " metricsHeight=" + metricsHeight 1061 + " smallestWidthDp=" + smallestWidthDp + " densityDpi=" + densityDpi 1062 + " orientation=" + orientation + "}"; 1063 } 1064 1065 @Override 1066 public boolean equals(Object obj) { 1067 if (obj == this) { 1068 return true; 1069 } 1070 if (!(obj instanceof SizeInfo)) { 1071 return false; 1072 } 1073 final SizeInfo that = (SizeInfo) obj; 1074 return widthDp == that.widthDp 1075 && heightDp == that.heightDp 1076 && displayWidth == that.displayWidth 1077 && displayHeight == that.displayHeight 1078 && metricsWidth == that.metricsWidth 1079 && metricsHeight == that.metricsHeight 1080 && smallestWidthDp == that.smallestWidthDp 1081 && densityDpi == that.densityDpi 1082 && orientation == that.orientation; 1083 } 1084 1085 @Override 1086 public int describeContents() { 1087 return 0; 1088 } 1089 1090 @Override 1091 public void writeToParcel(Parcel dest, int flags) { 1092 dest.writeInt(widthDp); 1093 dest.writeInt(heightDp); 1094 dest.writeInt(displayWidth); 1095 dest.writeInt(displayHeight); 1096 dest.writeInt(metricsWidth); 1097 dest.writeInt(metricsHeight); 1098 dest.writeInt(smallestWidthDp); 1099 dest.writeInt(densityDpi); 1100 dest.writeInt(orientation); 1101 } 1102 1103 public void readFromParcel(Parcel in) { 1104 widthDp = in.readInt(); 1105 heightDp = in.readInt(); 1106 displayWidth = in.readInt(); 1107 displayHeight = in.readInt(); 1108 metricsWidth = in.readInt(); 1109 metricsHeight = in.readInt(); 1110 smallestWidthDp = in.readInt(); 1111 densityDpi = in.readInt(); 1112 orientation = in.readInt(); 1113 } 1114 1115 public static final Creator<SizeInfo> CREATOR = new Creator<SizeInfo>() { 1116 @Override 1117 public SizeInfo createFromParcel(Parcel source) { 1118 final SizeInfo sizeInfo = new SizeInfo(); 1119 sizeInfo.readFromParcel(source); 1120 return sizeInfo; 1121 } 1122 1123 @Override 1124 public SizeInfo[] newArray(int size) { 1125 return new SizeInfo[size]; 1126 } 1127 }; 1128 } 1129 } 1130