/* * 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.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; // TODO(b/309411951): Replace V2 as the only main session once the experiment is done. /** * Main session associated with a context. * *

This session is created when the activity starts and finished when it stops; clients can use * it to create children activities. * * @hide */ @VisibleForTesting(visibility = VisibleForTesting.Visibility.PACKAGE) public final class MainContentCaptureSession 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 mHandler; /** * 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; /** * List of events held to be sent to the {@link ContentCaptureService} as a batch. * * @hide */ @VisibleForTesting(visibility = VisibleForTesting.Visibility.PRIVATE) @Nullable public ArrayList 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 mMainSession; SessionStateReceiver(MainContentCaptureSession session) { mMainSession = new WeakReference<>(session); } @Override public void send(int resultCode, Bundle resultData) { final MainContentCaptureSession 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.mHandler.post(() -> mainSession.resetSession( STATE_DISABLED | STATE_INTERNAL_ERROR)); return; } } else { binder = null; } mainSession.mHandler.post(() -> mainSession.onSessionStarted(resultCode, binder)); } } /** @hide */ @VisibleForTesting(visibility = VisibleForTesting.Visibility.PROTECTED) public MainContentCaptureSession( @NonNull ContentCaptureManager.StrippedContext context, @NonNull ContentCaptureManager manager, @NonNull Handler handler, @NonNull IContentCaptureManager systemServerInterface) { mContext = context; mManager = manager; mHandler = handler; mSystemServerInterface = systemServerInterface; final int logHistorySize = mManager.mOptions.logHistorySize; mFlushHistory = logHistorySize > 0 ? new LocalLog(logHistorySize) : null; mSessionStateReceiver = new SessionStateReceiver(this); } @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) { checkOnUiThread(); 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() { mHandler.removeMessages(MSG_FLUSH); mHandler.post(() -> { try { flush(FLUSH_REASON_SESSION_FINISHED); } finally { destroySession(); } }); } /** * 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) { checkOnUiThread(); 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(), mHandler, 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) { checkOnUiThread(); 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) { checkOnUiThread(); if (mContentProtectionEventProcessor != null) { mContentProtectionEventProcessor.processEvent(event); } } private void sendContentCaptureEvent(@NonNull ContentCaptureEvent event, boolean forceFlush) { checkOnUiThread(); 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() { checkOnUiThread(); return mState != UNKNOWN_STATE; } private void scheduleFlush(@FlushReason int reason, boolean checkExisting) { checkOnUiThread(); 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 && mHandler.hasMessages(MSG_FLUSH)) { // "Renew" the flush message by removing the previous one mHandler.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() mHandler.postDelayed(() -> flushIfNeeded(reason), MSG_FLUSH, flushFrequencyMs); } private void flushIfNeeded(@FlushReason int reason) { checkOnUiThread(); 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) { checkOnUiThread(); 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 (!mHandler.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 { mHandler.removeMessages(MSG_FLUSH); final ParceledListSlice 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 clearEvents() { checkOnUiThread(); // 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 events = new ArrayList<>(mEvents); mEvents.clear(); return new ParceledListSlice<>(events); } /** hide */ @VisibleForTesting(visibility = VisibleForTesting.Visibility.PRIVATE) public void destroySession() { checkOnUiThread(); 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; } // 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) { checkOnUiThread(); 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; mHandler.removeMessages(MSG_FLUSH); } @Override void internalNotifyViewAppeared(int sessionId, @NonNull ViewStructureImpl node) { final ContentCaptureEvent event = new ContentCaptureEvent(sessionId, TYPE_VIEW_APPEARED) .setViewNode(node.mNode); mHandler.post(() -> sendEvent(event)); } @Override void internalNotifyViewDisappeared(int sessionId, @NonNull AutofillId id) { final ContentCaptureEvent event = new ContentCaptureEvent(sessionId, TYPE_VIEW_DISAPPEARED) .setAutofillId(id); mHandler.post(() -> sendEvent(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); mHandler.post(() -> sendEvent(event)); } @Override void internalNotifyViewInsetsChanged(int sessionId, @NonNull Insets viewInsets) { final ContentCaptureEvent event = new ContentCaptureEvent(sessionId, TYPE_VIEW_INSETS_CHANGED) .setInsets(viewInsets); mHandler.post(() -> sendEvent(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); mHandler.post(() -> sendEvent(event, FORCE_FLUSH)); } @Override public void internalNotifySessionResumed() { final ContentCaptureEvent event = new ContentCaptureEvent(mId, TYPE_SESSION_RESUMED); mHandler.post(() -> sendEvent(event, FORCE_FLUSH)); } @Override public void internalNotifySessionPaused() { final ContentCaptureEvent event = new ContentCaptureEvent(mId, TYPE_SESSION_PAUSED); mHandler.post(() -> sendEvent(event, FORCE_FLUSH)); } @Override boolean isContentCaptureEnabled() { return super.isContentCaptureEnabled() && mManager.isContentCaptureEnabled(); } @Override boolean isDisabled() { return mDisabled.get(); } @Override 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); mHandler.post(() -> sendEvent(event, FORCE_FLUSH)); } @Override void internalNotifyChildSessionFinished(int parentSessionId, int childSessionId) { final ContentCaptureEvent event = new ContentCaptureEvent(childSessionId, TYPE_SESSION_FINISHED) .setParentSessionId(parentSessionId); mHandler.post(() -> sendEvent(event, FORCE_FLUSH)); } @Override void internalNotifyContextUpdated(int sessionId, @Nullable ContentCaptureContext context) { final ContentCaptureEvent event = new ContentCaptureEvent(sessionId, TYPE_CONTEXT_UPDATED) .setClientContext(context); mHandler.post(() -> sendEvent(event, FORCE_FLUSH)); } @Override public void notifyWindowBoundsChanged(int sessionId, @NonNull Rect bounds) { final ContentCaptureEvent event = new ContentCaptureEvent(sessionId, TYPE_WINDOW_BOUNDS_CHANGED) .setBounds(bounds); mHandler.post(() -> sendEvent(event)); } @Override public void notifyContentCaptureEvents( @NonNull SparseArray> contentCaptureEvents) { notifyContentCaptureEventsImpl(contentCaptureEvents); } private void notifyContentCaptureEventsImpl( @NonNull SparseArray> contentCaptureEvents) { checkOnUiThread(); 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 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 View) { View view = (View) event; ContentCaptureSession session = view.getContentCaptureSession(); 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); session.notifyViewAppeared(structure); } 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. * *

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.

*/ private void checkOnUiThread() { final boolean onUiThread = mHandler.getLooper().isCurrentThread(); if (!onUiThread) { 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)); } }