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