Add input consumer selection reason to active gesture logs

Test: manually tried gestures in 3 button and gesture nav and checked logs
Bug: 227514916
Change-Id: I7a48d960ef5a41ddee95153fa08a2345f00cbef7
diff --git a/quickstep/src/com/android/quickstep/TouchInteractionService.java b/quickstep/src/com/android/quickstep/TouchInteractionService.java
index c46926e..12a951f 100644
--- a/quickstep/src/com/android/quickstep/TouchInteractionService.java
+++ b/quickstep/src/com/android/quickstep/TouchInteractionService.java
@@ -94,6 +94,7 @@
 import com.android.quickstep.inputconsumers.SysUiOverlayInputConsumer;
 import com.android.quickstep.inputconsumers.TaskbarStashInputConsumer;
 import com.android.quickstep.util.ActiveGestureLog;
+import com.android.quickstep.util.ActiveGestureLog.CompoundString;
 import com.android.quickstep.util.ProtoTracer;
 import com.android.quickstep.util.ProxyScreenStatusProvider;
 import com.android.quickstep.util.SplitScreenBounds;
@@ -126,6 +127,9 @@
 public class TouchInteractionService extends Service
         implements ProtoTraceable<LauncherTraceProto.Builder> {
 
+    private static final String SUBSTRING_PREFIX = "; ";
+    private static final String NEWLINE_PREFIX = "\n\t\t\t-> ";
+
     private static final String TAG = "TouchInteractionService";
 
     private static final boolean BUBBLES_HOME_GESTURE_ENABLED =
@@ -619,8 +623,6 @@
                 mConsumer.onConsumerAboutToBeSwitched();
                 mGestureState = newGestureState;
                 mConsumer = newConsumer(prevGestureState, mGestureState, event);
-
-                ActiveGestureLog.INSTANCE.addLog("setInputConsumer: " + mConsumer.getName());
                 mUncheckedConsumer = mConsumer;
             } else if (mDeviceState.isUserUnlocked() && mDeviceState.isFullyGesturalNavMode()
                     && mDeviceState.canTriggerAssistantAction(event)) {
@@ -628,8 +630,7 @@
                 // Do not change mConsumer as if there is an ongoing QuickSwitch gesture, we
                 // should not interrupt it. QuickSwitch assumes that interruption can only
                 // happen if the next gesture is also quick switch.
-                mUncheckedConsumer = tryCreateAssistantInputConsumer(
-                        InputConsumer.NO_OP, mGestureState, event);
+                mUncheckedConsumer = tryCreateAssistantInputConsumer(mGestureState, event);
             } else if (mDeviceState.canTriggerOneHandedAction(event)) {
                 // Consume gesture event for triggering one handed feature.
                 mUncheckedConsumer = new OneHandedModeInputConsumer(this, mDeviceState,
@@ -676,12 +677,26 @@
         ProtoTracer.INSTANCE.get(this).scheduleFrameUpdate();
     }
 
-    private InputConsumer tryCreateAssistantInputConsumer(InputConsumer base,
+    private InputConsumer tryCreateAssistantInputConsumer(
             GestureState gestureState, MotionEvent motionEvent) {
-        return mDeviceState.isGestureBlockedTask(gestureState.getRunningTask())
-                ? base
-                : new AssistantInputConsumer(this, gestureState, base, mInputMonitorCompat,
-                        mDeviceState, motionEvent);
+        return tryCreateAssistantInputConsumer(
+                InputConsumer.NO_OP, gestureState, motionEvent, CompoundString.NO_OP);
+    }
+
+    private InputConsumer tryCreateAssistantInputConsumer(
+            InputConsumer base,
+            GestureState gestureState,
+            MotionEvent motionEvent,
+            CompoundString reasonString) {
+        if (mDeviceState.isGestureBlockedTask(gestureState.getRunningTask())) {
+            reasonString.append(SUBSTRING_PREFIX)
+                    .append("is gesture-blocked task, using base input consumer");
+            return base;
+        } else {
+            reasonString.append(SUBSTRING_PREFIX).append("using AssistantInputConsumer");
+            return new AssistantInputConsumer(
+                    this, gestureState, base, mInputMonitorCompat, mDeviceState, motionEvent);
+        }
     }
 
     public GestureState createGestureState(GestureState previousGestureState) {
@@ -699,50 +714,88 @@
         return gestureState;
     }
 
-    private InputConsumer newConsumer(GestureState previousGestureState,
-            GestureState newGestureState, MotionEvent event) {
+    private InputConsumer newConsumer(
+            GestureState previousGestureState, GestureState newGestureState, MotionEvent event) {
         AnimatedFloat progressProxy = mSwipeUpProxyProvider.apply(mGestureState);
         if (progressProxy != null) {
-            return new ProgressDelegateInputConsumer(this, mTaskAnimationManager,
-                    mGestureState, mInputMonitorCompat, progressProxy);
+            InputConsumer consumer = new ProgressDelegateInputConsumer(
+                    this, mTaskAnimationManager, mGestureState, mInputMonitorCompat, progressProxy);
+
+            logInputConsumerSelectionReason(consumer, newCompoundString(
+                    "mSwipeUpProxyProvider has been set, using ProgressDelegateInputConsumer"));
+
+            return consumer;
         }
 
         boolean canStartSystemGesture = mDeviceState.canStartSystemGesture();
 
         if (!mDeviceState.isUserUnlocked()) {
+            CompoundString reasonString = newCompoundString("device locked");
+            InputConsumer consumer;
             if (canStartSystemGesture) {
                 // This handles apps launched in direct boot mode (e.g. dialer) as well as apps
                 // launched while device is locked even after exiting direct boot mode (e.g. camera).
-                return createDeviceLockedInputConsumer(newGestureState);
+                consumer = createDeviceLockedInputConsumer(
+                        newGestureState, reasonString.append(SUBSTRING_PREFIX)
+                                .append("can start system gesture"));
             } else {
-                return getDefaultInputConsumer();
+                consumer = getDefaultInputConsumer(
+                        reasonString.append(SUBSTRING_PREFIX)
+                                .append("cannot start system gesture"));
             }
+            logInputConsumerSelectionReason(consumer, reasonString);
+            return consumer;
         }
 
+        CompoundString reasonString;
+        InputConsumer base;
         // When there is an existing recents animation running, bypass systemState check as this is
         // a followup gesture and the first gesture started in a valid system state.
-        InputConsumer base = canStartSystemGesture
-                || previousGestureState.isRecentsAnimationRunning()
-                        ? newBaseConsumer(previousGestureState, newGestureState, event)
-                        : getDefaultInputConsumer();
+        if (canStartSystemGesture || previousGestureState.isRecentsAnimationRunning()) {
+            reasonString = newCompoundString(canStartSystemGesture
+                    ? "can start system gesture" : "recents animation was running")
+                    .append(", trying to use base consumer");
+            base = newBaseConsumer(previousGestureState, newGestureState, event, reasonString);
+        } else {
+            reasonString = newCompoundString(
+                    "cannot start system gesture and recents animation was not running")
+                    .append(", trying to use default input consumer");
+            base = getDefaultInputConsumer(reasonString);
+        }
         if (mDeviceState.isGesturalNavMode()) {
             handleOrientationSetup(base);
         }
         if (mDeviceState.isFullyGesturalNavMode()) {
+            String reasonPrefix = "device is in gesture navigation mode";
             if (mDeviceState.canTriggerAssistantAction(event)) {
-                base = tryCreateAssistantInputConsumer(base, newGestureState, event);
+                reasonString.append(NEWLINE_PREFIX)
+                        .append(reasonPrefix)
+                        .append(SUBSTRING_PREFIX)
+                        .append("gesture can trigger the assistant")
+                        .append(", trying to use assistant input consumer");
+                base = tryCreateAssistantInputConsumer(base, newGestureState, event, reasonString);
             }
 
             // If Taskbar is present, we listen for long press to unstash it.
             TaskbarActivityContext tac = mTaskbarManager.getCurrentActivityContext();
             if (tac != null) {
+                reasonString.append(NEWLINE_PREFIX)
+                        .append(reasonPrefix)
+                        .append(SUBSTRING_PREFIX)
+                        .append("TaskbarActivityContext != null, using TaskbarStashInputConsumer");
                 base = new TaskbarStashInputConsumer(this, base, mInputMonitorCompat, tac);
             }
 
             if (mDeviceState.isBubblesExpanded()) {
+                reasonString = newCompoundString(reasonPrefix)
+                        .append(SUBSTRING_PREFIX)
+                        .append("bubbles expanded");
                 if (BUBBLES_HOME_GESTURE_ENABLED) {
+                    reasonString.append(SUBSTRING_PREFIX)
+                            .append("bubbles can handle the home gesture")
+                            .append(", trying to use default input consumer");
                     // Bubbles can handle home gesture itself.
-                    base = getDefaultInputConsumer();
+                    base = getDefaultInputConsumer(reasonString);
                 } else {
                     // If Bubbles is expanded, use the overlay input consumer, which will close
                     // Bubbles instead of going all the way home when a swipe up is detected.
@@ -750,6 +803,9 @@
                     // expanded in the back. Make sure swipe up is not passed to bubbles in this
                     // case.
                     if (!mDeviceState.isNotificationPanelExpanded()) {
+                        reasonString = newCompoundString(reasonPrefix)
+                                .append(SUBSTRING_PREFIX)
+                                .append("using SysUiOverlayInputConsumer");
                         base = new SysUiOverlayInputConsumer(
                                 getBaseContext(), mDeviceState, mInputMonitorCompat);
                     }
@@ -757,6 +813,9 @@
             }
 
             if (mDeviceState.isSystemUiDialogShowing()) {
+                reasonString = newCompoundString(reasonPrefix)
+                        .append(SUBSTRING_PREFIX)
+                        .append("system dialog is showing, using SysUiOverlayInputConsumer");
                 base = new SysUiOverlayInputConsumer(
                         getBaseContext(), mDeviceState, mInputMonitorCompat);
             }
@@ -764,44 +823,91 @@
 
 
             if (mDeviceState.isScreenPinningActive()) {
+                reasonString = newCompoundString(reasonPrefix)
+                        .append(SUBSTRING_PREFIX)
+                        .append("screen pinning is active, using ScreenPinnedInputConsumer");
                 // Note: we only allow accessibility to wrap this, and it replaces the previous
                 // base input consumer (which should be NO_OP anyway since topTaskLocked == true).
                 base = new ScreenPinnedInputConsumer(this, newGestureState);
             }
 
             if (mDeviceState.canTriggerOneHandedAction(event)) {
-                base = new OneHandedModeInputConsumer(this, mDeviceState, base,
-                        mInputMonitorCompat);
+                reasonString.append(NEWLINE_PREFIX)
+                        .append(reasonPrefix)
+                        .append(SUBSTRING_PREFIX)
+                        .append("gesture can trigger one handed mode")
+                        .append(", using OneHandedModeInputConsumer");
+                base = new OneHandedModeInputConsumer(
+                        this, mDeviceState, base, mInputMonitorCompat);
             }
 
             if (mDeviceState.isAccessibilityMenuAvailable()) {
-                base = new AccessibilityInputConsumer(this, mDeviceState, base,
-                        mInputMonitorCompat);
+                reasonString.append(NEWLINE_PREFIX)
+                        .append(reasonPrefix)
+                        .append(SUBSTRING_PREFIX)
+                        .append("accessibility menu is available")
+                        .append(", using AccessibilityInputConsumer");
+                base = new AccessibilityInputConsumer(
+                        this, mDeviceState, base, mInputMonitorCompat);
             }
         } else {
+            String reasonPrefix = "device is not in gesture navigation mode";
             if (mDeviceState.isScreenPinningActive()) {
-                base = getDefaultInputConsumer();
+                reasonString = newCompoundString(reasonPrefix)
+                        .append(SUBSTRING_PREFIX)
+                        .append("screen pinning is active, trying to use default input consumer");
+                base = getDefaultInputConsumer(reasonString);
             }
 
             if (mDeviceState.canTriggerOneHandedAction(event)) {
-                base = new OneHandedModeInputConsumer(this, mDeviceState, base,
-                        mInputMonitorCompat);
+                reasonString.append(NEWLINE_PREFIX)
+                        .append(reasonPrefix)
+                        .append(SUBSTRING_PREFIX)
+                        .append("gesture can trigger one handed mode")
+                        .append(", using OneHandedModeInputConsumer");
+                base = new OneHandedModeInputConsumer(
+                        this, mDeviceState, base, mInputMonitorCompat);
             }
         }
+        logInputConsumerSelectionReason(base, reasonString);
         return base;
     }
 
+    private CompoundString newCompoundString(String substring) {
+        return new CompoundString(NEWLINE_PREFIX).append(substring);
+    }
+
+    private void logInputConsumerSelectionReason(
+            InputConsumer consumer, CompoundString reasonString) {
+        if (!FeatureFlags.ENABLE_INPUT_CONSUMER_REASON_LOGGING.get()) {
+            ActiveGestureLog.INSTANCE.addLog("setInputConsumer: " + consumer.getName());
+            return;
+        }
+        ActiveGestureLog.INSTANCE.addLog(new CompoundString("setInputConsumer: ")
+                .append(consumer.getName())
+                .append(". reason(s):")
+                .append(reasonString));
+    }
+
     private void handleOrientationSetup(InputConsumer baseInputConsumer) {
         baseInputConsumer.notifyOrientationSetup();
     }
 
-    private InputConsumer newBaseConsumer(GestureState previousGestureState,
-            GestureState gestureState, MotionEvent event) {
+    private InputConsumer newBaseConsumer(
+            GestureState previousGestureState,
+            GestureState gestureState,
+            MotionEvent event,
+            CompoundString reasonString) {
         if (mDeviceState.isKeyguardShowingOccluded()) {
             // This handles apps showing over the lockscreen (e.g. camera)
-            return createDeviceLockedInputConsumer(gestureState);
+            return createDeviceLockedInputConsumer(
+                    gestureState,
+                    reasonString.append(SUBSTRING_PREFIX)
+                            .append("keyguard is showing occluded")
+                            .append(", trying to use device locked input consumer"));
         }
 
+        reasonString.append(SUBSTRING_PREFIX).append("keyguard is not showing occluded");
         // Use overview input consumer for sharesheets on top of home.
         boolean forceOverviewInputConsumer = gestureState.getActivityInterface().isStarted()
                 && gestureState.getRunningTask() != null
@@ -815,23 +921,46 @@
             forceOverviewInputConsumer = gestureState.getRunningTask().isHomeTask();
         }
 
+        boolean previousGestureAnimatedToLauncher =
+                previousGestureState.isRunningAnimationToLauncher();
+        // with shell-transitions, home is resumed during recents animation, so
+        // explicitly check against recents animation too.
+        boolean launcherResumedThroughShellTransition =
+                gestureState.getActivityInterface().isResumed()
+                        && !previousGestureState.isRecentsAnimationRunning();
         if (ENABLE_QUICKSTEP_LIVE_TILE.get()
                 && gestureState.getActivityInterface().isInLiveTileMode()) {
             return createOverviewInputConsumer(
-                    previousGestureState, gestureState, event, forceOverviewInputConsumer);
+                    previousGestureState,
+                    gestureState,
+                    event,
+                    forceOverviewInputConsumer,
+                    reasonString.append(SUBSTRING_PREFIX)
+                            .append("is in live tile mode, trying to use overview input consumer"));
         } else if (gestureState.getRunningTask() == null) {
-            return getDefaultInputConsumer();
-        } else if (previousGestureState.isRunningAnimationToLauncher()
-                || (gestureState.getActivityInterface().isResumed()
-                        // with shell-transitions, home is resumed during recents animation, so
-                        // explicitly check against recents animation too.
-                        && !previousGestureState.isRecentsAnimationRunning())
+            return getDefaultInputConsumer(reasonString.append(SUBSTRING_PREFIX)
+                    .append("running task == null"));
+        } else if (previousGestureAnimatedToLauncher
+                || launcherResumedThroughShellTransition
                 || forceOverviewInputConsumer) {
             return createOverviewInputConsumer(
-                    previousGestureState, gestureState, event, forceOverviewInputConsumer);
+                    previousGestureState,
+                    gestureState,
+                    event,
+                    forceOverviewInputConsumer,
+                    reasonString.append(SUBSTRING_PREFIX)
+                            .append(previousGestureAnimatedToLauncher
+                                    ? "previous gesture animated to launcher"
+                                    : (launcherResumedThroughShellTransition
+                                            ? "launcher resumed through a shell transition"
+                                            : "forceOverviewInputConsumer == true"))
+                            .append(", trying to use overview input consumer"));
         } else if (mDeviceState.isGestureBlockedTask(gestureState.getRunningTask())) {
-            return getDefaultInputConsumer();
+            return getDefaultInputConsumer(reasonString.append(SUBSTRING_PREFIX)
+                    .append("is gesture-blocked task, trying to use default input consumer"));
         } else {
+            reasonString.append(SUBSTRING_PREFIX)
+                    .append("using OtherActivityInputConsumer");
             return createOtherActivityInputConsumer(gestureState, event);
         }
     }
@@ -853,21 +982,34 @@
                 mInputMonitorCompat, mInputEventReceiver, disableHorizontalSwipe, factory);
     }
 
