blob: 76850535a641ba305161c2f5c63c262e1f76806f [file] [log] [blame]
/*
* Copyright (C) 2018 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package android.contentcaptureservice.cts;
import static android.contentcaptureservice.cts.Helper.MY_PACKAGE;
import static android.contentcaptureservice.cts.Helper.await;
import static android.contentcaptureservice.cts.Helper.componentNameFor;
import static com.google.common.truth.Truth.assertWithMessage;
import android.content.ComponentName;
import android.os.Parcel;
import android.os.ParcelFileDescriptor;
import android.service.contentcapture.ActivityEvent;
import android.service.contentcapture.ContentCaptureService;
import android.service.contentcapture.DataShareCallback;
import android.service.contentcapture.DataShareReadAdapter;
import android.util.ArrayMap;
import android.util.ArraySet;
import android.util.Log;
import android.util.Pair;
import android.view.contentcapture.ContentCaptureContext;
import android.view.contentcapture.ContentCaptureEvent;
import android.view.contentcapture.ContentCaptureSessionId;
import android.view.contentcapture.DataRemovalRequest;
import android.view.contentcapture.DataShareRequest;
import android.view.contentcapture.ViewNode;
import androidx.annotation.GuardedBy;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import java.io.FileDescriptor;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintWriter;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.stream.Collectors;
// TODO(b/123540602): if we don't move this service to a separate package, we need to handle the
// onXXXX methods in a separate thread
// Either way, we need to make sure its methods are thread safe
public class CtsContentCaptureService extends ContentCaptureService {
private static final String TAG = CtsContentCaptureService.class.getSimpleName();
public static final String SERVICE_NAME = MY_PACKAGE + "/."
+ CtsContentCaptureService.class.getSimpleName();
public static final ComponentName CONTENT_CAPTURE_SERVICE_COMPONENT_NAME =
componentNameFor(CtsContentCaptureService.class);
private static final Executor sExecutor = Executors.newCachedThreadPool();
private static int sIdCounter;
private static Object sLock = new Object();
@GuardedBy("sLock")
private static ServiceWatcher sServiceWatcher;
private final int mId = ++sIdCounter;
private static final ArrayList<Throwable> sExceptions = new ArrayList<>();
private final CountDownLatch mConnectedLatch = new CountDownLatch(1);
private final CountDownLatch mDisconnectedLatch = new CountDownLatch(1);
/**
* List of all sessions started - never reset.
*/
private final ArrayList<ContentCaptureSessionId> mAllSessions = new ArrayList<>();
/**
* Map of all sessions started but not finished yet - sessions are removed as they're finished.
*/
private final ArrayMap<ContentCaptureSessionId, Session> mOpenSessions = new ArrayMap<>();
/**
* Map of all sessions finished.
*/
private final ArrayMap<ContentCaptureSessionId, Session> mFinishedSessions = new ArrayMap<>();
/**
* Map of latches for sessions that started but haven't finished yet.
*/
private final ArrayMap<ContentCaptureSessionId, CountDownLatch> mUnfinishedSessionLatches =
new ArrayMap<>();
/**
* Counter of onCreate() / onDestroy() events.
*/
private int mLifecycleEventsCounter;
/**
* Counter of received {@link ActivityEvent} events.
*/
private int mActivityEventsCounter;
// NOTE: we could use the same counter for mLifecycleEventsCounter and mActivityEventsCounter,
// but that would make the tests flaker.
/**
* Used for testing onUserDataRemovalRequest.
*/
private DataRemovalRequest mRemovalRequest;
/**
* List of activity lifecycle events received.
*/
private final ArrayList<MyActivityEvent> mActivityEvents = new ArrayList<>();
/**
* Optional listener for {@code onDisconnect()}.
*/
@Nullable
private DisconnectListener mOnDisconnectListener;
/**
* When set, doesn't throw exceptions when it receives an event from a session that doesn't
* exist.
*/
private boolean mIgnoreOrphanSessionEvents;
/**
* Whether the service should accept a data share session.
*/
private boolean mDataSharingEnabled = false;
/**
* Bytes that were shared during the content capture
*/
byte[] mDataShared = new byte[20_000];
/**
* The fields below represent state of the content capture data sharing session.
*/
boolean mDataShareSessionStarted = false;
boolean mDataShareSessionFinished = false;
boolean mDataShareSessionSucceeded = false;
int mDataShareSessionErrorCode = 0;
DataShareRequest mDataShareRequest;
@NonNull
public static ServiceWatcher setServiceWatcher() {
synchronized (sLock) {
if (sServiceWatcher != null) {
throw new IllegalStateException("There Can Be Only One!");
}
sServiceWatcher = new ServiceWatcher();
return sServiceWatcher;
}
}
public static void resetStaticState() {
sExceptions.clear();
// TODO(b/123540602): should probably set sInstance to null as well, but first we would need
// to make sure each test unbinds the service.
// TODO(b/123540602): each test should use a different service instance, but we need
// to provide onConnected() / onDisconnected() methods first and then change the infra so
// we can wait for those
synchronized (sLock) {
if (sServiceWatcher != null) {
Log.wtf(TAG, "resetStaticState(): should not have sServiceWatcher");
sServiceWatcher = null;
}
}
}
private static ServiceWatcher getServiceWatcher() {
synchronized (sLock) {
return sServiceWatcher;
}
}
public static void clearServiceWatcher() {
synchronized (sLock) {
if (sServiceWatcher != null) {
if (sServiceWatcher.mReadyToClear) {
sServiceWatcher.mService = null;
sServiceWatcher = null;
} else {
sServiceWatcher.mReadyToClear = true;
}
}
}
}
/**
* When set, doesn't throw exceptions when it receives an event from a session that doesn't
* exist.
*/
// TODO: try to refactor WhitelistTest so it doesn't need this hack.
public void setIgnoreOrphanSessionEvents(boolean newValue) {
Log.d(TAG, "setIgnoreOrphanSessionEvents(): changing from " + mIgnoreOrphanSessionEvents
+ " to " + newValue);
mIgnoreOrphanSessionEvents = newValue;
}
@Override
public void onConnected() {
final ServiceWatcher sw = getServiceWatcher();
Log.i(TAG, "onConnected(id=" + mId + "): sServiceWatcher=" + sw);
if (sw == null) {
addException("onConnected() without a watcher");
return;
}
if (!sw.mReadyToClear && sw.mService != null) {
addException("onConnected(): already created: %s", sw);
return;
}
sw.mService = this;
sw.mCreated.countDown();
sw.mReadyToClear = false;
if (mConnectedLatch.getCount() == 0) {
addException("already connected: %s", mConnectedLatch);
}
mConnectedLatch.countDown();
}
@Override
public void onDisconnected() {
final ServiceWatcher sw = getServiceWatcher();
Log.i(TAG, "onDisconnected(id=" + mId + "): sServiceWatcher=" + sw);
if (mDisconnectedLatch.getCount() == 0) {
addException("already disconnected: %s", mConnectedLatch);
}
mDisconnectedLatch.countDown();
if (sw == null) {
addException("onDisconnected() without a watcher");
return;
}
if (sw.mService == null) {
addException("onDisconnected(): no service on %s", sw);
return;
}
// Notify test case as well
if (mOnDisconnectListener != null) {
final CountDownLatch latch = mOnDisconnectListener.mLatch;
mOnDisconnectListener = null;
latch.countDown();
}
sw.mDestroyed.countDown();
clearServiceWatcher();
}
/**
* Waits until the system calls {@link #onConnected()}.
*/
public void waitUntilConnected() throws InterruptedException {
await(mConnectedLatch, "not connected");
}
/**
* Waits until the system calls {@link #onDisconnected()}.
*/
public void waitUntilDisconnected() throws InterruptedException {
await(mDisconnectedLatch, "not disconnected");
}
@Override
public void onCreateContentCaptureSession(ContentCaptureContext context,
ContentCaptureSessionId sessionId) {
Log.i(TAG, "onCreateContentCaptureSession(id=" + mId + ", ignoreOrpahn="
+ mIgnoreOrphanSessionEvents + ", ctx=" + context + ", session=" + sessionId);
if (mIgnoreOrphanSessionEvents) return;
mAllSessions.add(sessionId);
safeRun(() -> {
final Session session = mOpenSessions.get(sessionId);
if (session != null) {
throw new IllegalStateException("Already contains session for " + sessionId
+ ": " + session);
}
mUnfinishedSessionLatches.put(sessionId, new CountDownLatch(1));
mOpenSessions.put(sessionId, new Session(sessionId, context));
});
}
@Override
public void onDestroyContentCaptureSession(ContentCaptureSessionId sessionId) {
Log.i(TAG, "onDestroyContentCaptureSession(id=" + mId + ", ignoreOrpahn="
+ mIgnoreOrphanSessionEvents + ", session=" + sessionId + ")");
if (mIgnoreOrphanSessionEvents) return;
safeRun(() -> {
final Session session = getExistingSession(sessionId);
session.finish();
mOpenSessions.remove(sessionId);
if (mFinishedSessions.containsKey(sessionId)) {
throw new IllegalStateException("Already destroyed " + sessionId);
} else {
mFinishedSessions.put(sessionId, session);
final CountDownLatch latch = getUnfinishedSessionLatch(sessionId);
latch.countDown();
}
});
}
@Override
public void onContentCaptureEvent(ContentCaptureSessionId sessionId,
ContentCaptureEvent originalEvent) {
// Parcel and unparcel the event to test the parceling logic and trigger the restoration
// of Composing/Selection spans.
// TODO: Use a service in another process to make the tests more realistic.
Parcel parceled = Parcel.obtain();
parceled.setDataPosition(0);
originalEvent.writeToParcel(parceled, 0);
parceled.setDataPosition(0);
final ContentCaptureEvent event = ContentCaptureEvent.CREATOR.createFromParcel(parceled);
parceled.recycle();
Log.i(TAG, "onContentCaptureEventsRequest(id=" + mId + ", ignoreOrpahn="
+ mIgnoreOrphanSessionEvents + ", session=" + sessionId + "): " + event + " text: "
+ getEventText(event));
if (mIgnoreOrphanSessionEvents) return;
final ViewNode node = event.getViewNode();
if (node != null) {
Log.v(TAG, "onContentCaptureEvent(): parentId=" + node.getParentAutofillId());
}
safeRun(() -> {
final Session session = getExistingSession(sessionId);
session.mEvents.add(event);
});
}
@Override
public void onDataRemovalRequest(DataRemovalRequest request) {
Log.i(TAG, "onUserDataRemovalRequest(id=" + mId + ",req=" + request + ")");
mRemovalRequest = request;
}
@Override
public void onDataShareRequest(DataShareRequest request, DataShareCallback callback) {
if (mDataSharingEnabled) {
mDataShareRequest = request;
callback.onAccept(sExecutor, new DataShareReadAdapter() {
@Override
public void onStart(ParcelFileDescriptor fd) {
mDataShareSessionStarted = true;
int bytesReadTotal = 0;
try (InputStream fis = new ParcelFileDescriptor.AutoCloseInputStream(fd)) {
while (true) {
int bytesRead = fis.read(mDataShared, bytesReadTotal,
mDataShared.length - bytesReadTotal);
if (bytesRead == -1) {
break;
}
bytesReadTotal += bytesRead;
}
mDataShareSessionFinished = true;
mDataShareSessionSucceeded = true;
} catch (IOException e) {
// fall through. dataShareSessionSucceeded will stay false.
}
}
@Override
public void onError(int errorCode) {
mDataShareSessionFinished = true;
mDataShareSessionErrorCode = errorCode;
}
});
} else {
callback.onReject();
mDataShareSessionStarted = mDataShareSessionFinished = true;
}
}
@Override
public void onActivityEvent(ActivityEvent event) {
Log.i(TAG, "onActivityEvent(): " + event);
mActivityEvents.add(new MyActivityEvent(event));
}
/**
* Gets the cached UserDataRemovalRequest for testing.
*/
public DataRemovalRequest getRemovalRequest() {
return mRemovalRequest;
}
/**
* Gets the finished session for the given session id.
*
* @throws IllegalStateException if the session didn't finish yet.
*/
@NonNull
public Session getFinishedSession(@NonNull ContentCaptureSessionId sessionId)
throws InterruptedException {
final CountDownLatch latch = getUnfinishedSessionLatch(sessionId);
await(latch, "session %s not finished yet", sessionId);
final Session session = mFinishedSessions.get(sessionId);
if (session == null) {
throwIllegalSessionStateException("No finished session for id %s", sessionId);
}
return session;
}
/**
* Gets the finished session when only one session is expected.
*
* <p>Should be used when the test case doesn't known in advance the id of the session.
*/
@NonNull
public Session getOnlyFinishedSession() throws InterruptedException {
final ArrayList<ContentCaptureSessionId> allSessions = mAllSessions;
assertWithMessage("Wrong number of sessions").that(allSessions).hasSize(1);
final ContentCaptureSessionId id = allSessions.get(0);
Log.d(TAG, "getOnlyFinishedSession(): id=" + id);
return getFinishedSession(id);
}
/**
* Gets all sessions that have been created so far.
*/
@NonNull
public List<ContentCaptureSessionId> getAllSessionIds() {
return Collections.unmodifiableList(mAllSessions);
}
/**
* Sets a listener to wait until the service disconnects.
*/
@NonNull
public DisconnectListener setOnDisconnectListener() {
if (mOnDisconnectListener != null) {
throw new IllegalStateException("already set");
}
mOnDisconnectListener = new DisconnectListener();
return mOnDisconnectListener;
}
public void setDataSharingEnabled(boolean enabled) {
this.mDataSharingEnabled = enabled;
}
@Override
protected void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
super.dump(fd, pw, args);
pw.print("sServiceWatcher: "); pw.println(getServiceWatcher());
pw.print("sExceptions: "); pw.println(sExceptions);
pw.print("sIdCounter: "); pw.println(sIdCounter);
pw.print("mId: "); pw.println(mId);
pw.print("mConnectedLatch: "); pw.println(mConnectedLatch);
pw.print("mDisconnectedLatch: "); pw.println(mDisconnectedLatch);
pw.print("mAllSessions: "); pw.println(mAllSessions);
pw.print("mOpenSessions: "); pw.println(mOpenSessions);
pw.print("mFinishedSessions: "); pw.println(mFinishedSessions);
pw.print("mUnfinishedSessionLatches: "); pw.println(mUnfinishedSessionLatches);
pw.print("mLifecycleEventsCounter: "); pw.println(mLifecycleEventsCounter);
pw.print("mActivityEventsCounter: "); pw.println(mActivityEventsCounter);
pw.print("mActivityLifecycleEvents: "); pw.println(mActivityEvents);
pw.print("mIgnoreOrphanSessionEvents: "); pw.println(mIgnoreOrphanSessionEvents);
}
@NonNull
private CountDownLatch getUnfinishedSessionLatch(final ContentCaptureSessionId sessionId) {
final CountDownLatch latch = mUnfinishedSessionLatches.get(sessionId);
if (latch == null) {
throwIllegalSessionStateException("no latch for %s", sessionId);
}
return latch;
}
/**
* Gets the exceptions that were thrown while the service handlded requests.
*/
public static List<Throwable> getExceptions() throws Exception {
return Collections.unmodifiableList(sExceptions);
}
private void throwIllegalSessionStateException(@NonNull String fmt, @Nullable Object...args) {
throw new IllegalStateException(String.format(fmt, args)
+ ".\nID=" + mId
+ ".\nAll=" + mAllSessions
+ ".\nOpen=" + mOpenSessions
+ ".\nLatches=" + mUnfinishedSessionLatches
+ ".\nFinished=" + mFinishedSessions
+ ".\nLifecycles=" + mActivityEvents
+ ".\nIgnoringOrphan=" + mIgnoreOrphanSessionEvents);
}
private Session getExistingSession(@NonNull ContentCaptureSessionId sessionId) {
final Session session = mOpenSessions.get(sessionId);
if (session == null) {
throwIllegalSessionStateException("No open session with id %s", sessionId);
}
if (session.finished) {
throw new IllegalStateException("session already finished: " + session);
}
return session;
}
private void safeRun(@NonNull Runnable r) {
try {
r.run();
} catch (Throwable t) {
Log.e(TAG, "Exception handling service callback: " + t);
sExceptions.add(t);
}
}
private static void addException(@NonNull String fmt, @Nullable Object...args) {
final String msg = String.format(fmt, args);
Log.e(TAG, msg);
sExceptions.add(new IllegalStateException(msg));
}
private static @Nullable String getEventText(ContentCaptureEvent event) {
CharSequence eventText = event.getText();
if (eventText != null) {
return eventText.toString();
}
ViewNode viewNode = event.getViewNode();
if (viewNode != null) {
eventText = viewNode.getText();
if (eventText != null) {
return eventText.toString();
}
}
return null;
}
public final class Session {
public final ContentCaptureSessionId id;
public final ContentCaptureContext context;
public final int creationOrder;
private final List<ContentCaptureEvent> mEvents = new ArrayList<>();
public boolean finished;
public int destructionOrder;
private Session(ContentCaptureSessionId id, ContentCaptureContext context) {
this.id = id;
this.context = context;
creationOrder = ++mLifecycleEventsCounter;
Log.d(TAG, "create(" + id + "): order=" + creationOrder);
}
private void finish() {
finished = true;
destructionOrder = ++mLifecycleEventsCounter;
Log.d(TAG, "finish(" + id + "): order=" + destructionOrder);
}
// TODO(b/123540602): currently we're only interested on all events, but eventually we
// should track individual requests as well to make sure they're probably batch (it will
// require adding a Settings to tune the buffer parameters.
// TODO: remove filtering of TYPE_WINDOW_BOUNDS_CHANGED events.
public List<ContentCaptureEvent> getEvents() {
return Collections.unmodifiableList(mEvents).stream().filter(
e -> e.getType() != ContentCaptureEvent.TYPE_WINDOW_BOUNDS_CHANGED
).collect(Collectors.toList());
}
public List<ContentCaptureEvent> getUnfilteredEvents() {
return Collections.unmodifiableList(mEvents);
}
@Override
public String toString() {
return "[id=" + id + ", context=" + context + ", events=" + mEvents.size()
+ ", finished=" + finished + "]";
}
}
private final class MyActivityEvent {
public final int order;
public final ActivityEvent event;
private MyActivityEvent(ActivityEvent event) {
order = ++mActivityEventsCounter;
this.event = event;
}
@Override
public String toString() {
return order + "-" + event;
}
}
public static final class ServiceWatcher {
private final CountDownLatch mCreated = new CountDownLatch(1);
private final CountDownLatch mDestroyed = new CountDownLatch(1);
private boolean mReadyToClear = true;
private Pair<Set<String>, Set<ComponentName>> mWhitelist;
private CtsContentCaptureService mService;
@NonNull
public CtsContentCaptureService waitOnCreate() throws InterruptedException {
await(mCreated, "not created");
if (mService == null) {
throw new IllegalStateException("not created");
}
if (mWhitelist != null) {
Log.d(TAG, "Whitelisting after created: " + mWhitelist);
mService.setContentCaptureWhitelist(mWhitelist.first, mWhitelist.second);
}
return mService;
}
public void waitOnDestroy() throws InterruptedException {
await(mDestroyed, "not destroyed");
}
/**
* Whitelists stuff when the service connects.
*/
public void whitelist(@Nullable Pair<Set<String>, Set<ComponentName>> whitelist) {
mWhitelist = whitelist;
}
/**
* Whitelists just this package.
*/
public void whitelistSelf() {
final ArraySet<String> pkgs = new ArraySet<>(1);
pkgs.add(MY_PACKAGE);
whitelist(new Pair<>(pkgs, null));
}
@Override
public String toString() {
return "mService: " + mService + " created: " + (mCreated.getCount() == 0)
+ " destroyed: " + (mDestroyed.getCount() == 0)
+ " whitelist: " + mWhitelist;
}
}
/**
* Listener used to block until the service is disconnected.
*/
public class DisconnectListener {
private final CountDownLatch mLatch = new CountDownLatch(1);
/**
* Wait or die!
*/
public void waitForOnDisconnected() {
try {
await(mLatch, "not disconnected");
} catch (Exception e) {
addException("DisconnectListener: onDisconnected() not called: " + e);
}
}
}
// TODO: make logic below more generic so it can be used for other events (and possibly move
// it to another helper class)
@NonNull
public EventsAssertor assertThat() {
return new EventsAssertor(mActivityEvents);
}
public static final class EventsAssertor {
private final List<MyActivityEvent> mEvents;
private int mNextEvent = 0;
private EventsAssertor(ArrayList<MyActivityEvent> events) {
mEvents = Collections.unmodifiableList(events);
Log.v(TAG, "EventsAssertor: " + mEvents);
}
@NonNull
public EventsAssertor activityResumed(@NonNull ComponentName expectedActivity) {
assertNextEvent((event) -> assertActivityEvent(event, expectedActivity,
ActivityEvent.TYPE_ACTIVITY_RESUMED), "no ACTIVITY_RESUMED event for %s",
expectedActivity);
return this;
}
@NonNull
public EventsAssertor activityPaused(@NonNull ComponentName expectedActivity) {
assertNextEvent((event) -> assertActivityEvent(event, expectedActivity,
ActivityEvent.TYPE_ACTIVITY_PAUSED), "no ACTIVITY_PAUSED event for %s",
expectedActivity);
return this;
}
@NonNull
public EventsAssertor activityStopped(@NonNull ComponentName expectedActivity) {
assertNextEvent((event) -> assertActivityEvent(event, expectedActivity,
ActivityEvent.TYPE_ACTIVITY_STOPPED), "no ACTIVITY_STOPPED event for %s",
expectedActivity);
return this;
}
@NonNull
public EventsAssertor activityDestroyed(@NonNull ComponentName expectedActivity) {
assertNextEvent((event) -> assertActivityEvent(event, expectedActivity,
ActivityEvent.TYPE_ACTIVITY_DESTROYED), "no ACTIVITY_DESTROYED event for %s",
expectedActivity);
return this;
}
private void assertNextEvent(@NonNull EventAssertion assertion, @NonNull String errorFormat,
@Nullable Object... errorArgs) {
if (mNextEvent >= mEvents.size()) {
throw new AssertionError("Reached the end of the events: "
+ String.format(errorFormat, errorArgs) + "\n. Events("
+ mEvents.size() + "): " + mEvents);
}
do {
final int index = mNextEvent++;
final MyActivityEvent event = mEvents.get(index);
final String error = assertion.getErrorMessage(event);
if (error == null) return;
Log.w(TAG, "assertNextEvent(): ignoring event #" + index + "(" + event + "): "
+ error);
} while (mNextEvent < mEvents.size());
throw new AssertionError(String.format(errorFormat, errorArgs) + "\n. Events("
+ mEvents.size() + "): " + mEvents);
}
}
@Nullable
public static String assertActivityEvent(@NonNull MyActivityEvent myEvent,
@NonNull ComponentName expectedActivity, int expectedType) {
if (myEvent == null) {
return "myEvent is null";
}
final ActivityEvent event = myEvent.event;
if (event == null) {
return "event is null";
}
final int actualType = event.getEventType();
if (actualType != expectedType) {
return String.format("wrong event type for %s: expected %s, got %s", event,
expectedType, actualType);
}
final ComponentName actualActivity = event.getComponentName();
if (!expectedActivity.equals(actualActivity)) {
return String.format("wrong activity for %s: expected %s, got %s", event,
expectedActivity, actualActivity);
}
return null;
}
private interface EventAssertion {
@Nullable
String getErrorMessage(@NonNull MyActivityEvent event);
}
}