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