-    private InputConsumer createDeviceLockedInputConsumer(GestureState gestureState) {
+    private InputConsumer createDeviceLockedInputConsumer(
+            GestureState gestureState, CompoundString reasonString) {
         if (mDeviceState.isFullyGesturalNavMode() && gestureState.getRunningTask() != null) {
-            return new DeviceLockedInputConsumer(this, mDeviceState, mTaskAnimationManager,
-                    gestureState, mInputMonitorCompat);
+            reasonString.append(SUBSTRING_PREFIX)
+                    .append("device is in gesture nav mode and running task != null")
+                    .append(", using DeviceLockedInputConsumer");
+            return new DeviceLockedInputConsumer(
+                    this, mDeviceState, mTaskAnimationManager, gestureState, mInputMonitorCompat);
         } else {
-            return getDefaultInputConsumer();
+            return getDefaultInputConsumer(reasonString
+                    .append(SUBSTRING_PREFIX)
+                    .append(mDeviceState.isFullyGesturalNavMode()
+                        ? "running task == null" : "device is not in gesture nav mode")
+                    .append(", trying to use default input consumer"));
         }
     }
 
-    public InputConsumer createOverviewInputConsumer(GestureState previousGestureState,
-            GestureState gestureState, MotionEvent event,
-            boolean forceOverviewInputConsumer) {
+    public InputConsumer createOverviewInputConsumer(
+            GestureState previousGestureState,
+            GestureState gestureState,
+            MotionEvent event,
+            boolean forceOverviewInputConsumer,
+            CompoundString reasonString) {
         StatefulActivity activity = gestureState.getActivityInterface().getCreatedActivity();
         if (activity == null) {
-            return getDefaultInputConsumer();
+            return getDefaultInputConsumer(
+                    reasonString.append(SUBSTRING_PREFIX)
+                            .append("activity == null, trying to use default input consumer"));
         }
 
         if (activity.getRootView().hasWindowFocus()
@@ -876,9 +1018,13 @@
                     && forceOverviewInputConsumer)
                 || (ENABLE_QUICKSTEP_LIVE_TILE.get()
                 && gestureState.getActivityInterface().isInLiveTileMode())) {
+            reasonString.append(SUBSTRING_PREFIX)
+                    .append("overview should have focus, using OverviewInputConsumer");
             return new OverviewInputConsumer(gestureState, activity, mInputMonitorCompat,
                     false /* startingInActivityBounds */);
         } else {
+            reasonString.append(SUBSTRING_PREFIX).append(
+                    "overview shouldn't have focus, using OverviewWithoutFocusInputConsumer");
             final boolean disableHorizontalSwipe = mDeviceState.isInExclusionRegion(event);
             return new OverviewWithoutFocusInputConsumer(activity, mDeviceState, gestureState,
                     mInputMonitorCompat, disableHorizontalSwipe);
@@ -906,13 +1052,21 @@
         }
     }
 
+    private @NonNull InputConsumer getDefaultInputConsumer() {
+        return getDefaultInputConsumer(CompoundString.NO_OP);
+    }
+
     /**
      * Returns the {@link ResetGestureInputConsumer} if user is unlocked, else NO_OP.
      */
-    private @NonNull InputConsumer getDefaultInputConsumer() {
+    private @NonNull InputConsumer getDefaultInputConsumer(@NonNull CompoundString reasonString) {
         if (mResetGestureInputConsumer != null) {
+            reasonString.append(SUBSTRING_PREFIX).append(
+                    "mResetGestureInputConsumer initialized, using ResetGestureInputConsumer");
             return mResetGestureInputConsumer;
         } else {
+            reasonString.append(SUBSTRING_PREFIX).append(
+                    "mResetGestureInputConsumer not initialized, using no-op input consumer");
             // mResetGestureInputConsumer isn't initialized until onUserUnlocked(), so reset to
             // NO_OP until then (we never want these to be null).
             return InputConsumer.NO_OP;
diff --git a/quickstep/src/com/android/quickstep/util/ActiveGestureLog.java b/quickstep/src/com/android/quickstep/util/ActiveGestureLog.java
index 064395f..bd96217 100644
--- a/quickstep/src/com/android/quickstep/util/ActiveGestureLog.java
+++ b/quickstep/src/com/android/quickstep/util/ActiveGestureLog.java
@@ -15,11 +15,16 @@
  */
 package com.android.quickstep.util;
 
+import androidx.annotation.NonNull;
+
 import java.io.PrintWriter;
 import java.text.SimpleDateFormat;
+import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Date;
+import java.util.List;
 import java.util.Locale;
+import java.util.Objects;
 import java.util.Random;
 
 /**
@@ -40,6 +45,7 @@
     private static final int TYPE_INTEGER = 2;
     private static final int TYPE_BOOL_TRUE = 3;
     private static final int TYPE_BOOL_FALSE = 4;
+    private static final int TYPE_INPUT_CONSUMER = 5;
 
     private final String name;
     private final EventEntry[] logs;
@@ -53,23 +59,29 @@
     }
 
     public void addLog(String event) {
-        addLog(TYPE_ONE_OFF, event, 0);
+        addLog(TYPE_ONE_OFF, event, 0, CompoundString.NO_OP);
     }
 
     public void addLog(String event, int extras) {
-        addLog(TYPE_INTEGER, event, extras);
+        addLog(TYPE_INTEGER, event, extras, CompoundString.NO_OP);
     }
 
     public void addLog(String event, boolean extras) {
-        addLog(extras ? TYPE_BOOL_TRUE : TYPE_BOOL_FALSE, event, 0);
+        addLog(extras ? TYPE_BOOL_TRUE : TYPE_BOOL_FALSE, event, 0, CompoundString.NO_OP);
     }
 
-    private void addLog(int type, String event, float extras) {
+    public void addLog(CompoundString compoundString) {
+        addLog(TYPE_INPUT_CONSUMER, "", 0, compoundString);
+    }
+
+    private void addLog(
+            int type, String event, float extras, @NonNull CompoundString compoundString) {
         // Merge the logs if it's a duplicate
         int last = (nextIndex + logs.length - 1) % logs.length;
         int secondLast = (nextIndex + logs.length - 2) % logs.length;
-        if (isEntrySame(logs[last], type, event) && isEntrySame(logs[secondLast], type, event)) {
-            logs[last].update(type, event, extras, mLogId);
+        if (isEntrySame(logs[last], type, event, compoundString)
+                && isEntrySame(logs[secondLast], type, event, compoundString)) {
+            logs[last].update(type, event, extras, compoundString, mLogId);
             logs[secondLast].duplicateCount++;
             return;
         }
@@ -77,7 +89,7 @@
         if (logs[nextIndex] == null) {
             logs[nextIndex] = new EventEntry();
         }
-        logs[nextIndex].update(type, event, extras, mLogId);
+        logs[nextIndex].update(type, event, extras, compoundString, mLogId);
         nextIndex = (nextIndex + 1) % logs.length;
     }
 
@@ -112,6 +124,9 @@
                 case TYPE_INTEGER:
                     msg.append(": ").append((int) log.extras);
                     break;
+                case TYPE_INPUT_CONSUMER:
+                    msg.append(log.mCompoundString);
+                    break;
                 default: // fall out
             }
             if (log.duplicateCount > 0) {
@@ -129,8 +144,12 @@
         return mLogId;
     }
 
-    private boolean isEntrySame(EventEntry entry, int type, String event) {
-        return entry != null && entry.type == type && entry.event.equals(event);
+    private boolean isEntrySame(
+            EventEntry entry, int type, String event, CompoundString compoundString) {
+        return entry != null
+                && entry.type == type
+                && entry.event.equals(event)
+                && entry.mCompoundString.equals(compoundString);
     }
 
     /** A single event entry. */
@@ -139,17 +158,93 @@
         private int type;
         private String event;
         private float extras;
+        @NonNull private CompoundString mCompoundString;
         private long time;
         private int duplicateCount;
         private int traceId;
 
-        public void update(int type, String event, float extras, int traceId) {
+        public void update(
+                int type,
+                String event,
+                float extras,
+                @NonNull CompoundString compoundString,
+                int traceId) {
             this.type = type;
             this.event = event;
             this.extras = extras;
+            this.mCompoundString = compoundString;
             this.traceId = traceId;
             time = System.currentTimeMillis();
             duplicateCount = 0;
         }
     }
+
+    /** A buildable string stored as an array for memory efficiency. */
+    public static class CompoundString {
+
+        public static final CompoundString NO_OP = new CompoundString();
+
+        private final List<String> mSubstrings;
+
+        private final boolean mIsNoOp;
+
+        private CompoundString() {
+            this(null);
+        }
+
+        public CompoundString(String substring) {
+            mIsNoOp = substring == null;
+            if (mIsNoOp) {
+                mSubstrings = null;
+                return;
+            }
+            mSubstrings = new ArrayList<>();
+            mSubstrings.add(substring);
+        }
+
+        public CompoundString append(CompoundString substring) {
+            if (mIsNoOp) {
+                return this;
+            }
+            mSubstrings.addAll(substring.mSubstrings);
+
+            return this;
+        }
+
+        public CompoundString append(String substring) {
+            if (mIsNoOp) {
+                return this;
+            }
+            mSubstrings.add(substring);
+
+            return this;
+        }
+
+        @Override
+        public String toString() {
+            if (mIsNoOp) {
+                return "ERROR: cannot use No-Op compound string";
+            }
+            StringBuilder sb = new StringBuilder();
+            for (String substring : mSubstrings) {
+                sb.append(substring);
+            }
+
+            return sb.toString();
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(mIsNoOp, mSubstrings);
+        }
+
+        @Override
+        public boolean equals(Object obj) {
+            if (!(obj instanceof CompoundString)) {
+                return false;
+            }
+            CompoundString other = (CompoundString) obj;
+            return mIsNoOp && other.mIsNoOp && Objects.equals(mSubstrings, other.mSubstrings);
+        }
+    }
 }
diff --git a/src/com/android/launcher3/Utilities.java b/src/com/android/launcher3/Utilities.java
index d0dbaf4..616b08a 100644
--- a/src/com/android/launcher3/Utilities.java
+++ b/src/com/android/launcher3/Utilities.java
@@ -75,6 +75,7 @@
 
 import androidx.annotation.ChecksSdkIntAtLeast;
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.core.graphics.ColorUtils;
 
 import com.android.launcher3.dragndrop.FolderAdaptiveIcon;
@@ -925,4 +926,12 @@
         }
         return options;
     }
+
+    public static boolean bothNull(@Nullable Object a, @Nullable Object b) {
+        return a == null && b == null;
+    }
+
+    public static boolean bothNonNull(@Nullable Object a, @Nullable Object b) {
+        return a != null && b != null;
+    }
 }
diff --git a/src/com/android/launcher3/config/FeatureFlags.java b/src/com/android/launcher3/config/FeatureFlags.java
index eabbf60..5bce524 100644
--- a/src/com/android/launcher3/config/FeatureFlags.java
+++ b/src/com/android/launcher3/config/FeatureFlags.java
@@ -63,6 +63,11 @@
      * Declare a new ToggleableFlag below. Give it a unique key (e.g. "QSB_ON_FIRST_SCREEN"),
      *    and set a default value for the flag. This will be the default value on Debug builds.
      */
+    public static final BooleanFlag ENABLE_INPUT_CONSUMER_REASON_LOGGING = getDebugFlag(
+            "ENABLE_INPUT_CONSUMER_REASON_LOGGING",
+            false,
+            "Log the reason why an Input Consumer was selected for a gesture.");
+
     // When enabled the promise icon is visible in all apps while installation an app.
     public static final BooleanFlag PROMISE_APPS_IN_ALL_APPS = getDebugFlag(
             "PROMISE_APPS_IN_ALL_APPS", false, "Add promise icon in all-apps");