| /* |
| * 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.view.contentcapture; |
| |
| import static android.view.contentcapture.ContentCaptureEvent.TYPE_CONTEXT_UPDATED; |
| import static android.view.contentcapture.ContentCaptureEvent.TYPE_SESSION_FINISHED; |
| import static android.view.contentcapture.ContentCaptureEvent.TYPE_SESSION_PAUSED; |
| import static android.view.contentcapture.ContentCaptureEvent.TYPE_SESSION_RESUMED; |
| import static android.view.contentcapture.ContentCaptureEvent.TYPE_SESSION_STARTED; |
| import static android.view.contentcapture.ContentCaptureEvent.TYPE_VIEW_APPEARED; |
| import static android.view.contentcapture.ContentCaptureEvent.TYPE_VIEW_DISAPPEARED; |
| import static android.view.contentcapture.ContentCaptureEvent.TYPE_VIEW_INSETS_CHANGED; |
| import static android.view.contentcapture.ContentCaptureEvent.TYPE_VIEW_TEXT_CHANGED; |
| import static android.view.contentcapture.ContentCaptureEvent.TYPE_VIEW_TREE_APPEARED; |
| import static android.view.contentcapture.ContentCaptureEvent.TYPE_VIEW_TREE_APPEARING; |
| import static android.view.contentcapture.ContentCaptureEvent.TYPE_WINDOW_BOUNDS_CHANGED; |
| import static android.view.contentcapture.ContentCaptureHelper.getSanitizedString; |
| import static android.view.contentcapture.ContentCaptureHelper.sDebug; |
| import static android.view.contentcapture.ContentCaptureHelper.sVerbose; |
| import static android.view.contentcapture.ContentCaptureManager.RESULT_CODE_FALSE; |
| |
| import android.annotation.NonNull; |
| import android.annotation.Nullable; |
| import android.content.ComponentName; |
| import android.content.pm.ParceledListSlice; |
| import android.graphics.Insets; |
| import android.graphics.Rect; |
| import android.os.Bundle; |
| import android.os.Handler; |
| import android.os.IBinder; |
| import android.os.IBinder.DeathRecipient; |
| import android.os.RemoteException; |
| import android.os.Trace; |
| import android.service.contentcapture.ContentCaptureService; |
| import android.text.Selection; |
| import android.text.Spannable; |
| import android.text.TextUtils; |
| import android.util.LocalLog; |
| import android.util.Log; |
| import android.util.SparseArray; |
| import android.util.TimeUtils; |
| import android.view.View; |
| import android.view.ViewStructure; |
| import android.view.autofill.AutofillId; |
| import android.view.contentcapture.ViewNode.ViewStructureImpl; |
| import android.view.contentprotection.ContentProtectionEventProcessor; |
| import android.view.inputmethod.BaseInputConnection; |
| |
| import com.android.internal.annotations.VisibleForTesting; |
| import com.android.internal.os.IResultReceiver; |
| import com.android.modules.expresslog.Counter; |
| |
| import java.io.PrintWriter; |
| import java.lang.ref.WeakReference; |
| import java.util.ArrayList; |
| import java.util.Collections; |
| import java.util.List; |
| import java.util.NoSuchElementException; |
| import java.util.concurrent.ConcurrentLinkedQueue; |
| import java.util.concurrent.atomic.AtomicBoolean; |
| import java.util.concurrent.atomic.AtomicInteger; |
| |
| /** |
| * Main session associated with a context. |
| * |
| * <p>This is forked from {@link MainContentCaptureSession} to hold the logic of running operations |
| * in the background thread.</p> |
| * |
| * @hide |
| */ |
| @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) |
| public final class MainContentCaptureSessionV2 extends ContentCaptureSession { |
| |
| private static final String TAG = MainContentCaptureSession.class.getSimpleName(); |
| |
| private static final String CONTENT_CAPTURE_WRONG_THREAD_METRIC_ID = |
| "content_capture.value_content_capture_wrong_thread_count"; |
| |
| // For readability purposes... |
| private static final boolean FORCE_FLUSH = true; |
| |
| /** |
| * Handler message used to flush the buffer. |
| */ |
| private static final int MSG_FLUSH = 1; |
| |
| @NonNull |
| private final AtomicBoolean mDisabled = new AtomicBoolean(false); |
| |
| @NonNull |
| private final ContentCaptureManager.StrippedContext mContext; |
| |
| @NonNull |
| private final ContentCaptureManager mManager; |
| |
| @NonNull |
| private final Handler mUiHandler; |
| |
| @NonNull |
| private final Handler mContentCaptureHandler; |
| |
| /** |
| * Interface to the system_server binder object - it's only used to start the session (and |
| * notify when the session is finished). |
| */ |
| @NonNull |
| private final IContentCaptureManager mSystemServerInterface; |
| |
| /** |
| * Direct interface to the service binder object - it's used to send the events, including the |
| * last ones (when the session is finished) |
| * |
| * @hide |
| */ |
| @VisibleForTesting(visibility = VisibleForTesting.Visibility.PRIVATE) |
| @Nullable |
| public IContentCaptureDirectManager mDirectServiceInterface; |
| |
| @Nullable |
| private DeathRecipient mDirectServiceVulture; |
| |
| private int mState = UNKNOWN_STATE; |
| |
| @Nullable |
| private IBinder mApplicationToken; |
| @Nullable |
| private IBinder mShareableActivityToken; |
| |
| /** @hide */ |
| @VisibleForTesting(visibility = VisibleForTesting.Visibility.PRIVATE) |
| @Nullable |
| public ComponentName mComponentName; |
| |
| /** |
| * Thread-safe queue of events held to be processed as a batch. |
| * |
| * Because it is not guaranteed that the events will be enqueued from a single thread, the |
| * implementation must be thread-safe to prevent unexpected behaviour. |
| * |
| * @hide |
| */ |
| @VisibleForTesting(visibility = VisibleForTesting.Visibility.PRIVATE) |
| @NonNull |
| public final ConcurrentLinkedQueue<ContentCaptureEvent> mEventProcessQueue; |
| |
| /** |
| * List of events held to be sent to the {@link ContentCaptureService} as a batch. |
| * |
| * @hide |
| */ |
| @VisibleForTesting(visibility = VisibleForTesting.Visibility.PRIVATE) |
| @Nullable |
| public ArrayList<ContentCaptureEvent> mEvents; |
| |
| // Used just for debugging purposes (on dump) |
| private long mNextFlush; |
| |
| /** |
| * Whether the next buffer flush is queued by a text changed event. |
| */ |
| private boolean mNextFlushForTextChanged = false; |
| |
| @Nullable |
| private final LocalLog mFlushHistory; |
| |
| private final AtomicInteger mWrongThreadCount = new AtomicInteger(0); |
| |
| /** |
| * Binder object used to update the session state. |
| */ |
| @NonNull |
| private final SessionStateReceiver mSessionStateReceiver; |
| |
| /** @hide */ |
| @VisibleForTesting(visibility = VisibleForTesting.Visibility.PRIVATE) |
| @Nullable |
| public ContentProtectionEventProcessor mContentProtectionEventProcessor; |
| |
| private static class SessionStateReceiver extends IResultReceiver.Stub { |
| private final WeakReference<MainContentCaptureSessionV2> mMainSession; |
| |
| SessionStateReceiver(MainContentCaptureSessionV2 session) { |
| mMainSession = new WeakReference<>(session); |
| } |
| |
| @Override |
| public void send(int resultCode, Bundle resultData) { |
| final MainContentCaptureSessionV2 mainSession = mMainSession.get(); |
| if (mainSession == null) { |
| Log.w(TAG, "received result after mina session released"); |
| return; |
| } |
| final IBinder binder; |
| if (resultData != null) { |
| // Change in content capture enabled. |
| final boolean hasEnabled = resultData.getBoolean(EXTRA_ENABLED_STATE); |
| if (hasEnabled) { |
| final boolean disabled = (resultCode == RESULT_CODE_FALSE); |
| mainSession.mDisabled.set(disabled); |
| return; |
| } |
| binder = resultData.getBinder(EXTRA_BINDER); |
| if (binder == null) { |
| Log.wtf(TAG, "No " + EXTRA_BINDER + " extra result"); |
| mainSession.runOnContentCaptureThread(() -> mainSession.resetSession( |
| STATE_DISABLED | STATE_INTERNAL_ERROR)); |
| return; |
| } |
| } else { |
| binder = null; |
| } |
| mainSession.runOnContentCaptureThread(() -> |
| mainSession.onSessionStarted(resultCode, binder)); |
| } |
| } |
| |
| /** @hide */ |
| @VisibleForTesting(visibility = VisibleForTesting.Visibility.PROTECTED) |
| public MainContentCaptureSessionV2( |
| @NonNull ContentCaptureManager.StrippedContext context, |
| @NonNull ContentCaptureManager manager, |
| @NonNull Handler uiHandler, |
| @NonNull Handler contentCaptureHandler, |
| @NonNull IContentCaptureManager systemServerInterface) { |
| mContext = context; |
| mManager = manager; |
| mUiHandler = uiHandler; |
| mContentCaptureHandler = contentCaptureHandler; |
| mSystemServerInterface = systemServerInterface; |
| |
| final int logHistorySize = mManager.mOptions.logHistorySize; |
| mFlushHistory = logHistorySize > 0 ? new LocalLog(logHistorySize) : null; |
| |
| mSessionStateReceiver = new SessionStateReceiver(this); |
| |
| mEventProcessQueue = new ConcurrentLinkedQueue<>(); |
| } |
| |
| @Override |
| ContentCaptureSession getMainCaptureSession() { |
| return this; |
| } |
| |
| @Override |
| ContentCaptureSession newChild(@NonNull ContentCaptureContext clientContext) { |
| final ContentCaptureSession child = new ChildContentCaptureSession(this, clientContext); |
| internalNotifyChildSessionStarted(mId, child.mId, clientContext); |
| return child; |
| } |
| |
| /** |
| * Starts this session. |
| */ |
| @Override |
| void start(@NonNull IBinder token, @NonNull IBinder shareableActivityToken, |
| @NonNull ComponentName component, int flags) { |
| runOnContentCaptureThread( |
| () -> startImpl(token, shareableActivityToken, component, flags)); |
| } |
| |
| private void startImpl(@NonNull IBinder token, @NonNull IBinder shareableActivityToken, |
| @NonNull ComponentName component, int flags) { |
| checkOnContentCaptureThread(); |
| if (!isContentCaptureEnabled()) return; |
| |
| if (sVerbose) { |
| Log.v(TAG, "start(): token=" + token + ", comp=" |
| + ComponentName.flattenToShortString(component)); |
| } |
| |
| if (hasStarted()) { |
| // TODO(b/122959591): make sure this is expected (and when), or use Log.w |
| if (sDebug) { |
| Log.d(TAG, "ignoring handleStartSession(" + token + "/" |
| + ComponentName.flattenToShortString(component) + " while on state " |
| + getStateAsString(mState)); |
| } |
| return; |
| } |
| mState = STATE_WAITING_FOR_SERVER; |
| mApplicationToken = token; |
| mShareableActivityToken = shareableActivityToken; |
| mComponentName = component; |
| |
| if (sVerbose) { |
| Log.v(TAG, "handleStartSession(): token=" + token + ", act=" |
| + getDebugState() + ", id=" + mId); |
| } |
| |
| try { |
| mSystemServerInterface.startSession(mApplicationToken, mShareableActivityToken, |
| component, mId, flags, mSessionStateReceiver); |
| } catch (RemoteException e) { |
| Log.w(TAG, "Error starting session for " + component.flattenToShortString() + ": " + e); |
| } |
| } |
| @Override |
| void onDestroy() { |
| clearAndRunOnContentCaptureThread(() -> { |
| try { |
| flush(FLUSH_REASON_SESSION_FINISHED); |
| } finally { |
| destroySession(); |
| } |
| }, MSG_FLUSH); |
| } |
| |
| /** |
| * Callback from {@code system_server} after call to {@link |
| * IContentCaptureManager#startSession(IBinder, ComponentName, String, int, IResultReceiver)}. |
| * |
| * @param resultCode session state |
| * @param binder handle to {@code IContentCaptureDirectManager} |
| * @hide |
| */ |
| @VisibleForTesting(visibility = VisibleForTesting.Visibility.PRIVATE) |
| public void onSessionStarted(int resultCode, @Nullable IBinder binder) { |
| checkOnContentCaptureThread(); |
| if (binder != null) { |
| mDirectServiceInterface = IContentCaptureDirectManager.Stub.asInterface(binder); |
| mDirectServiceVulture = () -> { |
| Log.w(TAG, "Keeping session " + mId + " when service died"); |
| mState = STATE_SERVICE_DIED; |
| mDisabled.set(true); |
| }; |
| try { |
| binder.linkToDeath(mDirectServiceVulture, 0); |
| } catch (RemoteException e) { |
| Log.w(TAG, "Failed to link to death on " + binder + ": " + e); |
| } |
| } |
| |
| if (isContentProtectionEnabled()) { |
| mContentProtectionEventProcessor = |
| new ContentProtectionEventProcessor( |
| mManager.getContentProtectionEventBuffer(), |
| mContentCaptureHandler, |
| mSystemServerInterface, |
| mComponentName.getPackageName(), |
| mManager.mOptions.contentProtectionOptions); |
| } else { |
| mContentProtectionEventProcessor = null; |
| } |
| |
| if ((resultCode & STATE_DISABLED) != 0) { |
| resetSession(resultCode); |
| } else { |
| mState = resultCode; |
| mDisabled.set(false); |
| // Flush any pending data immediately as buffering forced until now. |
| flushIfNeeded(FLUSH_REASON_SESSION_CONNECTED); |
| } |
| if (sVerbose) { |
| Log.v(TAG, "handleSessionStarted() result: id=" + mId + " resultCode=" + resultCode |
| + ", state=" + getStateAsString(mState) + ", disabled=" + mDisabled.get() |
| + ", binder=" + binder + ", events=" + (mEvents == null ? 0 : mEvents.size())); |
| } |
| } |
| |
| /** @hide */ |
| @VisibleForTesting(visibility = VisibleForTesting.Visibility.PRIVATE) |
| public void sendEvent(@NonNull ContentCaptureEvent event) { |
| sendEvent(event, /* forceFlush= */ false); |
| } |
| |
| private void sendEvent(@NonNull ContentCaptureEvent event, boolean forceFlush) { |
| checkOnContentCaptureThread(); |
| final int eventType = event.getType(); |
| if (sVerbose) Log.v(TAG, "handleSendEvent(" + getDebugState() + "): " + event); |
| if (!hasStarted() && eventType != ContentCaptureEvent.TYPE_SESSION_STARTED |
| && eventType != ContentCaptureEvent.TYPE_CONTEXT_UPDATED) { |
| // TODO(b/120494182): comment when this could happen (dialogs?) |
| if (sVerbose) { |
| Log.v(TAG, "handleSendEvent(" + getDebugState() + ", " |
| + ContentCaptureEvent.getTypeAsString(eventType) |
| + "): dropping because session not started yet"); |
| } |
| return; |
| } |
| if (mDisabled.get()) { |
| // This happens when the event was queued in the handler before the sesison was ready, |
| // then handleSessionStarted() returned and set it as disabled - we need to drop it, |
| // otherwise it will keep triggering handleScheduleFlush() |
| if (sVerbose) Log.v(TAG, "handleSendEvent(): ignoring when disabled"); |
| return; |
| } |
| |
| if (Trace.isTagEnabled(Trace.TRACE_TAG_VIEW)) { |
| if (eventType == TYPE_VIEW_TREE_APPEARING) { |
| Trace.asyncTraceBegin( |
| Trace.TRACE_TAG_VIEW, /* methodName= */ "sendEventAsync", /* cookie= */ 0); |
| } |
| } |
| |
| if (isContentProtectionReceiverEnabled()) { |
| sendContentProtectionEvent(event); |
| } |
| if (isContentCaptureReceiverEnabled()) { |
| sendContentCaptureEvent(event, forceFlush); |
| } |
| |
| if (Trace.isTagEnabled(Trace.TRACE_TAG_VIEW)) { |
| if (eventType == TYPE_VIEW_TREE_APPEARED) { |
| Trace.asyncTraceEnd( |
| Trace.TRACE_TAG_VIEW, /* methodName= */ "sendEventAsync", /* cookie= */ 0); |
| } |
| } |
| } |
| |
| private void sendContentProtectionEvent(@NonNull ContentCaptureEvent event) { |
| checkOnContentCaptureThread(); |
| if (mContentProtectionEventProcessor != null) { |
| mContentProtectionEventProcessor.processEvent(event); |
| } |
| } |
| |
| private void sendContentCaptureEvent(@NonNull ContentCaptureEvent event, boolean forceFlush) { |
| checkOnContentCaptureThread(); |
| final int eventType = event.getType(); |
| final int maxBufferSize = mManager.mOptions.maxBufferSize; |
| if (mEvents == null) { |
| if (sVerbose) { |
| Log.v(TAG, "handleSendEvent(): creating buffer for " + maxBufferSize + " events"); |
| } |
| mEvents = new ArrayList<>(maxBufferSize); |
| } |
| |
| // Some type of events can be merged together |
| boolean addEvent = true; |
| |
| if (eventType == TYPE_VIEW_TEXT_CHANGED) { |
| // We determine whether to add or merge the current event by following criteria: |
| // 1. Don't have composing span: always add. |
| // 2. Have composing span: |
| // 2.1 either last or current text is empty: add. |
| // 2.2 last event doesn't have composing span: add. |
| // Otherwise, merge. |
| final CharSequence text = event.getText(); |
| final boolean hasComposingSpan = event.hasComposingSpan(); |
| if (hasComposingSpan) { |
| ContentCaptureEvent lastEvent = null; |
| for (int index = mEvents.size() - 1; index >= 0; index--) { |
| final ContentCaptureEvent tmpEvent = mEvents.get(index); |
| if (event.getId().equals(tmpEvent.getId())) { |
| lastEvent = tmpEvent; |
| break; |
| } |
| } |
| if (lastEvent != null && lastEvent.hasComposingSpan()) { |
| final CharSequence lastText = lastEvent.getText(); |
| final boolean bothNonEmpty = !TextUtils.isEmpty(lastText) |
| && !TextUtils.isEmpty(text); |
| boolean equalContent = |
| TextUtils.equals(lastText, text) |
| && lastEvent.hasSameComposingSpan(event) |
| && lastEvent.hasSameSelectionSpan(event); |
| if (equalContent) { |
| addEvent = false; |
| } else if (bothNonEmpty) { |
| lastEvent.mergeEvent(event); |
| addEvent = false; |
| } |
| if (!addEvent && sVerbose) { |
| Log.v(TAG, "Buffering VIEW_TEXT_CHANGED event, updated text=" |
| + getSanitizedString(text)); |
| } |
| } |
| } |
| } |
| |
| if (!mEvents.isEmpty() && eventType == TYPE_VIEW_DISAPPEARED) { |
| final ContentCaptureEvent lastEvent = mEvents.get(mEvents.size() - 1); |
| if (lastEvent.getType() == TYPE_VIEW_DISAPPEARED |
| && event.getSessionId() == lastEvent.getSessionId()) { |
| if (sVerbose) { |
| Log.v(TAG, "Buffering TYPE_VIEW_DISAPPEARED events for session " |
| + lastEvent.getSessionId()); |
| } |
| lastEvent.mergeEvent(event); |
| addEvent = false; |
| } |
| } |
| |
| if (addEvent) { |
| mEvents.add(event); |
| } |
| |
| // TODO: we need to change when the flush happens so that we don't flush while the |
| // composing span hasn't changed. But we might need to keep flushing the events for the |
| // non-editable views and views that don't have the composing state; otherwise some other |
| // Content Capture features may be delayed. |
| |
| final int numberEvents = mEvents.size(); |
| |
| final boolean bufferEvent = numberEvents < maxBufferSize; |
| |
| if (bufferEvent && !forceFlush) { |
| final int flushReason; |
| if (eventType == TYPE_VIEW_TEXT_CHANGED) { |
| mNextFlushForTextChanged = true; |
| flushReason = FLUSH_REASON_TEXT_CHANGE_TIMEOUT; |
| } else { |
| if (mNextFlushForTextChanged) { |
| if (sVerbose) { |
| Log.i(TAG, "Not scheduling flush because next flush is for text changed"); |
| } |
| return; |
| } |
| |
| flushReason = FLUSH_REASON_IDLE_TIMEOUT; |
| } |
| scheduleFlush(flushReason, /* checkExisting= */ true); |
| return; |
| } |
| |
| if (mState != STATE_ACTIVE && numberEvents >= maxBufferSize) { |
| // Callback from startSession hasn't been called yet - typically happens on system |
| // apps that are started before the system service |
| // TODO(b/122959591): try to ignore session while system is not ready / boot |
| // not complete instead. Similarly, the manager service should return right away |
| // when the user does not have a service set |
| if (sDebug) { |
| Log.d(TAG, "Closing session for " + getDebugState() |
| + " after " + numberEvents + " delayed events"); |
| } |
| resetSession(STATE_DISABLED | STATE_NO_RESPONSE); |
| // TODO(b/111276913): denylist activity / use special flag to indicate that |
| // when it's launched again |
| return; |
| } |
| final int flushReason; |
| switch (eventType) { |
| case ContentCaptureEvent.TYPE_SESSION_STARTED: |
| flushReason = FLUSH_REASON_SESSION_STARTED; |
| break; |
| case ContentCaptureEvent.TYPE_SESSION_FINISHED: |
| flushReason = FLUSH_REASON_SESSION_FINISHED; |
| break; |
| case ContentCaptureEvent.TYPE_VIEW_TREE_APPEARING: |
| flushReason = FLUSH_REASON_VIEW_TREE_APPEARING; |
| break; |
| case ContentCaptureEvent.TYPE_VIEW_TREE_APPEARED: |
| flushReason = FLUSH_REASON_VIEW_TREE_APPEARED; |
| break; |
| default: |
| flushReason = forceFlush ? FLUSH_REASON_FORCE_FLUSH : FLUSH_REASON_FULL; |
| } |
| |
| flush(flushReason); |
| } |
| |
| private boolean hasStarted() { |
| checkOnContentCaptureThread(); |
| return mState != UNKNOWN_STATE; |
| } |
| |
| private void scheduleFlush(@FlushReason int reason, boolean checkExisting) { |
| checkOnContentCaptureThread(); |
| if (sVerbose) { |
| Log.v(TAG, "handleScheduleFlush(" + getDebugState(reason) |
| + ", checkExisting=" + checkExisting); |
| } |
| if (!hasStarted()) { |
| if (sVerbose) Log.v(TAG, "handleScheduleFlush(): session not started yet"); |
| return; |
| } |
| |
| if (mDisabled.get()) { |
| // Should not be called on this state, as handleSendEvent checks. |
| // But we rather add one if check and log than re-schedule and keep the session alive... |
| Log.e(TAG, "handleScheduleFlush(" + getDebugState(reason) + "): should not be called " |
| + "when disabled. events=" + (mEvents == null ? null : mEvents.size())); |
| return; |
| } |
| if (checkExisting && mContentCaptureHandler.hasMessages(MSG_FLUSH)) { |
| // "Renew" the flush message by removing the previous one |
| mContentCaptureHandler.removeMessages(MSG_FLUSH); |
| } |
| |
| final int flushFrequencyMs; |
| if (reason == FLUSH_REASON_TEXT_CHANGE_TIMEOUT) { |
| flushFrequencyMs = mManager.mOptions.textChangeFlushingFrequencyMs; |
| } else { |
| if (reason != FLUSH_REASON_IDLE_TIMEOUT) { |
| if (sDebug) { |
| Log.d(TAG, "handleScheduleFlush(" + getDebugState(reason) + "): not a timeout " |
| + "reason because mDirectServiceInterface is not ready yet"); |
| } |
| } |
| flushFrequencyMs = mManager.mOptions.idleFlushingFrequencyMs; |
| } |
| |
| mNextFlush = System.currentTimeMillis() + flushFrequencyMs; |
| if (sVerbose) { |
| Log.v(TAG, "handleScheduleFlush(): scheduled to flush in " |
| + flushFrequencyMs + "ms: " + TimeUtils.logTimeOfDay(mNextFlush)); |
| } |
| // Post using a Runnable directly to trim a few μs from PooledLambda.obtainMessage() |
| mContentCaptureHandler.postDelayed(() -> |
| flushIfNeeded(reason), MSG_FLUSH, flushFrequencyMs); |
| } |
| |
| private void flushIfNeeded(@FlushReason int reason) { |
| checkOnContentCaptureThread(); |
| if (mEvents == null || mEvents.isEmpty()) { |
| if (sVerbose) Log.v(TAG, "Nothing to flush"); |
| return; |
| } |
| flush(reason); |
| } |
| |
| /** @hide */ |
| @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) |
| @Override |
| public void flush(@FlushReason int reason) { |
| runOnContentCaptureThread(() -> flushImpl(reason)); |
| } |
| |
| private void flushImpl(@FlushReason int reason) { |
| checkOnContentCaptureThread(); |
| if (mEvents == null || mEvents.size() == 0) { |
| if (sVerbose) { |
| Log.v(TAG, "Don't flush for empty event buffer."); |
| } |
| return; |
| } |
| |
| if (mDisabled.get()) { |
| Log.e(TAG, "handleForceFlush(" + getDebugState(reason) + "): should not be when " |
| + "disabled"); |
| return; |
| } |
| |
| if (!isContentCaptureReceiverEnabled()) { |
| return; |
| } |
| |
| if (mDirectServiceInterface == null) { |
| if (sVerbose) { |
| Log.v(TAG, "handleForceFlush(" + getDebugState(reason) + "): hold your horses, " |
| + "client not ready: " + mEvents); |
| } |
| if (!mContentCaptureHandler.hasMessages(MSG_FLUSH)) { |
| scheduleFlush(reason, /* checkExisting= */ false); |
| } |
| return; |
| } |
| |
| mNextFlushForTextChanged = false; |
| |
| final int numberEvents = mEvents.size(); |
| final String reasonString = getFlushReasonAsString(reason); |
| |
| if (sVerbose) { |
| ContentCaptureEvent event = mEvents.get(numberEvents - 1); |
| String forceString = (reason == FLUSH_REASON_FORCE_FLUSH) ? ". The force flush event " |
| + ContentCaptureEvent.getTypeAsString(event.getType()) : ""; |
| Log.v(TAG, "Flushing " + numberEvents + " event(s) for " + getDebugState(reason) |
| + forceString); |
| } |
| if (mFlushHistory != null) { |
| // Logs reason, size, max size, idle timeout |
| final String logRecord = "r=" + reasonString + " s=" + numberEvents |
| + " m=" + mManager.mOptions.maxBufferSize |
| + " i=" + mManager.mOptions.idleFlushingFrequencyMs; |
| mFlushHistory.log(logRecord); |
| } |
| try { |
| mContentCaptureHandler.removeMessages(MSG_FLUSH); |
| |
| final ParceledListSlice<ContentCaptureEvent> events = clearEvents(); |
| mDirectServiceInterface.sendEvents(events, reason, mManager.mOptions); |
| } catch (RemoteException e) { |
| Log.w(TAG, "Error sending " + numberEvents + " for " + getDebugState() |
| + ": " + e); |
| } |
| } |
| |
| @Override |
| public void updateContentCaptureContext(@Nullable ContentCaptureContext context) { |
| internalNotifyContextUpdated(mId, context); |
| } |
| |
| /** |
| * Resets the buffer and return a {@link ParceledListSlice} with the previous events. |
| */ |
| @NonNull |
| private ParceledListSlice<ContentCaptureEvent> clearEvents() { |
| checkOnContentCaptureThread(); |
| // NOTE: we must save a reference to the current mEvents and then set it to to null, |
| // otherwise clearing it would clear it in the receiving side if the service is also local. |
| if (mEvents == null) { |
| return new ParceledListSlice<>(Collections.EMPTY_LIST); |
| } |
| |
| final List<ContentCaptureEvent> events = new ArrayList<>(mEvents); |
| mEvents.clear(); |
| return new ParceledListSlice<>(events); |
| } |
| |
| /** hide */ |
| @VisibleForTesting(visibility = VisibleForTesting.Visibility.PRIVATE) |
| public void destroySession() { |
| checkOnContentCaptureThread(); |
| if (sDebug) { |
| Log.d(TAG, "Destroying session (ctx=" + mContext + ", id=" + mId + ") with " |
| + (mEvents == null ? 0 : mEvents.size()) + " event(s) for " |
| + getDebugState()); |
| } |
| |
| reportWrongThreadMetric(); |
| try { |
| mSystemServerInterface.finishSession(mId); |
| } catch (RemoteException e) { |
| Log.e(TAG, "Error destroying system-service session " + mId + " for " |
| + getDebugState() + ": " + e); |
| } |
| |
| if (mDirectServiceInterface != null) { |
| mDirectServiceInterface.asBinder().unlinkToDeath(mDirectServiceVulture, 0); |
| } |
| mDirectServiceInterface = null; |
| mContentProtectionEventProcessor = null; |
| mEventProcessQueue.clear(); |
| } |
| |
| // TODO(b/122454205): once we support multiple sessions, we might need to move some of these |
| // clearings out. |
| /** @hide */ |
| @VisibleForTesting(visibility = VisibleForTesting.Visibility.PRIVATE) |
| public void resetSession(int newState) { |
| checkOnContentCaptureThread(); |
| if (sVerbose) { |
| Log.v(TAG, "handleResetSession(" + getActivityName() + "): from " |
| + getStateAsString(mState) + " to " + getStateAsString(newState)); |
| } |
| mState = newState; |
| mDisabled.set((newState & STATE_DISABLED) != 0); |
| // TODO(b/122454205): must reset children (which currently is owned by superclass) |
| mApplicationToken = null; |
| mShareableActivityToken = null; |
| mComponentName = null; |
| mEvents = null; |
| if (mDirectServiceInterface != null) { |
| try { |
| mDirectServiceInterface.asBinder().unlinkToDeath(mDirectServiceVulture, 0); |
| } catch (NoSuchElementException e) { |
| Log.w(TAG, "IContentCaptureDirectManager does not exist"); |
| } |
| } |
| mDirectServiceInterface = null; |
| mContentProtectionEventProcessor = null; |
| mContentCaptureHandler.removeMessages(MSG_FLUSH); |
| } |
| |
| @Override |
| void internalNotifyViewAppeared(int sessionId, @NonNull ViewStructureImpl node) { |
| final ContentCaptureEvent event = new ContentCaptureEvent(sessionId, TYPE_VIEW_APPEARED) |
| .setViewNode(node.mNode); |
| enqueueEvent(event); |
| } |
| |
| @Override |
| void internalNotifyViewDisappeared(int sessionId, @NonNull AutofillId id) { |
| final ContentCaptureEvent event = new ContentCaptureEvent(sessionId, TYPE_VIEW_DISAPPEARED) |
| .setAutofillId(id); |
| enqueueEvent(event); |
| } |
| |
| @Override |
| void internalNotifyViewTextChanged( |
| int sessionId, @NonNull AutofillId id, @Nullable CharSequence text) { |
| // Since the same CharSequence instance may be reused in the TextView, we need to make |
| // a copy of its content so that its value will not be changed by subsequent updates |
| // in the TextView. |
| CharSequence trimmed = TextUtils.trimToParcelableSize(text); |
| final CharSequence eventText = trimmed != null && trimmed == text |
| ? trimmed.toString() |
| : trimmed; |
| |
| final int composingStart; |
| final int composingEnd; |
| if (text instanceof Spannable) { |
| composingStart = BaseInputConnection.getComposingSpanStart((Spannable) text); |
| composingEnd = BaseInputConnection.getComposingSpanEnd((Spannable) text); |
| } else { |
| composingStart = ContentCaptureEvent.MAX_INVALID_VALUE; |
| composingEnd = ContentCaptureEvent.MAX_INVALID_VALUE; |
| } |
| |
| final int startIndex = Selection.getSelectionStart(text); |
| final int endIndex = Selection.getSelectionEnd(text); |
| |
| final ContentCaptureEvent event = new ContentCaptureEvent(sessionId, TYPE_VIEW_TEXT_CHANGED) |
| .setAutofillId(id).setText(eventText) |
| .setComposingIndex(composingStart, composingEnd) |
| .setSelectionIndex(startIndex, endIndex); |
| enqueueEvent(event); |
| } |
| |
| @Override |
| void internalNotifyViewInsetsChanged(int sessionId, @NonNull Insets viewInsets) { |
| final ContentCaptureEvent event = |
| new ContentCaptureEvent(sessionId, TYPE_VIEW_INSETS_CHANGED) |
| .setInsets(viewInsets); |
| enqueueEvent(event); |
| } |
| |
| @Override |
| public void internalNotifyViewTreeEvent(int sessionId, boolean started) { |
| final int type = started ? TYPE_VIEW_TREE_APPEARING : TYPE_VIEW_TREE_APPEARED; |
| final boolean disableFlush = mManager.getFlushViewTreeAppearingEventDisabled(); |
| final boolean forceFlush = disableFlush ? !started : FORCE_FLUSH; |
| |
| final ContentCaptureEvent event = new ContentCaptureEvent(sessionId, type); |
| enqueueEvent(event, forceFlush); |
| } |
| |
| @Override |
| public void internalNotifySessionResumed() { |
| final ContentCaptureEvent event = new ContentCaptureEvent(mId, TYPE_SESSION_RESUMED); |
| enqueueEvent(event, FORCE_FLUSH); |
| } |
| |
| @Override |
| public void internalNotifySessionPaused() { |
| final ContentCaptureEvent event = new ContentCaptureEvent(mId, TYPE_SESSION_PAUSED); |
| enqueueEvent(event, FORCE_FLUSH); |
| } |
| |
| @Override |
| boolean isContentCaptureEnabled() { |
| return super.isContentCaptureEnabled() && mManager.isContentCaptureEnabled(); |
| } |
| |
| // Called by ContentCaptureManager.isContentCaptureEnabled |
| boolean isDisabled() { |
| return mDisabled.get(); |
| } |
| |
| /** |
| * Sets the disabled state of content capture. |
| * |
| * @return whether disabled state was changed. |
| */ |
| boolean setDisabled(boolean disabled) { |
| return mDisabled.compareAndSet(!disabled, disabled); |
| } |
| |
| @Override |
| void internalNotifyChildSessionStarted(int parentSessionId, int childSessionId, |
| @NonNull ContentCaptureContext clientContext) { |
| final ContentCaptureEvent event = |
| new ContentCaptureEvent(childSessionId, TYPE_SESSION_STARTED) |
| .setParentSessionId(parentSessionId) |
| .setClientContext(clientContext); |
| enqueueEvent(event, FORCE_FLUSH); |
| } |
| |
| @Override |
| void internalNotifyChildSessionFinished(int parentSessionId, int childSessionId) { |
| final ContentCaptureEvent event = |
| new ContentCaptureEvent(childSessionId, TYPE_SESSION_FINISHED) |
| .setParentSessionId(parentSessionId); |
| enqueueEvent(event, FORCE_FLUSH); |
| } |
| |
| @Override |
| void internalNotifyContextUpdated(int sessionId, @Nullable ContentCaptureContext context) { |
| final ContentCaptureEvent event = new ContentCaptureEvent(sessionId, TYPE_CONTEXT_UPDATED) |
| .setClientContext(context); |
| enqueueEvent(event, FORCE_FLUSH); |
| } |
| |
| @Override |
| public void notifyWindowBoundsChanged(int sessionId, @NonNull Rect bounds) { |
| final ContentCaptureEvent event = |
| new ContentCaptureEvent(sessionId, TYPE_WINDOW_BOUNDS_CHANGED) |
| .setBounds(bounds); |
| enqueueEvent(event); |
| } |
| |
| private List<ContentCaptureEvent> clearBufferEvents() { |
| final ArrayList<ContentCaptureEvent> bufferEvents = new ArrayList<>(); |
| ContentCaptureEvent event; |
| while ((event = mEventProcessQueue.poll()) != null) { |
| bufferEvents.add(event); |
| } |
| return bufferEvents; |
| } |
| |
| private void enqueueEvent(@NonNull final ContentCaptureEvent event) { |
| enqueueEvent(event, /* forceFlush */ false); |
| } |
| |
| /** |
| * Enqueue the event into {@code mEventProcessBuffer} if it is not an urgent request. Otherwise, |
| * clear the buffer events then starting sending out current event. |
| */ |
| private void enqueueEvent(@NonNull final ContentCaptureEvent event, boolean forceFlush) { |
| if (forceFlush || mEventProcessQueue.size() >= mManager.mOptions.maxBufferSize - 1) { |
| // The buffer events are cleared in the same thread first to prevent new events |
| // being added during the time of context switch. This would disrupt the sequence |
| // of events. |
| final List<ContentCaptureEvent> batchEvents = clearBufferEvents(); |
| runOnContentCaptureThread(() -> { |
| for (int i = 0; i < batchEvents.size(); i++) { |
| sendEvent(batchEvents.get(i)); |
| } |
| sendEvent(event, /* forceFlush= */ true); |
| }); |
| } else { |
| mEventProcessQueue.offer(event); |
| } |
| } |
| |
| @Override |
| public void notifyContentCaptureEvents( |
| @NonNull SparseArray<ArrayList<Object>> contentCaptureEvents) { |
| runOnUiThread(() -> { |
| prepareViewStructures(contentCaptureEvents); |
| runOnContentCaptureThread(() -> |
| notifyContentCaptureEventsImpl(contentCaptureEvents)); |
| }); |
| } |
| |
| /** |
| * Traverse events and pre-process {@link View} events to {@link ViewStructureSession} events. |
| * If a {@link View} event is invalid, an empty {@link ViewStructureSession} will still be |
| * provided. |
| */ |
| private void prepareViewStructures( |
| @NonNull SparseArray<ArrayList<Object>> contentCaptureEvents) { |
| for (int i = 0; i < contentCaptureEvents.size(); i++) { |
| int sessionId = contentCaptureEvents.keyAt(i); |
| ArrayList<Object> events = contentCaptureEvents.valueAt(i); |
| for_each_event: for (int j = 0; j < events.size(); j++) { |
| Object event = events.get(j); |
| if (event instanceof View) { |
| View view = (View) event; |
| ContentCaptureSession session = view.getContentCaptureSession(); |
| ViewStructureSession structureSession = new ViewStructureSession(); |
| |
| // Replace the View event with ViewStructureSession no matter the data is |
| // available or not. This is to ensure the sequence of the events are still |
| // the same. Calls to notifyViewAppeared will check the availability later. |
| events.set(j, structureSession); |
| if (session == null) { |
| Log.w(TAG, "no content capture session on view: " + view); |
| continue for_each_event; |
| } |
| int actualId = session.getId(); |
| if (actualId != sessionId) { |
| Log.w(TAG, "content capture session mismatch for view (" + view |
| + "): was " + sessionId + " before, it's " + actualId + " now"); |
| continue for_each_event; |
| } |
| ViewStructure structure = session.newViewStructure(view); |
| view.onProvideContentCaptureStructure(structure, /* flags= */ 0); |
| |
| structureSession.setSession(session); |
| structureSession.setStructure(structure); |
| } |
| } |
| } |
| } |
| |
| private void notifyContentCaptureEventsImpl( |
| @NonNull SparseArray<ArrayList<Object>> contentCaptureEvents) { |
| checkOnContentCaptureThread(); |
| try { |
| if (Trace.isTagEnabled(Trace.TRACE_TAG_VIEW)) { |
| Trace.traceBegin(Trace.TRACE_TAG_VIEW, "notifyContentCaptureEvents"); |
| } |
| for (int i = 0; i < contentCaptureEvents.size(); i++) { |
| int sessionId = contentCaptureEvents.keyAt(i); |
| internalNotifyViewTreeEvent(sessionId, /* started= */ true); |
| ArrayList<Object> events = contentCaptureEvents.valueAt(i); |
| for_each_event: for (int j = 0; j < events.size(); j++) { |
| Object event = events.get(j); |
| if (event instanceof AutofillId) { |
| internalNotifyViewDisappeared(sessionId, (AutofillId) event); |
| } else if (event instanceof ViewStructureSession viewStructureSession) { |
| viewStructureSession.notifyViewAppeared(); |
| } else if (event instanceof Insets) { |
| internalNotifyViewInsetsChanged(sessionId, (Insets) event); |
| } else { |
| Log.w(TAG, "invalid content capture event: " + event); |
| } |
| } |
| internalNotifyViewTreeEvent(sessionId, /* started= */ false); |
| } |
| } finally { |
| Trace.traceEnd(Trace.TRACE_TAG_VIEW); |
| } |
| } |
| |
| @Override |
| void dump(@NonNull String prefix, @NonNull PrintWriter pw) { |
| super.dump(prefix, pw); |
| |
| pw.print(prefix); pw.print("mContext: "); pw.println(mContext); |
| pw.print(prefix); pw.print("user: "); pw.println(mContext.getUserId()); |
| if (mDirectServiceInterface != null) { |
| pw.print(prefix); pw.print("mDirectServiceInterface: "); |
| pw.println(mDirectServiceInterface); |
| } |
| pw.print(prefix); pw.print("mDisabled: "); pw.println(mDisabled.get()); |
| pw.print(prefix); pw.print("isEnabled(): "); pw.println(isContentCaptureEnabled()); |
| pw.print(prefix); pw.print("state: "); pw.println(getStateAsString(mState)); |
| if (mApplicationToken != null) { |
| pw.print(prefix); pw.print("app token: "); pw.println(mApplicationToken); |
| } |
| if (mShareableActivityToken != null) { |
| pw.print(prefix); pw.print("sharable activity token: "); |
| pw.println(mShareableActivityToken); |
| } |
| if (mComponentName != null) { |
| pw.print(prefix); pw.print("component name: "); |
| pw.println(mComponentName.flattenToShortString()); |
| } |
| if (mEvents != null && !mEvents.isEmpty()) { |
| final int numberEvents = mEvents.size(); |
| pw.print(prefix); pw.print("buffered events: "); pw.print(numberEvents); |
| pw.print('/'); pw.println(mManager.mOptions.maxBufferSize); |
| if (sVerbose && numberEvents > 0) { |
| final String prefix3 = prefix + " "; |
| for (int i = 0; i < numberEvents; i++) { |
| final ContentCaptureEvent event = mEvents.get(i); |
| pw.print(prefix3); pw.print(i); pw.print(": "); event.dump(pw); |
| pw.println(); |
| } |
| } |
| pw.print(prefix); pw.print("mNextFlushForTextChanged: "); |
| pw.println(mNextFlushForTextChanged); |
| pw.print(prefix); pw.print("flush frequency: "); |
| if (mNextFlushForTextChanged) { |
| pw.println(mManager.mOptions.textChangeFlushingFrequencyMs); |
| } else { |
| pw.println(mManager.mOptions.idleFlushingFrequencyMs); |
| } |
| pw.print(prefix); pw.print("next flush: "); |
| TimeUtils.formatDuration(mNextFlush - System.currentTimeMillis(), pw); |
| pw.print(" ("); pw.print(TimeUtils.logTimeOfDay(mNextFlush)); pw.println(")"); |
| } |
| if (mFlushHistory != null) { |
| pw.print(prefix); pw.println("flush history:"); |
| mFlushHistory.reverseDump(/* fd= */ null, pw, /* args= */ null); pw.println(); |
| } else { |
| pw.print(prefix); pw.println("not logging flush history"); |
| } |
| |
| super.dump(prefix, pw); |
| } |
| |
| /** |
| * Gets a string that can be used to identify the activity on logging statements. |
| */ |
| private String getActivityName() { |
| return mComponentName == null |
| ? "pkg:" + mContext.getPackageName() |
| : "act:" + mComponentName.flattenToShortString(); |
| } |
| |
| @NonNull |
| private String getDebugState() { |
| return getActivityName() + " [state=" + getStateAsString(mState) + ", disabled=" |
| + mDisabled.get() + "]"; |
| } |
| |
| @NonNull |
| private String getDebugState(@FlushReason int reason) { |
| return getDebugState() + ", reason=" + getFlushReasonAsString(reason); |
| } |
| |
| private boolean isContentProtectionReceiverEnabled() { |
| return mManager.mOptions.contentProtectionOptions.enableReceiver; |
| } |
| |
| private boolean isContentCaptureReceiverEnabled() { |
| return mManager.mOptions.enableReceiver; |
| } |
| |
| private boolean isContentProtectionEnabled() { |
| // Should not be possible for mComponentName to be null here but check anyway |
| // Should not be possible for groups to be empty if receiver is enabled but check anyway |
| return mManager.mOptions.contentProtectionOptions.enableReceiver |
| && mManager.getContentProtectionEventBuffer() != null |
| && mComponentName != null |
| && (!mManager.mOptions.contentProtectionOptions.requiredGroups.isEmpty() |
| || !mManager.mOptions.contentProtectionOptions.optionalGroups.isEmpty()); |
| } |
| |
| /** |
| * Checks that the current work is running on the assigned thread from {@code mHandler} and |
| * count the number of times running on the wrong thread. |
| * |
| * <p>It is not guaranteed that the callers always invoke function from a single thread. |
| * Therefore, accessing internal properties in {@link MainContentCaptureSession} should |
| * always delegate to the assigned thread from {@code mHandler} for synchronization.</p> |
| */ |
| private void checkOnContentCaptureThread() { |
| final boolean onContentCaptureThread = mContentCaptureHandler.getLooper().isCurrentThread(); |
| if (!onContentCaptureThread) { |
| mWrongThreadCount.incrementAndGet(); |
| Log.e(TAG, "MainContentCaptureSession running on " + Thread.currentThread()); |
| } |
| } |
| |
| /** Reports number of times running on the wrong thread. */ |
| private void reportWrongThreadMetric() { |
| Counter.logIncrement( |
| CONTENT_CAPTURE_WRONG_THREAD_METRIC_ID, mWrongThreadCount.getAndSet(0)); |
| } |
| |
| /** |
| * Ensures that {@code r} will be running on the assigned thread. |
| * |
| * <p>This is to prevent unnecessary delegation to Handler that results in fragmented runnable. |
| * </p> |
| */ |
| private void runOnContentCaptureThread(@NonNull Runnable r) { |
| if (!mContentCaptureHandler.getLooper().isCurrentThread()) { |
| mContentCaptureHandler.post(r); |
| } else { |
| r.run(); |
| } |
| } |
| |
| private void clearAndRunOnContentCaptureThread(@NonNull Runnable r, int what) { |
| if (!mContentCaptureHandler.getLooper().isCurrentThread()) { |
| mContentCaptureHandler.removeMessages(what); |
| mContentCaptureHandler.post(r); |
| } else { |
| r.run(); |
| } |
| } |
| |
| private void runOnUiThread(@NonNull Runnable r) { |
| if (mUiHandler.getLooper().isCurrentThread()) { |
| r.run(); |
| } else { |
| mUiHandler.post(r); |
| } |
| } |
| |
| /** |
| * Holds {@link ContentCaptureSession} and related {@link ViewStructure} for processing. |
| */ |
| private static final class ViewStructureSession { |
| @Nullable private ContentCaptureSession mSession; |
| @Nullable private ViewStructure mStructure; |
| |
| ViewStructureSession() {} |
| |
| void setSession(@Nullable ContentCaptureSession session) { |
| this.mSession = session; |
| } |
| |
| void setStructure(@Nullable ViewStructure struct) { |
| this.mStructure = struct; |
| } |
| |
| /** |
| * Calls {@link ContentCaptureSession#notifyViewAppeared(ViewStructure)} if the session and |
| * the view structure are available. |
| */ |
| void notifyViewAppeared() { |
| if (mSession != null && mStructure != null) { |
| mSession.notifyViewAppeared(mStructure); |
| } |
| } |
| } |
| } |