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 package android.contentcaptureservice.cts; 17 18 import static android.contentcaptureservice.cts.Helper.MY_PACKAGE; 19 import static android.contentcaptureservice.cts.Helper.await; 20 import static android.contentcaptureservice.cts.Helper.componentNameFor; 21 22 import static com.google.common.truth.Truth.assertWithMessage; 23 24 import android.content.ComponentName; 25 import android.os.Parcel; 26 import android.os.ParcelFileDescriptor; 27 import android.service.contentcapture.ActivityEvent; 28 import android.service.contentcapture.ContentCaptureService; 29 import android.service.contentcapture.DataShareCallback; 30 import android.service.contentcapture.DataShareReadAdapter; 31 import android.util.ArrayMap; 32 import android.util.ArraySet; 33 import android.util.Log; 34 import android.util.Pair; 35 import android.view.contentcapture.ContentCaptureContext; 36 import android.view.contentcapture.ContentCaptureEvent; 37 import android.view.contentcapture.ContentCaptureSessionId; 38 import android.view.contentcapture.DataRemovalRequest; 39 import android.view.contentcapture.DataShareRequest; 40 import android.view.contentcapture.ViewNode; 41 42 import androidx.annotation.NonNull; 43 import androidx.annotation.Nullable; 44 45 import java.io.FileDescriptor; 46 import java.io.IOException; 47 import java.io.InputStream; 48 import java.io.PrintWriter; 49 import java.util.ArrayList; 50 import java.util.Collections; 51 import java.util.List; 52 import java.util.Set; 53 import java.util.concurrent.CountDownLatch; 54 import java.util.concurrent.Executor; 55 import java.util.concurrent.Executors; 56 import java.util.stream.Collectors; 57 58 // TODO(b/123540602): if we don't move this service to a separate package, we need to handle the 59 // onXXXX methods in a separate thread 60 // Either way, we need to make sure its methods are thread safe 61 62 public class CtsContentCaptureService extends ContentCaptureService { 63 64 private static final String TAG = CtsContentCaptureService.class.getSimpleName(); 65 66 public static final String SERVICE_NAME = MY_PACKAGE + "/." 67 + CtsContentCaptureService.class.getSimpleName(); 68 public static final ComponentName CONTENT_CAPTURE_SERVICE_COMPONENT_NAME = 69 componentNameFor(CtsContentCaptureService.class); 70 71 private static final Executor sExecutor = Executors.newCachedThreadPool(); 72 73 private static int sIdCounter; 74 75 private static ServiceWatcher sServiceWatcher; 76 77 private final int mId = ++sIdCounter; 78 79 private static final ArrayList<Throwable> sExceptions = new ArrayList<>(); 80 81 private final CountDownLatch mConnectedLatch = new CountDownLatch(1); 82 private final CountDownLatch mDisconnectedLatch = new CountDownLatch(1); 83 84 /** 85 * List of all sessions started - never reset. 86 */ 87 private final ArrayList<ContentCaptureSessionId> mAllSessions = new ArrayList<>(); 88 89 /** 90 * Map of all sessions started but not finished yet - sessions are removed as they're finished. 91 */ 92 private final ArrayMap<ContentCaptureSessionId, Session> mOpenSessions = new ArrayMap<>(); 93 94 /** 95 * Map of all sessions finished. 96 */ 97 private final ArrayMap<ContentCaptureSessionId, Session> mFinishedSessions = new ArrayMap<>(); 98 99 /** 100 * Map of latches for sessions that started but haven't finished yet. 101 */ 102 private final ArrayMap<ContentCaptureSessionId, CountDownLatch> mUnfinishedSessionLatches = 103 new ArrayMap<>(); 104 105 /** 106 * Counter of onCreate() / onDestroy() events. 107 */ 108 private int mLifecycleEventsCounter; 109 110 /** 111 * Counter of received {@link ActivityEvent} events. 112 */ 113 private int mActivityEventsCounter; 114 115 // NOTE: we could use the same counter for mLifecycleEventsCounter and mActivityEventsCounter, 116 // but that would make the tests flaker. 117 118 /** 119 * Used for testing onUserDataRemovalRequest. 120 */ 121 private DataRemovalRequest mRemovalRequest; 122 123 /** 124 * List of activity lifecycle events received. 125 */ 126 private final ArrayList<MyActivityEvent> mActivityEvents = new ArrayList<>(); 127 128 /** 129 * Optional listener for {@code onDisconnect()}. 130 */ 131 @Nullable 132 private DisconnectListener mOnDisconnectListener; 133 134 /** 135 * When set, doesn't throw exceptions when it receives an event from a session that doesn't 136 * exist. 137 */ 138 private boolean mIgnoreOrphanSessionEvents; 139 140 /** 141 * Whether the service should accept a data share session. 142 */ 143 private boolean mDataSharingEnabled = false; 144 145 /** 146 * Bytes that were shared during the content capture 147 */ 148 byte[] mDataShared = new byte[20_000]; 149 150 /** 151 * The fields below represent state of the content capture data sharing session. 152 */ 153 boolean mDataShareSessionStarted = false; 154 boolean mDataShareSessionFinished = false; 155 boolean mDataShareSessionSucceeded = false; 156 int mDataShareSessionErrorCode = 0; 157 DataShareRequest mDataShareRequest; 158 159 @NonNull setServiceWatcher()160 public static ServiceWatcher setServiceWatcher() { 161 if (sServiceWatcher != null) { 162 throw new IllegalStateException("There Can Be Only One!"); 163 } 164 sServiceWatcher = new ServiceWatcher(); 165 return sServiceWatcher; 166 } 167 resetStaticState()168 public static void resetStaticState() { 169 sExceptions.clear(); 170 // TODO(b/123540602): should probably set sInstance to null as well, but first we would need 171 // to make sure each test unbinds the service. 172 173 // TODO(b/123540602): each test should use a different service instance, but we need 174 // to provide onConnected() / onDisconnected() methods first and then change the infra so 175 // we can wait for those 176 177 if (sServiceWatcher != null) { 178 Log.wtf(TAG, "resetStaticState(): should not have sServiceWatcher"); 179 sServiceWatcher = null; 180 } 181 } 182 clearServiceWatcher()183 public static void clearServiceWatcher() { 184 if (sServiceWatcher != null) { 185 if (sServiceWatcher.mReadyToClear) { 186 sServiceWatcher.mService = null; 187 sServiceWatcher = null; 188 } else { 189 sServiceWatcher.mReadyToClear = true; 190 } 191 } 192 } 193 194 /** 195 * When set, doesn't throw exceptions when it receives an event from a session that doesn't 196 * exist. 197 */ 198 // TODO: try to refactor WhitelistTest so it doesn't need this hack. setIgnoreOrphanSessionEvents(boolean newValue)199 public void setIgnoreOrphanSessionEvents(boolean newValue) { 200 Log.d(TAG, "setIgnoreOrphanSessionEvents(): changing from " + mIgnoreOrphanSessionEvents 201 + " to " + newValue); 202 mIgnoreOrphanSessionEvents = newValue; 203 } 204 205 @Override onConnected()206 public void onConnected() { 207 Log.i(TAG, "onConnected(id=" + mId + "): sServiceWatcher=" + sServiceWatcher); 208 209 if (sServiceWatcher == null) { 210 addException("onConnected() without a watcher"); 211 return; 212 } 213 214 if (!sServiceWatcher.mReadyToClear && sServiceWatcher.mService != null) { 215 addException("onConnected(): already created: %s", sServiceWatcher); 216 return; 217 } 218 219 sServiceWatcher.mService = this; 220 sServiceWatcher.mCreated.countDown(); 221 sServiceWatcher.mReadyToClear = false; 222 223 if (mConnectedLatch.getCount() == 0) { 224 addException("already connected: %s", mConnectedLatch); 225 } 226 mConnectedLatch.countDown(); 227 } 228 229 @Override onDisconnected()230 public void onDisconnected() { 231 Log.i(TAG, "onDisconnected(id=" + mId + "): sServiceWatcher=" + sServiceWatcher); 232 233 if (mDisconnectedLatch.getCount() == 0) { 234 addException("already disconnected: %s", mConnectedLatch); 235 } 236 mDisconnectedLatch.countDown(); 237 238 if (sServiceWatcher == null) { 239 addException("onDisconnected() without a watcher"); 240 return; 241 } 242 if (sServiceWatcher.mService == null) { 243 addException("onDisconnected(): no service on %s", sServiceWatcher); 244 return; 245 } 246 // Notify test case as well 247 if (mOnDisconnectListener != null) { 248 final CountDownLatch latch = mOnDisconnectListener.mLatch; 249 mOnDisconnectListener = null; 250 latch.countDown(); 251 } 252 sServiceWatcher.mDestroyed.countDown(); 253 clearServiceWatcher(); 254 } 255 256 /** 257 * Waits until the system calls {@link #onConnected()}. 258 */ waitUntilConnected()259 public void waitUntilConnected() throws InterruptedException { 260 await(mConnectedLatch, "not connected"); 261 } 262 263 /** 264 * Waits until the system calls {@link #onDisconnected()}. 265 */ waitUntilDisconnected()266 public void waitUntilDisconnected() throws InterruptedException { 267 await(mDisconnectedLatch, "not disconnected"); 268 } 269 270 @Override onCreateContentCaptureSession(ContentCaptureContext context, ContentCaptureSessionId sessionId)271 public void onCreateContentCaptureSession(ContentCaptureContext context, 272 ContentCaptureSessionId sessionId) { 273 Log.i(TAG, "onCreateContentCaptureSession(id=" + mId + ", ignoreOrpahn=" 274 + mIgnoreOrphanSessionEvents + ", ctx=" + context + ", session=" + sessionId); 275 if (mIgnoreOrphanSessionEvents) return; 276 mAllSessions.add(sessionId); 277 278 safeRun(() -> { 279 final Session session = mOpenSessions.get(sessionId); 280 if (session != null) { 281 throw new IllegalStateException("Already contains session for " + sessionId 282 + ": " + session); 283 } 284 mUnfinishedSessionLatches.put(sessionId, new CountDownLatch(1)); 285 mOpenSessions.put(sessionId, new Session(sessionId, context)); 286 }); 287 } 288 289 @Override onDestroyContentCaptureSession(ContentCaptureSessionId sessionId)290 public void onDestroyContentCaptureSession(ContentCaptureSessionId sessionId) { 291 Log.i(TAG, "onDestroyContentCaptureSession(id=" + mId + ", ignoreOrpahn=" 292 + mIgnoreOrphanSessionEvents + ", session=" + sessionId + ")"); 293 if (mIgnoreOrphanSessionEvents) return; 294 safeRun(() -> { 295 final Session session = getExistingSession(sessionId); 296 session.finish(); 297 mOpenSessions.remove(sessionId); 298 if (mFinishedSessions.containsKey(sessionId)) { 299 throw new IllegalStateException("Already destroyed " + sessionId); 300 } else { 301 mFinishedSessions.put(sessionId, session); 302 final CountDownLatch latch = getUnfinishedSessionLatch(sessionId); 303 latch.countDown(); 304 } 305 }); 306 } 307 308 @Override onContentCaptureEvent(ContentCaptureSessionId sessionId, ContentCaptureEvent originalEvent)309 public void onContentCaptureEvent(ContentCaptureSessionId sessionId, 310 ContentCaptureEvent originalEvent) { 311 // Parcel and unparcel the event to test the parceling logic and trigger the restoration 312 // of Composing/Selection spans. 313 // TODO: Use a service in another process to make the tests more realistic. 314 Parcel parceled = Parcel.obtain(); 315 parceled.setDataPosition(0); 316 originalEvent.writeToParcel(parceled, 0); 317 parceled.setDataPosition(0); 318 final ContentCaptureEvent event = ContentCaptureEvent.CREATOR.createFromParcel(parceled); 319 parceled.recycle(); 320 321 Log.i(TAG, "onContentCaptureEventsRequest(id=" + mId + ", ignoreOrpahn=" 322 + mIgnoreOrphanSessionEvents + ", session=" + sessionId + "): " + event + " text: " 323 + getEventText(event)); 324 if (mIgnoreOrphanSessionEvents) return; 325 final ViewNode node = event.getViewNode(); 326 if (node != null) { 327 Log.v(TAG, "onContentCaptureEvent(): parentId=" + node.getParentAutofillId()); 328 } 329 safeRun(() -> { 330 final Session session = getExistingSession(sessionId); 331 session.mEvents.add(event); 332 }); 333 } 334 335 @Override onDataRemovalRequest(DataRemovalRequest request)336 public void onDataRemovalRequest(DataRemovalRequest request) { 337 Log.i(TAG, "onUserDataRemovalRequest(id=" + mId + ",req=" + request + ")"); 338 mRemovalRequest = request; 339 } 340 341 @Override onDataShareRequest(DataShareRequest request, DataShareCallback callback)342 public void onDataShareRequest(DataShareRequest request, DataShareCallback callback) { 343 if (mDataSharingEnabled) { 344 mDataShareRequest = request; 345 callback.onAccept(sExecutor, new DataShareReadAdapter() { 346 @Override 347 public void onStart(ParcelFileDescriptor fd) { 348 mDataShareSessionStarted = true; 349 350 int bytesReadTotal = 0; 351 try (InputStream fis = new ParcelFileDescriptor.AutoCloseInputStream(fd)) { 352 while (true) { 353 int bytesRead = fis.read(mDataShared, bytesReadTotal, 354 mDataShared.length - bytesReadTotal); 355 if (bytesRead == -1) { 356 break; 357 } 358 bytesReadTotal += bytesRead; 359 } 360 mDataShareSessionFinished = true; 361 mDataShareSessionSucceeded = true; 362 } catch (IOException e) { 363 // fall through. dataShareSessionSucceeded will stay false. 364 } 365 } 366 367 @Override 368 public void onError(int errorCode) { 369 mDataShareSessionFinished = true; 370 mDataShareSessionErrorCode = errorCode; 371 } 372 }); 373 } else { 374 callback.onReject(); 375 mDataShareSessionStarted = mDataShareSessionFinished = true; 376 } 377 } 378 379 @Override onActivityEvent(ActivityEvent event)380 public void onActivityEvent(ActivityEvent event) { 381 Log.i(TAG, "onActivityEvent(): " + event); 382 mActivityEvents.add(new MyActivityEvent(event)); 383 } 384 385 /** 386 * Gets the cached UserDataRemovalRequest for testing. 387 */ getRemovalRequest()388 public DataRemovalRequest getRemovalRequest() { 389 return mRemovalRequest; 390 } 391 392 /** 393 * Gets the finished session for the given session id. 394 * 395 * @throws IllegalStateException if the session didn't finish yet. 396 */ 397 @NonNull getFinishedSession(@onNull ContentCaptureSessionId sessionId)398 public Session getFinishedSession(@NonNull ContentCaptureSessionId sessionId) 399 throws InterruptedException { 400 final CountDownLatch latch = getUnfinishedSessionLatch(sessionId); 401 await(latch, "session %s not finished yet", sessionId); 402 403 final Session session = mFinishedSessions.get(sessionId); 404 if (session == null) { 405 throwIllegalSessionStateException("No finished session for id %s", sessionId); 406 } 407 return session; 408 } 409 410 /** 411 * Gets the finished session when only one session is expected. 412 * 413 * <p>Should be used when the test case doesn't known in advance the id of the session. 414 */ 415 @NonNull getOnlyFinishedSession()416 public Session getOnlyFinishedSession() throws InterruptedException { 417 final ArrayList<ContentCaptureSessionId> allSessions = mAllSessions; 418 assertWithMessage("Wrong number of sessions").that(allSessions).hasSize(1); 419 final ContentCaptureSessionId id = allSessions.get(0); 420 Log.d(TAG, "getOnlyFinishedSession(): id=" + id); 421 return getFinishedSession(id); 422 } 423 424 /** 425 * Gets all sessions that have been created so far. 426 */ 427 @NonNull getAllSessionIds()428 public List<ContentCaptureSessionId> getAllSessionIds() { 429 return Collections.unmodifiableList(mAllSessions); 430 } 431 432 /** 433 * Sets a listener to wait until the service disconnects. 434 */ 435 @NonNull setOnDisconnectListener()436 public DisconnectListener setOnDisconnectListener() { 437 if (mOnDisconnectListener != null) { 438 throw new IllegalStateException("already set"); 439 } 440 mOnDisconnectListener = new DisconnectListener(); 441 return mOnDisconnectListener; 442 } 443 setDataSharingEnabled(boolean enabled)444 public void setDataSharingEnabled(boolean enabled) { 445 this.mDataSharingEnabled = enabled; 446 } 447 448 @Override dump(FileDescriptor fd, PrintWriter pw, String[] args)449 protected void dump(FileDescriptor fd, PrintWriter pw, String[] args) { 450 super.dump(fd, pw, args); 451 452 pw.print("sServiceWatcher: "); pw.println(sServiceWatcher); 453 pw.print("sExceptions: "); pw.println(sExceptions); 454 pw.print("sIdCounter: "); pw.println(sIdCounter); 455 pw.print("mId: "); pw.println(mId); 456 pw.print("mConnectedLatch: "); pw.println(mConnectedLatch); 457 pw.print("mDisconnectedLatch: "); pw.println(mDisconnectedLatch); 458 pw.print("mAllSessions: "); pw.println(mAllSessions); 459 pw.print("mOpenSessions: "); pw.println(mOpenSessions); 460 pw.print("mFinishedSessions: "); pw.println(mFinishedSessions); 461 pw.print("mUnfinishedSessionLatches: "); pw.println(mUnfinishedSessionLatches); 462 pw.print("mLifecycleEventsCounter: "); pw.println(mLifecycleEventsCounter); 463 pw.print("mActivityEventsCounter: "); pw.println(mActivityEventsCounter); 464 pw.print("mActivityLifecycleEvents: "); pw.println(mActivityEvents); 465 pw.print("mIgnoreOrphanSessionEvents: "); pw.println(mIgnoreOrphanSessionEvents); 466 } 467 468 @NonNull getUnfinishedSessionLatch(final ContentCaptureSessionId sessionId)469 private CountDownLatch getUnfinishedSessionLatch(final ContentCaptureSessionId sessionId) { 470 final CountDownLatch latch = mUnfinishedSessionLatches.get(sessionId); 471 if (latch == null) { 472 throwIllegalSessionStateException("no latch for %s", sessionId); 473 } 474 return latch; 475 } 476 477 /** 478 * Gets the exceptions that were thrown while the service handlded requests. 479 */ getExceptions()480 public static List<Throwable> getExceptions() throws Exception { 481 return Collections.unmodifiableList(sExceptions); 482 } 483 throwIllegalSessionStateException(@onNull String fmt, @Nullable Object...args)484 private void throwIllegalSessionStateException(@NonNull String fmt, @Nullable Object...args) { 485 throw new IllegalStateException(String.format(fmt, args) 486 + ".\nID=" + mId 487 + ".\nAll=" + mAllSessions 488 + ".\nOpen=" + mOpenSessions 489 + ".\nLatches=" + mUnfinishedSessionLatches 490 + ".\nFinished=" + mFinishedSessions 491 + ".\nLifecycles=" + mActivityEvents 492 + ".\nIgnoringOrphan=" + mIgnoreOrphanSessionEvents); 493 } 494 getExistingSession(@onNull ContentCaptureSessionId sessionId)495 private Session getExistingSession(@NonNull ContentCaptureSessionId sessionId) { 496 final Session session = mOpenSessions.get(sessionId); 497 if (session == null) { 498 throwIllegalSessionStateException("No open session with id %s", sessionId); 499 } 500 if (session.finished) { 501 throw new IllegalStateException("session already finished: " + session); 502 } 503 504 return session; 505 } 506 safeRun(@onNull Runnable r)507 private void safeRun(@NonNull Runnable r) { 508 try { 509 r.run(); 510 } catch (Throwable t) { 511 Log.e(TAG, "Exception handling service callback: " + t); 512 sExceptions.add(t); 513 } 514 } 515 addException(@onNull String fmt, @Nullable Object...args)516 private static void addException(@NonNull String fmt, @Nullable Object...args) { 517 final String msg = String.format(fmt, args); 518 Log.e(TAG, msg); 519 sExceptions.add(new IllegalStateException(msg)); 520 } 521 getEventText(ContentCaptureEvent event)522 private static @Nullable String getEventText(ContentCaptureEvent event) { 523 CharSequence eventText = event.getText(); 524 if (eventText != null) { 525 return eventText.toString(); 526 } 527 528 ViewNode viewNode = event.getViewNode(); 529 if (viewNode != null) { 530 eventText = viewNode.getText(); 531 532 if (eventText != null) { 533 return eventText.toString(); 534 } 535 } 536 537 return null; 538 } 539 540 public final class Session { 541 public final ContentCaptureSessionId id; 542 public final ContentCaptureContext context; 543 public final int creationOrder; 544 private final List<ContentCaptureEvent> mEvents = new ArrayList<>(); 545 public boolean finished; 546 public int destructionOrder; 547 Session(ContentCaptureSessionId id, ContentCaptureContext context)548 private Session(ContentCaptureSessionId id, ContentCaptureContext context) { 549 this.id = id; 550 this.context = context; 551 creationOrder = ++mLifecycleEventsCounter; 552 Log.d(TAG, "create(" + id + "): order=" + creationOrder); 553 } 554 finish()555 private void finish() { 556 finished = true; 557 destructionOrder = ++mLifecycleEventsCounter; 558 Log.d(TAG, "finish(" + id + "): order=" + destructionOrder); 559 } 560 561 // TODO(b/123540602): currently we're only interested on all events, but eventually we 562 // should track individual requests as well to make sure they're probably batch (it will 563 // require adding a Settings to tune the buffer parameters. 564 // TODO: remove filtering of TYPE_WINDOW_BOUNDS_CHANGED events. getEvents()565 public List<ContentCaptureEvent> getEvents() { 566 return Collections.unmodifiableList(mEvents).stream().filter( 567 e -> e.getType() != ContentCaptureEvent.TYPE_WINDOW_BOUNDS_CHANGED 568 ).collect(Collectors.toList()); 569 } 570 getUnfilteredEvents()571 public List<ContentCaptureEvent> getUnfilteredEvents() { 572 return Collections.unmodifiableList(mEvents); 573 } 574 575 @Override toString()576 public String toString() { 577 return "[id=" + id + ", context=" + context + ", events=" + mEvents.size() 578 + ", finished=" + finished + "]"; 579 } 580 } 581 582 private final class MyActivityEvent { 583 public final int order; 584 public final ActivityEvent event; 585 MyActivityEvent(ActivityEvent event)586 private MyActivityEvent(ActivityEvent event) { 587 order = ++mActivityEventsCounter; 588 this.event = event; 589 } 590 591 @Override toString()592 public String toString() { 593 return order + "-" + event; 594 } 595 } 596 597 public static final class ServiceWatcher { 598 599 private final CountDownLatch mCreated = new CountDownLatch(1); 600 private final CountDownLatch mDestroyed = new CountDownLatch(1); 601 private boolean mReadyToClear = true; 602 private Pair<Set<String>, Set<ComponentName>> mWhitelist; 603 604 private CtsContentCaptureService mService; 605 606 @NonNull waitOnCreate()607 public CtsContentCaptureService waitOnCreate() throws InterruptedException { 608 await(mCreated, "not created"); 609 610 if (mService == null) { 611 throw new IllegalStateException("not created"); 612 } 613 614 if (mWhitelist != null) { 615 Log.d(TAG, "Whitelisting after created: " + mWhitelist); 616 mService.setContentCaptureWhitelist(mWhitelist.first, mWhitelist.second); 617 } 618 619 return mService; 620 } 621 waitOnDestroy()622 public void waitOnDestroy() throws InterruptedException { 623 await(mDestroyed, "not destroyed"); 624 } 625 626 /** 627 * Whitelists stuff when the service connects. 628 */ whitelist(@ullable Pair<Set<String>, Set<ComponentName>> whitelist)629 public void whitelist(@Nullable Pair<Set<String>, Set<ComponentName>> whitelist) { 630 mWhitelist = whitelist; 631 } 632 633 /** 634 * Whitelists just this package. 635 */ whitelistSelf()636 public void whitelistSelf() { 637 final ArraySet<String> pkgs = new ArraySet<>(1); 638 pkgs.add(MY_PACKAGE); 639 whitelist(new Pair<>(pkgs, null)); 640 } 641 642 @Override toString()643 public String toString() { 644 return "mService: " + mService + " created: " + (mCreated.getCount() == 0) 645 + " destroyed: " + (mDestroyed.getCount() == 0) 646 + " whitelist: " + mWhitelist; 647 } 648 } 649 650 /** 651 * Listener used to block until the service is disconnected. 652 */ 653 public class DisconnectListener { 654 private final CountDownLatch mLatch = new CountDownLatch(1); 655 656 /** 657 * Wait or die! 658 */ waitForOnDisconnected()659 public void waitForOnDisconnected() { 660 try { 661 await(mLatch, "not disconnected"); 662 } catch (Exception e) { 663 addException("DisconnectListener: onDisconnected() not called: " + e); 664 } 665 } 666 } 667 668 // TODO: make logic below more generic so it can be used for other events (and possibly move 669 // it to another helper class) 670 671 @NonNull assertThat()672 public EventsAssertor assertThat() { 673 return new EventsAssertor(mActivityEvents); 674 } 675 676 public static final class EventsAssertor { 677 private final List<MyActivityEvent> mEvents; 678 private int mNextEvent = 0; 679 EventsAssertor(ArrayList<MyActivityEvent> events)680 private EventsAssertor(ArrayList<MyActivityEvent> events) { 681 mEvents = Collections.unmodifiableList(events); 682 Log.v(TAG, "EventsAssertor: " + mEvents); 683 } 684 685 @NonNull activityResumed(@onNull ComponentName expectedActivity)686 public EventsAssertor activityResumed(@NonNull ComponentName expectedActivity) { 687 assertNextEvent((event) -> assertActivityEvent(event, expectedActivity, 688 ActivityEvent.TYPE_ACTIVITY_RESUMED), "no ACTIVITY_RESUMED event for %s", 689 expectedActivity); 690 return this; 691 } 692 693 @NonNull activityPaused(@onNull ComponentName expectedActivity)694 public EventsAssertor activityPaused(@NonNull ComponentName expectedActivity) { 695 assertNextEvent((event) -> assertActivityEvent(event, expectedActivity, 696 ActivityEvent.TYPE_ACTIVITY_PAUSED), "no ACTIVITY_PAUSED event for %s", 697 expectedActivity); 698 return this; 699 } 700 701 @NonNull activityStopped(@onNull ComponentName expectedActivity)702 public EventsAssertor activityStopped(@NonNull ComponentName expectedActivity) { 703 assertNextEvent((event) -> assertActivityEvent(event, expectedActivity, 704 ActivityEvent.TYPE_ACTIVITY_STOPPED), "no ACTIVITY_STOPPED event for %s", 705 expectedActivity); 706 return this; 707 } 708 709 @NonNull activityDestroyed(@onNull ComponentName expectedActivity)710 public EventsAssertor activityDestroyed(@NonNull ComponentName expectedActivity) { 711 assertNextEvent((event) -> assertActivityEvent(event, expectedActivity, 712 ActivityEvent.TYPE_ACTIVITY_DESTROYED), "no ACTIVITY_DESTROYED event for %s", 713 expectedActivity); 714 return this; 715 } 716 assertNextEvent(@onNull EventAssertion assertion, @NonNull String errorFormat, @Nullable Object... errorArgs)717 private void assertNextEvent(@NonNull EventAssertion assertion, @NonNull String errorFormat, 718 @Nullable Object... errorArgs) { 719 if (mNextEvent >= mEvents.size()) { 720 throw new AssertionError("Reached the end of the events: " 721 + String.format(errorFormat, errorArgs) + "\n. Events(" 722 + mEvents.size() + "): " + mEvents); 723 } 724 do { 725 final int index = mNextEvent++; 726 final MyActivityEvent event = mEvents.get(index); 727 final String error = assertion.getErrorMessage(event); 728 if (error == null) return; 729 Log.w(TAG, "assertNextEvent(): ignoring event #" + index + "(" + event + "): " 730 + error); 731 } while (mNextEvent < mEvents.size()); 732 throw new AssertionError(String.format(errorFormat, errorArgs) + "\n. Events(" 733 + mEvents.size() + "): " + mEvents); 734 } 735 } 736 737 @Nullable assertActivityEvent(@onNull MyActivityEvent myEvent, @NonNull ComponentName expectedActivity, int expectedType)738 public static String assertActivityEvent(@NonNull MyActivityEvent myEvent, 739 @NonNull ComponentName expectedActivity, int expectedType) { 740 if (myEvent == null) { 741 return "myEvent is null"; 742 } 743 final ActivityEvent event = myEvent.event; 744 if (event == null) { 745 return "event is null"; 746 } 747 final int actualType = event.getEventType(); 748 if (actualType != expectedType) { 749 return String.format("wrong event type for %s: expected %s, got %s", event, 750 expectedType, actualType); 751 } 752 final ComponentName actualActivity = event.getComponentName(); 753 if (!expectedActivity.equals(actualActivity)) { 754 return String.format("wrong activity for %s: expected %s, got %s", event, 755 expectedActivity, actualActivity); 756 } 757 return null; 758 } 759 760 private interface EventAssertion { 761 @Nullable getErrorMessage(@onNull MyActivityEvent event)762 String getErrorMessage(@NonNull MyActivityEvent event); 763 } 764 } 765