Initial buffering of Content Capture events.

IntelligenceManager must buffer ContentCapture events and send them to the
service in a batch, and this is the initial implementation of such batch:
it's just batching a pre-defined number of events, without any further
optimization (like flushing after x ms).

Test: manual verification

Bug: 111276913
Bug: 119220549

Change-Id: I96a4708fd3fcfd3098a0894a3ae3e967804cf4e6
diff --git a/core/java/android/view/intelligence/IntelligenceManager.java b/core/java/android/view/intelligence/IntelligenceManager.java
index 755c54c..2f3b4ef 100644
--- a/core/java/android/view/intelligence/IntelligenceManager.java
+++ b/core/java/android/view/intelligence/IntelligenceManager.java
@@ -39,7 +39,6 @@
 import android.view.autofill.AutofillId;
 import android.view.intelligence.ContentCaptureEvent.EventType;
 
-import com.android.internal.annotations.GuardedBy;
 import com.android.internal.os.IResultReceiver;
 import com.android.internal.util.Preconditions;
 
@@ -47,10 +46,18 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.Set;
+import java.util.concurrent.atomic.AtomicBoolean;
 
 /**
  * TODO(b/111276913): add javadocs / implement
  */
+/*
+ * NOTE: all methods in this class should return right away, or do the real work in a handler
+ * thread.
+ *
+ * Hence, the only field that must be thread-safe is mEnabled, which is called at the beginning
+ * of every method.
+ */
 @SystemService(Context.INTELLIGENCE_MANAGER_SERVICE)
 public final class IntelligenceManager {
 
@@ -97,48 +104,48 @@
     private static final String BG_THREAD_NAME = "intel_svc_streamer_thread";
 
     /**
-     * Maximum number of events that are delayed for an app.
-     *
-     * <p>If the session is not started after the limit is reached, it's discarded.
+     * Maximum number of events that are buffered before sent to the app.
      */
-    private static final int MAX_DELAYED_SIZE = 20;
+    // TODO(b/111276913): use settings
+    private static final int MAX_BUFFER_SIZE = 100;
 
+    @NonNull
+    private final AtomicBoolean mDisabled = new AtomicBoolean();
+
+    @NonNull
     private final Context mContext;
 
     @Nullable
     private final IIntelligenceManager mService;
 
-    private final Object mLock = new Object();
-
     @Nullable
-    @GuardedBy("mLock")
     private InteractionSessionId mId;
 
-    @GuardedBy("mLock")
     private int mState = STATE_UNKNOWN;
 
-    @GuardedBy("mLock")
+    @Nullable
     private IBinder mApplicationToken;
 
-    // TODO(b/111276913): replace by an interface name implemented by Activity, similar to
-    // AutofillClient
-    @GuardedBy("mLock")
+    @Nullable
     private ComponentName mComponentName;
 
-    // TODO(b/111276913): create using maximum batch size as capacity
     /**
      * List of events held to be sent as a batch.
      */
-    @GuardedBy("mLock")
-    private final ArrayList<ContentCaptureEvent> mEvents = new ArrayList<>();
+    @Nullable
+    private ArrayList<ContentCaptureEvent> mEvents;
 
+    // TODO(b/111276913): use UI Thread directly (as calls are one-way) or a shared thread / handler
+    // held at the Application level
     private final Handler mHandler;
 
     /** @hide */
     public IntelligenceManager(@NonNull Context context, @Nullable IIntelligenceManager service) {
         mContext = Preconditions.checkNotNull(context, "context cannot be null");
+        if (VERBOSE) {
+            Log.v(TAG, "Constructor for " + context.getPackageName());
+        }
         mService = service;
-
         // TODO(b/111276913): use an existing bg thread instead...
         final HandlerThread bgThread = new HandlerThread(BG_THREAD_NAME);
         bgThread.start();
@@ -149,102 +156,100 @@
     public void onActivityCreated(@NonNull IBinder token, @NonNull ComponentName componentName) {
         if (!isContentCaptureEnabled()) return;
 
-        synchronized (mLock) {
-            if (mState != STATE_UNKNOWN) {
-                // TODO(b/111276913): revisit this scenario
-                Log.w(TAG, "ignoring onActivityStarted(" + token + ") while on state "
-                        + getStateAsString(mState));
-                return;
-            }
-            mState = STATE_WAITING_FOR_SERVER;
-            mId = new InteractionSessionId();
-            mApplicationToken = token;
-            mComponentName = componentName;
+        mHandler.sendMessage(obtainMessage(IntelligenceManager::handleStartSession, this,
+                token, componentName));
+    }
 
-            if (VERBOSE) {
-                Log.v(TAG, "onActivityCreated(): token=" + token + ", act="
-                        + getActivityDebugNameLocked() + ", id=" + mId);
-            }
-            final int flags = 0; // TODO(b/111276913): get proper flags
+    private void handleStartSession(@NonNull IBinder token, @NonNull ComponentName componentName) {
+        if (mState != STATE_UNKNOWN) {
+            // TODO(b/111276913): revisit this scenario
+            Log.w(TAG, "ignoring handleStartSession(" + token + ") while on state "
+                    + getStateAsString(mState));
+            return;
+        }
+        mState = STATE_WAITING_FOR_SERVER;
+        mId = new InteractionSessionId();
+        mApplicationToken = token;
+        mComponentName = componentName;
 
-            try {
-                mService.startSession(mContext.getUserId(), mApplicationToken, componentName,
-                        mId, flags, new IResultReceiver.Stub() {
-                            @Override
-                            public void send(int resultCode, Bundle resultData)
-                                    throws RemoteException {
-                                synchronized (mLock) {
-                                    mState = resultCode;
-                                    if (VERBOSE) {
-                                        Log.v(TAG, "onActivityStarted() result: code=" + resultCode
-                                                + ", id=" + mId
-                                                + ", state=" + getStateAsString(mState));
-                                    }
-                                }
-                            }
-                        });
-            } catch (RemoteException e) {
-                throw e.rethrowFromSystemServer();
-            }
+        if (VERBOSE) {
+            Log.v(TAG, "handleStartSession(): token=" + token + ", act="
+                    + getActivityDebugName() + ", id=" + mId);
+        }
+        final int flags = 0; // TODO(b/111276913): get proper flags
+
+        try {
+            mService.startSession(mContext.getUserId(), mApplicationToken, componentName,
+                    mId, flags, new IResultReceiver.Stub() {
+                        @Override
+                        public void send(int resultCode, Bundle resultData) {
+                            handleSessionStarted(resultCode);
+                        }
+                    });
+        } catch (RemoteException e) {
+            Log.w(TAG, "Error starting session for " + componentName.flattenToShortString() + ": "
+                    + e);
         }
     }
 
-    //TODO(b/111276913): should buffer event (and call service on handler thread), instead of
-    // calling right away
-    private void sendEvent(@NonNull ContentCaptureEvent event) {
-        mHandler.sendMessage(obtainMessage(IntelligenceManager::handleSendEvent, this, event));
+    private  void handleSessionStarted(int resultCode) {
+        mState = resultCode;
+        mDisabled.set(mState == STATE_DISABLED);
+        if (VERBOSE) {
+            Log.v(TAG, "onActivityStarted() result: code=" + resultCode + ", id=" + mId
+                    + ", state=" + getStateAsString(mState) + ", disabled=" + mDisabled.get());
+        }
     }
 
-    private void handleSendEvent(@NonNull ContentCaptureEvent event) {
-
-        //TODO(b/111276913): make a copy and don't use lock
-        synchronized (mLock) {
-            mEvents.add(event);
-            final int numberEvents = mEvents.size();
-            if (mState != STATE_ACTIVE) {
-                if (numberEvents >= MAX_DELAYED_SIZE) {
-                    // Typically happens on system apps that are started before the system service
-                    // is ready (like com.android.settings/.FallbackHome)
-                    //TODO(b/111276913): 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 (VERBOSE) {
-                        Log.v(TAG, "Closing session for " + getActivityDebugNameLocked()
-                                + " after " + numberEvents + " delayed events and state "
-                                + getStateAsString(mState));
-                    }
-                    // TODO(b/111276913): blacklist activity / use special flag to indicate that
-                    // when it's launched again
-                    resetStateLocked();
-                    return;
-                }
-
-                if (VERBOSE) {
-                    Log.v(TAG, "Delaying " + numberEvents + " events for "
-                            + getActivityDebugNameLocked() + " while on state "
-                            + getStateAsString(mState));
-                }
-                return;
+    private void handleSendEvent(@NonNull ContentCaptureEvent event, boolean forceFlush) {
+        if (mEvents == null) {
+            if (VERBOSE) {
+                Log.v(TAG, "Creating buffer for " + MAX_BUFFER_SIZE + " events");
             }
+            mEvents = new ArrayList<>(MAX_BUFFER_SIZE);
+        }
+        mEvents.add(event);
+        final int numberEvents = mEvents.size();
+        if (numberEvents < MAX_BUFFER_SIZE && !forceFlush) {
+            // Buffering events, return right away...
+            return;
+        }
 
-            if (mId == null) {
-                // Sanity check - should not happen
-                Log.wtf(TAG, "null session id for " + mComponentName);
-                return;
+        if (mState != STATE_ACTIVE) {
+            // Callback from startSession hasn't been called yet - typically happens on system
+            // apps that are started before the system service
+            // TODO(b/111276913): 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 (VERBOSE) {
+                Log.v(TAG, "Closing session for " + getActivityDebugName()
+                        + " after " + numberEvents + " delayed events and state "
+                        + getStateAsString(mState));
             }
+            handleResetState();
+            // TODO(b/111276913): blacklist activity / use special flag to indicate that
+            // when it's launched again
+            return;
+        }
 
-            //TODO(b/111276913): right now we're sending sending right away (unless not ready), but
-            // we should hold the events and flush later.
-            try {
-                if (DEBUG) {
-                    Log.d(TAG, "Sending " + numberEvents + " event(s) for "
-                            + getActivityDebugNameLocked());
-                }
-                mService.sendEvents(mContext.getUserId(), mId, mEvents);
-                mEvents.clear();
-            } catch (RemoteException e) {
-                throw e.rethrowFromSystemServer();
+        if (mId == null) {
+            // Sanity check - should not happen
+            Log.wtf(TAG, "null session id for " + getActivityDebugName());
+            return;
+        }
+
+        try {
+            if (DEBUG) {
+                Log.d(TAG, "Flushing " + numberEvents + " event(s) for " + getActivityDebugName());
             }
+            mService.sendEvents(mContext.getUserId(), mId, mEvents);
+            // TODO(b/111276913): decide whether we should clear or set it to null, as each has
+            // its own advantages: clearing will save extra allocations while the session is
+            // active, while setting to null would save memory if there's no more event coming.
+            mEvents.clear();
+        } catch (RemoteException e) {
+            Log.w(TAG, "Error sending " + numberEvents + " for " + getActivityDebugName()
+                    + ": " + e);
         }
     }
 
@@ -256,41 +261,54 @@
     public void onActivityLifecycleEvent(@EventType int type) {
         if (!isContentCaptureEnabled()) return;
         if (VERBOSE) {
-            Log.v(TAG, "onActivityLifecycleEvent() for " + getActivityDebugNameLocked()
+            Log.v(TAG, "onActivityLifecycleEvent() for " + getActivityDebugName()
                     + ": " + ContentCaptureEvent.getTypeAsString(type));
         }
-        sendEvent(new ContentCaptureEvent(type));
+        mHandler.sendMessage(obtainMessage(IntelligenceManager::handleSendEvent, this,
+                new ContentCaptureEvent(type), /* forceFlush= */ true));
     }
 
     /** @hide */
     public void onActivityDestroyed() {
         if (!isContentCaptureEnabled()) return;
 
-        synchronized (mLock) {
-            //TODO(b/111276913): check state (for example, how to handle if it's waiting for remote
-            // id) and send it to the cache of batched commands
+        //TODO(b/111276913): check state (for example, how to handle if it's waiting for remote
+        // id) and send it to the cache of batched commands
+        if (VERBOSE) {
+            Log.v(TAG, "onActivityDestroyed(): state=" + getStateAsString(mState)
+                    + ", mId=" + mId);
+        }
 
-            if (VERBOSE) {
-                Log.v(TAG, "onActivityDestroyed(): state=" + getStateAsString(mState)
-                        + ", mId=" + mId);
+        mHandler.sendMessage(obtainMessage(IntelligenceManager::handleFinishSession, this));
+    }
+
+    private void handleFinishSession() {
+        //TODO(b/111276913): right now both the ContentEvents and lifecycle sessions are sent
+        // to system_server, so it's ok to call both in sequence here. But once we split
+        // them so the events are sent directly to the service, we need to make sure they're
+        // sent in order.
+        try {
+            if (DEBUG) {
+                Log.d(TAG, "Finishing session " + mId + " with "
+                        + (mEvents == null ? 0 : mEvents.size()) + " event(s) for "
+                        + getActivityDebugName());
             }
 
-            try {
-                mService.finishSession(mContext.getUserId(), mId);
-                resetStateLocked();
-            } catch (RemoteException e) {
-                throw e.rethrowFromSystemServer();
-            }
+            mService.finishSession(mContext.getUserId(), mId, mEvents);
+        } catch (RemoteException e) {
+            Log.e(TAG, "Error finishing session " + mId + " for " + getActivityDebugName()
+                    + ": " + e);
+        } finally {
+            handleResetState();
         }
     }
 
-    @GuardedBy("mLock")
-    private void resetStateLocked() {
+    private void handleResetState() {
         mState = STATE_UNKNOWN;
         mId = null;
         mApplicationToken = null;
         mComponentName = null;
-        mEvents.clear();
+        mEvents = null;
     }
 
     /**
@@ -309,8 +327,11 @@
         if (!(node instanceof ViewNode.ViewStructureImpl)) {
             throw new IllegalArgumentException("Invalid node class: " + node.getClass());
         }
-        sendEvent(new ContentCaptureEvent(TYPE_VIEW_APPEARED)
-                .setViewNode(((ViewNode.ViewStructureImpl) node).mNode));
+
+        mHandler.sendMessage(obtainMessage(IntelligenceManager::handleSendEvent, this,
+                new ContentCaptureEvent(TYPE_VIEW_APPEARED)
+                        .setViewNode(((ViewNode.ViewStructureImpl) node).mNode),
+                        /* forceFlush= */ false));
     }
 
     /**
@@ -325,7 +346,9 @@
         Preconditions.checkNotNull(id);
         if (!isContentCaptureEnabled()) return;
 
-        sendEvent(new ContentCaptureEvent(TYPE_VIEW_DISAPPEARED).setAutofillId(id));
+        mHandler.sendMessage(obtainMessage(IntelligenceManager::handleSendEvent, this,
+                new ContentCaptureEvent(TYPE_VIEW_DISAPPEARED).setAutofillId(id),
+                        /* forceFlush= */ false));
     }
 
     /**
@@ -339,10 +362,12 @@
     public void notifyViewTextChanged(@NonNull AutofillId id, @Nullable CharSequence text,
             int flags) {
         Preconditions.checkNotNull(id);
+
         if (!isContentCaptureEnabled()) return;
 
-        sendEvent(new ContentCaptureEvent(TYPE_VIEW_TEXT_CHANGED, flags).setAutofillId(id)
-                .setText(text));
+        mHandler.sendMessage(obtainMessage(IntelligenceManager::handleSendEvent, this,
+                new ContentCaptureEvent(TYPE_VIEW_TEXT_CHANGED, flags).setAutofillId(id)
+                        .setText(text), /* forceFlush= */ false));
     }
 
     /**
@@ -384,10 +409,7 @@
      * Checks whether content capture is enabled for this activity.
      */
     public boolean isContentCaptureEnabled() {
-        //TODO(b/111276913): properly implement by checking if it was explicitly disabled by
-        // service, or if service is not set
-        // (and probably renamign to isEnabledLocked()
-        return mService != null && mState != STATE_DISABLED;
+        return mService != null && !mDisabled.get();
     }
 
     /**
@@ -504,25 +526,36 @@
     public void dump(String prefix, PrintWriter pw) {
         pw.print(prefix); pw.println("IntelligenceManager");
         final String prefix2 = prefix + "  ";
-        synchronized (mLock) {
-            pw.print(prefix2); pw.print("mContext: "); pw.println(mContext);
+        pw.print(prefix2); pw.print("mContext: "); pw.println(mContext);
+        pw.print(prefix2); pw.print("user: "); pw.println(mContext.getUserId());
+        if (mService != null) {
             pw.print(prefix2); pw.print("mService: "); pw.println(mService);
-            pw.print(prefix2); pw.print("user: "); pw.println(mContext.getUserId());
-            pw.print(prefix2); pw.print("enabled: "); pw.println(isContentCaptureEnabled());
+        }
+        pw.print(prefix2); pw.print("mDisabled: "); pw.println(mDisabled.get());
+        pw.print(prefix2); pw.print("isEnabled(): "); pw.println(isContentCaptureEnabled());
+        if (mId != null) {
             pw.print(prefix2); pw.print("id: "); pw.println(mId);
-            pw.print(prefix2); pw.print("state: "); pw.print(mState); pw.print(" (");
-            pw.print(getStateAsString(mState)); pw.println(")");
+        }
+        pw.print(prefix2); pw.print("state: "); pw.print(mState); pw.print(" (");
+        pw.print(getStateAsString(mState)); pw.println(")");
+        if (mApplicationToken != null) {
             pw.print(prefix2); pw.print("app token: "); pw.println(mApplicationToken);
+        }
+        if (mComponentName != null) {
             pw.print(prefix2); pw.print("component name: ");
-            pw.println(mComponentName == null ? "null" : mComponentName.flattenToShortString());
+            pw.println(mComponentName.flattenToShortString());
+        }
+        if (mEvents != null) {
             final int numberEvents = mEvents.size();
-            pw.print(prefix2); pw.print("batched events: "); pw.println(numberEvents);
-            if (numberEvents > 0) {
+            pw.print(prefix2); pw.print("batched events: "); pw.print(numberEvents);
+            pw.print('/'); pw.println(MAX_BUFFER_SIZE);
+            if (VERBOSE && numberEvents > 0) {
+                final String prefix3 = prefix2 + "  ";
                 for (int i = 0; i < numberEvents; i++) {
                     final ContentCaptureEvent event = mEvents.get(i);
-                    pw.println(i); pw.print(": "); event.dump(pw); pw.println();
+                    pw.print(prefix3); pw.print(i); pw.print(": "); event.dump(pw);
+                    pw.println();
                 }
-
             }
         }
     }
@@ -530,8 +563,7 @@
     /**
      * Gets a string that can be used to identify the activity on logging statements.
      */
-    @GuardedBy("mLock")
-    private String getActivityDebugNameLocked() {
+    private String getActivityDebugName() {
         return mComponentName == null ? mContext.getPackageName()
                 : mComponentName.flattenToShortString();
     }