content: /*
   * 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_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.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.annotation.UiThread;
  import android.content.ComponentName;
  import android.content.Context;
  import android.content.pm.ParceledListSlice;
  import android.os.Bundle;
  import android.os.Handler;
  import android.os.IBinder;
  import android.os.IBinder.DeathRecipient;
  import android.os.RemoteException;
  import android.util.LocalLog;
  import android.util.Log;
  import android.util.TimeUtils;
  import android.view.autofill.AutofillId;
  import android.view.contentcapture.ViewNode.ViewStructureImpl;
  
  import com.android.internal.os.IResultReceiver;
  
  import java.io.PrintWriter;
  import java.util.ArrayList;
  import java.util.Collections;
  import java.util.List;
  import java.util.concurrent.atomic.AtomicBoolean;
  
  /**
   * Main session associated with a context.
   *
   * <p>This session is created when the activity starts and finished when it stops; clients can use
   * it to create children activities.
   *
   * @hide
   */
  public final class MainContentCaptureSession extends ContentCaptureSession {
  
      private static final String TAG = MainContentCaptureSession.class.getSimpleName();
  
      // For readability purposes...
      private static final boolean FORCE_FLUSH = true;
  
      /**
       * Handler message used to flush the buffer.
       */
      private static final int MSG_FLUSH = 1;
  
      /**
       * Name of the {@link IResultReceiver} extra used to pass the binder interface to the service.
       * @hide
       */
      public static final String EXTRA_BINDER = "binder";
  
      /**
       * Name of the {@link IResultReceiver} extra used to pass the content capture enabled state.
       * @hide
       */
      public static final String EXTRA_ENABLED_STATE = "enabled";
  
      @NonNull
      private final AtomicBoolean mDisabled = new AtomicBoolean(false);
  
      @NonNull
      private final Context 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)
       */
      @NonNull
      private IContentCaptureDirectManager mDirectServiceInterface;
      @Nullable
      private DeathRecipient mDirectServiceVulture;
  
      private int mState = UNKNOWN_STATE;
  
      @Nullable
      private IBinder mApplicationToken;
  
      @Nullable
      private ComponentName mComponentName;
  
      /**
       * List of events held to be sent as a batch.
       */
      @Nullable
      private 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;
  
      /**
       * Binder object used to update the session state.
       */
      @NonNull
      private final IResultReceiver.Stub mSessionStateReceiver;
  
      protected MainContentCaptureSession(@NonNull Context 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 IResultReceiver.Stub() {
              @Override
              public void send(int resultCode, Bundle resultData) {
                  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);
                          mDisabled.set(disabled);
                          return;
                      }
                      binder = resultData.getBinder(EXTRA_BINDER);
                      if (binder == null) {
                          Log.wtf(TAG, "No " + EXTRA_BINDER + " extra result");
                          mHandler.post(() -> resetSession(
                                  STATE_DISABLED | STATE_INTERNAL_ERROR));
                          return;
                      }
                  } else {
                      binder = null;
                  }
                  mHandler.post(() -> onSessionStarted(resultCode, binder));
              }
          };
  
      }
  
      @Override
      MainContentCaptureSession getMainCaptureSession() {
          return this;
      }
  
      @Override
      ContentCaptureSession newChild(@NonNull ContentCaptureContext clientContext) {
          final ContentCaptureSession child = new ChildContentCaptureSession(this, clientContext);
          notifyChildSessionStarted(mId, child.mId, clientContext);
          return child;
      }
  
      /**
       * Starts this session.
       */
      @UiThread
      void start(@NonNull IBinder token, @NonNull ComponentName component,
              int flags) {
          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;
          mComponentName = component;
  
          if (sVerbose) {
              Log.v(TAG, "handleStartSession(): token=" + token + ", act="
                      + getDebugState() + ", id=" + mId);
          }
  
          try {
              mSystemServerInterface.startSession(mApplicationToken, 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(() -> 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}
       */
      @UiThread
      private void onSessionStarted(int resultCode, @Nullable IBinder binder) {
          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 ((resultCode & STATE_DISABLED) != 0) {
              resetSession(resultCode);
          } else {
              mState = resultCode;
              mDisabled.set(false);
          }
          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()));
          }
      }
  
      @UiThread
      private void sendEvent(@NonNull ContentCaptureEvent event) {
          sendEvent(event, /* forceFlush= */ false);
      }
  
      @UiThread
      private void sendEvent(@NonNull ContentCaptureEvent event, boolean forceFlush) {
          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?)
              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;
          }
          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 (!mEvents.isEmpty() && eventType == TYPE_VIEW_TEXT_CHANGED) {
              final ContentCaptureEvent lastEvent = mEvents.get(mEvents.size() - 1);
  
              // TODO(b/121045053): check if flags match
              if (lastEvent.getType() == TYPE_VIEW_TEXT_CHANGED
                      && lastEvent.getId().equals(event.getId())) {
                  if (sVerbose) {
                      Log.v(TAG, "Buffering VIEW_TEXT_CHANGED event, updated text="
                              + getSanitizedString(event.getText()));
                  }
                  lastEvent.mergeEvent(event);
                  addEvent = false;
              }
          }
  
          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);
          }
  
          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): blacklist 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;
              default:
                  flushReason = FLUSH_REASON_FULL;
          }
  
          flush(flushReason);
      }
  
      @UiThread
      private boolean hasStarted() {
          return mState != UNKNOWN_STATE;
      }
  
      @UiThread
      private void scheduleFlush(@FlushReason int reason, boolean checkExisting) {
          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);
      }
  
      @UiThread
      private void flushIfNeeded(@FlushReason int reason) {
          if (mEvents == null || mEvents.isEmpty()) {
              if (sVerbose) Log.v(TAG, "Nothing to flush");
              return;
          }
          flush(reason);
      }
  
      @Override
      @UiThread
      void flush(@FlushReason int reason) {
          if (mEvents == null) return;
  
          if (mDisabled.get()) {
              Log.e(TAG, "handleForceFlush(" + getDebugState(reason) + "): should not be when "
                      + "disabled");
              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 (sDebug) {
              Log.d(TAG, "Flushing " + numberEvents + " event(s) for " + getDebugState(reason));
          }
          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<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) {
          notifyContextUpdated(mId, context);
      }
  
      /**
       * Resets the buffer and return a {@link ParceledListSlice} with the previous events.
       */
      @NonNull
      @UiThread
      private ParceledListSlice<ContentCaptureEvent> clearEvents() {
          // 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.
          final List<ContentCaptureEvent> events = mEvents == null
                  ? Collections.emptyList()
                  : mEvents;
          mEvents = null;
          return new ParceledListSlice<>(events);
      }
  
      @UiThread
      private void destroySession() {
          if (sDebug) {
              Log.d(TAG, "Destroying session (ctx=" + mContext + ", id=" + mId + ") with "
                      + (mEvents == null ? 0 : mEvents.size()) + " event(s) for "
                      + getDebugState());
          }
  
          try {
              mSystemServerInterface.finishSession(mId);
          } catch (RemoteException e) {
              Log.e(TAG, "Error destroying system-service session " + mId + " for "
                      + getDebugState() + ": " + e);
          }
      }
  
      // TODO(b/122454205): once we support multiple sessions, we might need to move some of these
      // clearings out.
      @UiThread
      private void resetSession(int newState) {
          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;
          mComponentName = null;
          mEvents = null;
          if (mDirectServiceInterface != null) {
              mDirectServiceInterface.asBinder().unlinkToDeath(mDirectServiceVulture, 0);
          }
          mDirectServiceInterface = null;
          mHandler.removeMessages(MSG_FLUSH);
      }
  
      @Override
      void internalNotifyViewAppeared(@NonNull ViewStructureImpl node) {
          notifyViewAppeared(mId, node);
      }
  
      @Override
      void internalNotifyViewDisappeared(@NonNull AutofillId id) {
          notifyViewDisappeared(mId, id);
      }
  
      @Override
      void internalNotifyViewTextChanged(@NonNull AutofillId id, @Nullable CharSequence text) {
          notifyViewTextChanged(mId, id, text);
      }
  
      @Override
      public void internalNotifyViewTreeEvent(boolean started) {
          notifyViewTreeEvent(mId, started);
      }
  
      @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);
      }
  
      // TODO(b/122454205): refactor "notifyXXXX" methods below to a common "Buffer" object that is
      // shared between ActivityContentCaptureSession and ChildContentCaptureSession objects. Such
      // change should also get get rid of the "internalNotifyXXXX" methods above
      void notifyChildSessionStarted(int parentSessionId, int childSessionId,
              @NonNull ContentCaptureContext clientContext) {
          sendEvent(new ContentCaptureEvent(childSessionId, TYPE_SESSION_STARTED)
                  .setParentSessionId(parentSessionId).setClientContext(clientContext),
                  FORCE_FLUSH);
      }
  
      void notifyChildSessionFinished(int parentSessionId, int childSessionId) {
          sendEvent(new ContentCaptureEvent(childSessionId, TYPE_SESSION_FINISHED)
                  .setParentSessionId(parentSessionId), FORCE_FLUSH);
      }
  
      void notifyViewAppeared(int sessionId, @NonNull ViewStructureImpl node) {
          sendEvent(new ContentCaptureEvent(sessionId, TYPE_VIEW_APPEARED)
                  .setViewNode(node.mNode));
      }
  
      /** Public because is also used by ViewRootImpl */
      public void notifyViewDisappeared(int sessionId, @NonNull AutofillId id) {
          sendEvent(new ContentCaptureEvent(sessionId, TYPE_VIEW_DISAPPEARED).setAutofillId(id));
      }
  
      void notifyViewTextChanged(int sessionId, @NonNull AutofillId id, @Nullable CharSequence text) {
          sendEvent(new ContentCaptureEvent(sessionId, TYPE_VIEW_TEXT_CHANGED).setAutofillId(id)
                  .setText(text));
      }
  
      /** Public because is also used by ViewRootImpl */
      public void notifyViewTreeEvent(int sessionId, boolean started) {
          final int type = started ? TYPE_VIEW_TREE_APPEARING : TYPE_VIEW_TREE_APPEARED;
          sendEvent(new ContentCaptureEvent(sessionId, type), FORCE_FLUSH);
      }
  
      /** Public because is also used by ViewRootImpl */
      public void notifySessionLifecycle(boolean started) {
          final int type = started ? TYPE_SESSION_RESUMED : TYPE_SESSION_PAUSED;
          sendEvent(new ContentCaptureEvent(mId, type), FORCE_FLUSH);
      }
  
      void notifyContextUpdated(int sessionId, @Nullable ContentCaptureContext context) {
          sendEvent(new ContentCaptureEvent(sessionId, TYPE_CONTEXT_UPDATED)
                  .setClientContext(context));
      }
  
      @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 (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);
      }
  }
  