Create Fallback Velocity Tracking for AXIS_SCROLL

This change adds a fallback AXIS_SCROLL velocity tracking logic, to
support the axis' velocity tracking for pre-Android-U versions.

To be able to fully offload the velocity tracking work for this AXIS to
the fallback tracker, we also add compat APIs for some VelocityTracker
APIs.

RelNote: adds more compat APIs for Velocity Tracker
Bug: 294895104
Test: manual, unit tests
Change-Id: I327530551bbae8e4594d1b081ce3277bc60efe57
diff --git a/core/core/api/current.txt b/core/core/api/current.txt
index 7ff5c2a..423faf9 100644
--- a/core/core/api/current.txt
+++ b/core/core/api/current.txt
@@ -2885,11 +2885,16 @@
   }
 
   public final class VelocityTrackerCompat {
+    method public static void addMovement(android.view.VelocityTracker, android.view.MotionEvent);
+    method public static void clear(android.view.VelocityTracker);
+    method public static void computeCurrentVelocity(android.view.VelocityTracker, int);
+    method public static void computeCurrentVelocity(android.view.VelocityTracker, int, float);
     method public static float getAxisVelocity(android.view.VelocityTracker, int);
     method public static float getAxisVelocity(android.view.VelocityTracker, int, int);
     method @Deprecated public static float getXVelocity(android.view.VelocityTracker!, int);
     method @Deprecated public static float getYVelocity(android.view.VelocityTracker!, int);
     method public static boolean isAxisSupported(android.view.VelocityTracker, int);
+    method public static void recycle(android.view.VelocityTracker);
   }
 
   public class ViewCompat {
diff --git a/core/core/api/restricted_current.txt b/core/core/api/restricted_current.txt
index 7be35d5..8372c33 100644
--- a/core/core/api/restricted_current.txt
+++ b/core/core/api/restricted_current.txt
@@ -3334,11 +3334,16 @@
   }
 
   public final class VelocityTrackerCompat {
+    method public static void addMovement(android.view.VelocityTracker, android.view.MotionEvent);
+    method public static void clear(android.view.VelocityTracker);
+    method public static void computeCurrentVelocity(android.view.VelocityTracker, int);
+    method public static void computeCurrentVelocity(android.view.VelocityTracker, int, float);
     method public static float getAxisVelocity(android.view.VelocityTracker, @androidx.core.view.VelocityTrackerCompat.VelocityTrackableMotionEventAxis int);
     method public static float getAxisVelocity(android.view.VelocityTracker, @androidx.core.view.VelocityTrackerCompat.VelocityTrackableMotionEventAxis int, int);
     method @Deprecated public static float getXVelocity(android.view.VelocityTracker!, int);
     method @Deprecated public static float getYVelocity(android.view.VelocityTracker!, int);
     method public static boolean isAxisSupported(android.view.VelocityTracker, @androidx.core.view.VelocityTrackerCompat.VelocityTrackableMotionEventAxis int);
+    method public static void recycle(android.view.VelocityTracker);
   }
 
   @IntDef({android.view.MotionEvent.AXIS_X, android.view.MotionEvent.AXIS_Y, android.view.MotionEvent.AXIS_SCROLL}) @RestrictTo(androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX) @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.SOURCE) public static @interface VelocityTrackerCompat.VelocityTrackableMotionEventAxis {
diff --git a/core/core/src/androidTest/java/androidx/core/view/VelocityTrackerCompatTest.java b/core/core/src/androidTest/java/androidx/core/view/VelocityTrackerCompatTest.java
index bb8d29a..8365528 100644
--- a/core/core/src/androidTest/java/androidx/core/view/VelocityTrackerCompatTest.java
+++ b/core/core/src/androidTest/java/androidx/core/view/VelocityTrackerCompatTest.java
@@ -24,6 +24,7 @@
 
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotEquals;
 import static org.junit.Assert.assertTrue;
 
 import android.os.Build;
@@ -60,7 +61,7 @@
      * is added to a tracker. For velocities to be non-zero, we should generally have 2/3 movements,
      * so 4 is a good value to use.
      */
-    private static final int NUM_MOVEMENTS = 4;
+    private static final int NUM_MOVEMENTS = 8;
 
     private VelocityTracker mPlanarTracker;
     private VelocityTracker mScrollTracker;
@@ -97,8 +98,14 @@
             addScrollMotionEvent(2, time, scrollPointer2);
         }
 
-        mPlanarTracker.computeCurrentVelocity(1000);
-        mScrollTracker.computeCurrentVelocity(1000);
+        // Assert that all velocity is 0 before compute is called.
+        assertEquals(0, VelocityTrackerCompat.getAxisVelocity(mPlanarTracker, AXIS_X), 0);
+        assertEquals(0, VelocityTrackerCompat.getAxisVelocity(mPlanarTracker, AXIS_Y), 0);
+        assertEquals(
+                0, VelocityTrackerCompat.getAxisVelocity(mScrollTracker, AXIS_SCROLL), 0);
+
+        VelocityTrackerCompat.computeCurrentVelocity(mPlanarTracker, 1000);
+        VelocityTrackerCompat.computeCurrentVelocity(mScrollTracker, 1000);
     }
 
     @Test
@@ -108,15 +115,12 @@
     }
 
     @Test
-    public void testIsAxisSupported_nonPlanarAxes() {
-        if (Build.VERSION.SDK_INT >= 34) {
-            assertTrue(
-                    VelocityTrackerCompat.isAxisSupported(VelocityTracker.obtain(), AXIS_SCROLL));
-        } else {
-            assertFalse(
-                    VelocityTrackerCompat.isAxisSupported(VelocityTracker.obtain(), AXIS_SCROLL));
-        }
+    public void testIsAxisSupported_axisScroll() {
+        assertTrue(VelocityTrackerCompat.isAxisSupported(VelocityTracker.obtain(), AXIS_SCROLL));
+    }
 
+    @Test
+    public void testIsAxisSupported_nonPlanarAxes() {
         // Check against an axis that has not yet been supported at any Android version.
         assertFalse(VelocityTrackerCompat.isAxisSupported(VelocityTracker.obtain(), AXIS_BRAKE));
     }
@@ -167,14 +171,18 @@
 
     @Test
     public void testGetAxisVelocity_axisScroll_noPointerId() {
-        float compatScrollVelocity =
-                VelocityTrackerCompat.getAxisVelocity(mScrollTracker, AXIS_SCROLL);
+        float compatVelocity = VelocityTrackerCompat.getAxisVelocity(mScrollTracker, AXIS_SCROLL);
 
-        if (Build.VERSION.SDK_INT >= 34) {
-            assertEquals(mScrollTracker.getAxisVelocity(AXIS_SCROLL), compatScrollVelocity, 0);
-        } else {
-            assertEquals(0, compatScrollVelocity, 0);
-        }
+        assertEquals(SCROLL_VEL_POINTER_ID_1 * 1000, compatVelocity, 0);
+
+        compatVelocity = VelocityTrackerCompat.getAxisVelocity(mScrollTracker, AXIS_SCROLL);
+
+        assertEquals(SCROLL_VEL_POINTER_ID_1 * 1000, compatVelocity, 0);
+
+        VelocityTrackerCompat.clear(mScrollTracker);
+        compatVelocity = VelocityTrackerCompat.getAxisVelocity(mScrollTracker, AXIS_SCROLL);
+
+        assertEquals(0, compatVelocity, 0);
     }
 
     @Test
@@ -183,6 +191,7 @@
                 VelocityTrackerCompat.getAxisVelocity(mScrollTracker, AXIS_SCROLL, 2);
 
         if (Build.VERSION.SDK_INT >= 34) {
+            assertNotEquals(0, compatScrollVelocity);
             assertEquals(mScrollTracker.getAxisVelocity(AXIS_SCROLL, 2), compatScrollVelocity, 0);
         } else {
             assertEquals(0, compatScrollVelocity, 0);
@@ -192,7 +201,7 @@
 
     private void addPlanarMotionEvent(int pointerId, long time, float x, float y) {
         MotionEvent ev = MotionEvent.obtain(0L, time, MotionEvent.ACTION_MOVE, x, y, 0);
-        mPlanarTracker.addMovement(ev);
+        VelocityTrackerCompat.addMovement(mPlanarTracker, ev);
         ev.recycle();
     }
     private void addScrollMotionEvent(int pointerId, long time, float scrollAmount) {
@@ -216,7 +225,7 @@
                 0 /* edgeFlags */,
                 InputDevice.SOURCE_ROTARY_ENCODER,
                 0 /* flags */);
-        mScrollTracker.addMovement(ev);
+        VelocityTrackerCompat.addMovement(mScrollTracker, ev);
         ev.recycle();
     }
 }
diff --git a/core/core/src/androidTest/java/androidx/core/view/VelocityTrackerFallbackTest.java b/core/core/src/androidTest/java/androidx/core/view/VelocityTrackerFallbackTest.java
new file mode 100644
index 0000000..11ec1a4
--- /dev/null
+++ b/core/core/src/androidTest/java/androidx/core/view/VelocityTrackerFallbackTest.java
@@ -0,0 +1,261 @@
+/*
+ * Copyright 2023 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 androidx.core.view;
+
+import static androidx.core.view.MotionEventCompat.AXIS_SCROLL;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import android.view.InputDevice;
+import android.view.MotionEvent;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.filters.SmallTest;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+/** Unit tests for {@link VelocityTrackerFallback}. */
+@SmallTest
+@RunWith(AndroidJUnit4.class)
+public class VelocityTrackerFallbackTest {
+
+    private static final float TOLERANCE = 0.05f; // 5% tolerance
+
+    private long mTime;
+    private long mLastTime;
+    private float mScrollAmount;
+    private float mVelocity;
+    private float mAcceleration;
+
+    private VelocityTrackerFallback mTracker;
+
+    @Before
+    public void setup() {
+        mTracker = new VelocityTrackerFallback();
+    }
+
+    @Test
+    public void testLinearMovement() {
+        mVelocity = 3.0f;
+        move(100, 10);
+
+        mTracker.computeCurrentVelocity(/* units= */ 1);
+
+        assertVelocityWithTolerance(mTracker.getAxisVelocity(AXIS_SCROLL), mVelocity);
+    }
+
+    @Test
+    public void testAcceleratingMovement() {
+        mVelocity = 3.0f;
+        mAcceleration = 2.0f;
+        move(200, 10);
+
+        mTracker.computeCurrentVelocity(/* units= */ 1);
+
+        assertVelocityWithTolerance(mTracker.getAxisVelocity(AXIS_SCROLL), mVelocity);
+    }
+
+    @Test
+    public void testDeceleratingMovement() {
+        mVelocity = 7.0f;
+        mAcceleration = -2.0f;
+        move(200, 10);
+
+        mTracker.computeCurrentVelocity(/* units= */ 1);
+
+        assertVelocityWithTolerance(mTracker.getAxisVelocity(AXIS_SCROLL), mVelocity);
+    }
+
+    @Test
+    public void testNegativeVelocity() {
+        mVelocity = -3.0f;
+        move(100, 10);
+
+        mTracker.computeCurrentVelocity(/* units= */ 1);
+
+        assertVelocityWithTolerance(mTracker.getAxisVelocity(AXIS_SCROLL), mVelocity);
+    }
+
+    @Test
+    public void testZeroVelocity() {
+        mVelocity = 0f;
+        move(100, 10);
+
+        mTracker.computeCurrentVelocity(/* units= */ 1);
+
+        assertVelocityWithTolerance(mTracker.getAxisVelocity(AXIS_SCROLL), mVelocity);
+    }
+
+    @Test
+    public void testVelocityDataExpiration() {
+        mVelocity = 20f;
+        move(100, 10);
+        mVelocity = 200f;
+        mTime += 10000;
+        move(10, 10);
+
+        mTracker.computeCurrentVelocity(/* units= */ 1);
+
+        assertThat(mTracker.getAxisVelocity(AXIS_SCROLL)).isEqualTo(200);
+    }
+
+    @Test
+    public void testOneDataPoint() {
+        mVelocity = 30f;
+        move(100, 100);
+
+        mTracker.computeCurrentVelocity(/* units= */ 1);
+
+        assertThat(mTracker.getAxisVelocity(AXIS_SCROLL)).isZero();
+    }
+
+    @Test
+    public void testTwoDataPoints() {
+        mVelocity = 30f;
+        move(100, 30);
+
+        mTracker.computeCurrentVelocity(/* units= */ 1);
+
+        assertVelocityWithTolerance(mTracker.getAxisVelocity(AXIS_SCROLL), mVelocity);
+    }
+
+    @Test
+    public void testPointerMovementStopped() {
+        mVelocity = 30f;
+        move(50, 2);
+        // Add one last event, where the time-step (50ms) exceeds 40ms, which is the pointer stopped
+        // assumption duration.
+        move(50, 50);
+
+        mTracker.computeCurrentVelocity(/* units= */ 1);
+
+        assertThat(mTracker.getAxisVelocity(AXIS_SCROLL)).isZero();
+    }
+
+    @Test
+    public void testUnits() {
+        mVelocity = 3.0f;
+        move(100, 10);
+
+        mTracker.computeCurrentVelocity(/* units= */ 100);
+
+        assertVelocityWithTolerance(mTracker.getAxisVelocity(AXIS_SCROLL), 100 * mVelocity);
+    }
+
+    @Test
+    public void testMaxVelocity() {
+        mVelocity = 3.0f;
+        move(100, 10);
+
+        mTracker.computeCurrentVelocity(/* units= */ 1, /* maxVelocity= */ 2.4f);
+
+        assertThat(mTracker.getAxisVelocity(AXIS_SCROLL)).isEqualTo(2.4f);
+    }
+
+    @Test
+    public void testUnitsWithMaxVelocity() {
+        mVelocity = 3.0f;
+        move(100, 10);
+
+        mTracker.computeCurrentVelocity(/* units= */ 100, /* maxVelocity= */ 75f);
+
+        assertThat(mTracker.getAxisVelocity(AXIS_SCROLL)).isEqualTo(75f);
+    }
+
+    @Test
+    public void testNoMovement() {
+        assertThat(new VelocityTrackerFallback().getAxisVelocity(AXIS_SCROLL)).isZero();
+    }
+
+    @Test
+    public void testTwoMovementsWithSameTime() {
+        addMovement(/* scrollAmount= */ 2, /* time= */ 10);
+        addMovement(/* scrollAmount= */ 200, /* time= */ 10);
+
+        mTracker.computeCurrentVelocity(/* units= */ 1);
+
+        assertThat(mTracker.getAxisVelocity(AXIS_SCROLL)).isZero();
+    }
+
+    @Test
+    public void testMoreDataPointsThanHistorySize() {
+        mVelocity = 7.0f;
+        mAcceleration = 2.0f;
+        move(300, 10); // 30 data points
+
+        mTracker.computeCurrentVelocity(/* units= */ 1);
+
+        assertVelocityWithTolerance(mTracker.getAxisVelocity(AXIS_SCROLL), mVelocity);
+    }
+
+    private void move(long duration, long step) {
+        addMovement();
+        while (duration > 0) {
+            duration -= step;
+            mTime += step;
+
+            mScrollAmount = (mAcceleration / 2 * step + mVelocity) * step;
+            mVelocity += mAcceleration * step;
+            addMovement();
+        }
+    }
+
+    private void addMovement() {
+        if (mTime <= mLastTime) {
+            return;
+        }
+        addMovement(mScrollAmount, mTime);
+        mLastTime = mTime;
+    }
+
+    private void addMovement(float scrollAmount, long time) {
+        MotionEvent.PointerProperties[] props =
+                new MotionEvent.PointerProperties[] {new MotionEvent.PointerProperties()};
+        props[0].id = 0;
+
+        MotionEvent.PointerCoords[] coords =
+                new MotionEvent.PointerCoords[] {new MotionEvent.PointerCoords()};
+        coords[0].setAxisValue(MotionEvent.AXIS_SCROLL, scrollAmount);
+
+        MotionEvent ev =
+                MotionEvent.obtain(
+                        /* downTime= */ 0,
+                        time,
+                        MotionEvent.ACTION_SCROLL,
+                        /* pointerCount= */ 1,
+                        props,
+                        coords,
+                        /* metaState= */ 0,
+                        /* buttonState= */ 0,
+                        /* xPrecision= */ 0,
+                        /* yPrecision= */ 0,
+                        /* deviceId= */ 1,
+                        /* edgeFlags= */ 0,
+                        InputDevice.SOURCE_ROTARY_ENCODER,
+                        /* flags= */ 0);
+        mTracker.addMovement(ev);
+        ev.recycle();
+    }
+
+    private void assertVelocityWithTolerance(float actualVelocity, float expectedVelocity) {
+        assertThat(actualVelocity)
+                .isWithin(Math.abs(TOLERANCE * expectedVelocity))
+                .of(expectedVelocity);
+    }
+}
diff --git a/core/core/src/main/java/androidx/core/view/VelocityTrackerCompat.java b/core/core/src/main/java/androidx/core/view/VelocityTrackerCompat.java
index 23fb371..4bfec10 100644
--- a/core/core/src/main/java/androidx/core/view/VelocityTrackerCompat.java
+++ b/core/core/src/main/java/androidx/core/view/VelocityTrackerCompat.java
@@ -21,16 +21,21 @@
 import static java.lang.annotation.RetentionPolicy.SOURCE;
 
 import android.os.Build;
+import android.view.InputDevice;
 import android.view.MotionEvent;
 import android.view.VelocityTracker;
 
 import androidx.annotation.DoNotInline;
 import androidx.annotation.IntDef;
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
 import androidx.annotation.RestrictTo;
 
 import java.lang.annotation.Retention;
+import java.util.Collections;
+import java.util.Map;
+import java.util.WeakHashMap;
 
 /** Helper for accessing features in {@link VelocityTracker}. */
 public final class VelocityTrackerCompat {
@@ -42,6 +47,37 @@
             MotionEvent.AXIS_SCROLL
     })
     public @interface VelocityTrackableMotionEventAxis {}
+
+    /**
+     * Mapping of platform velocity trackers to their respective fallback.
+     *
+     * <p>This mapping is used to provide a consistent add/clear/getVelocity experience for axes
+     * that may not be supported at a given Android version. Clients can continue to call the
+     * compat's add/clear/compute/getVelocity with the platform tracker instances, and this class
+     * will assign a "fallback" tracker instance for each unique platform tracker instance to
+     * consistently run these operations just as they would run on the platorm instances.
+     *
+     * <p>Since the compat APIs have been provided statically, we will use a singleton compat
+     * instance to manage the mappings whenever we need a "fallback" handling for velocity.
+     *
+     * <p>High level flow for a compat velocity logic for a platform-unsupported axis "A" looks
+     * as follows:
+     *     [1]. add(platformTracker, event):
+     *         [a] Create fallback tracker, and associate it with "platformTracker`.
+     *         [b] Add `event` to the fallback tracker.
+     *     [2]. computeCurrentVelocity(platformTracker, event):
+     *         [a] If there is no associated fallback tracker for `platformTracker`, exit.
+     *         [b] If there's a fallback, compute current velocity for the fallback.
+     *     [3]. getAxisVelocity(platformTracker, axis):
+     *         [a] If there is no associated fallback tracker for `platformTracker`, exit.
+     *         [b] If there's a fallback, return the velocity from the fallback.
+     *     [4]. clear/recycle(platformTracker)
+     *         [a] Remove any association between `platformTracker` and a fallback tracker.
+     *
+     */
+    private static Map<VelocityTracker, VelocityTrackerFallback> sFallbackTrackers =
+            Collections.synchronizedMap(new WeakHashMap<>());
+
     /**
      * Call {@link VelocityTracker#getXVelocity(int)}.
      * If running on a pre-{@link Build.VERSION_CODES#HONEYCOMB} device,
@@ -87,7 +123,9 @@
         if (Build.VERSION.SDK_INT >= 34) {
             return Api34Impl.isAxisSupported(tracker, axis);
         }
-        return axis == MotionEvent.AXIS_X || axis == MotionEvent.AXIS_Y;
+        return axis == MotionEvent.AXIS_SCROLL // Supported via VelocityTrackerFallback.
+                || axis == MotionEvent.AXIS_X // Supported by platform at all API levels.
+                || axis == MotionEvent.AXIS_Y; // Supported by platform at all API levels.
     }
 
     /**
@@ -107,12 +145,22 @@
         if (Build.VERSION.SDK_INT >= 34) {
             return Api34Impl.getAxisVelocity(tracker, axis);
         }
+
+        // For X and Y axes, use the `get*Velocity` APIs that existed at all API levels.
         if (axis == MotionEvent.AXIS_X) {
             return tracker.getXVelocity();
         }
         if (axis == MotionEvent.AXIS_Y) {
             return tracker.getYVelocity();
         }
+
+        // For any other axis before API 34, use the corresponding VelocityTrackerFallback, if any,
+        // to determine the velocity.
+        VelocityTrackerFallback fallback = getFallbackTrackerOrNull(tracker);
+        if (fallback != null) {
+            return fallback.getAxisVelocity(axis);
+        }
+
         return  0;
     }
 
@@ -125,8 +173,9 @@
      * supported since the introduction of this class, the following axes can be candidates for this
      * method:
      * <ul>
-     *   <li> {@link MotionEvent#AXIS_SCROLL}: supported starting
-     *        {@link Build.VERSION_CODES#UPSIDE_DOWN_CAKE}
+     *   <li> {@link MotionEvent#AXIS_SCROLL}: supported via the platform starting
+     *        {@link Build.VERSION_CODES#UPSIDE_DOWN_CAKE}. Supported via a fallback logic at all
+     *        platform levels for the active pointer only.
      * </ul>
      *
      * <p>Before accessing velocities of an axis using this method, check that your
@@ -155,7 +204,92 @@
             return tracker.getYVelocity(pointerId);
         }
         return  0;
+    }
 
+    /** Reset the velocity tracker back to its initial state. */
+    public static void clear(@NonNull VelocityTracker tracker) {
+        tracker.clear();
+        removeFallbackForTracker(tracker);
+    }
+
+    /**
+     * Return a VelocityTracker object back to be re-used by others.  You must not touch the object
+     * after calling this function. That is, don't call any methods on it, or pass it as an input to
+     * any of this class' compat APIs, as the instance is no longer valid for velocity tracking.
+     */
+    public static void recycle(@NonNull VelocityTracker tracker) {
+        tracker.recycle();
+        removeFallbackForTracker(tracker);
+    }
+
+    /**
+     * Compute the current velocity based on the points that have been
+     * collected. Only call this when you actually want to retrieve velocity
+     * information, as it is relatively expensive.  You can then retrieve
+     * the velocity with {@link #getAxisVelocity(VelocityTracker, int)} ()}.
+     *
+     * @param tracker The {@link VelocityTracker} for which to compute velocity.
+     * @param units The units you would like the velocity in.  A value of 1
+     * provides units per millisecond, 1000 provides units per second, etc.
+     * Note that the units referred to here are the same units with which motion is reported. For
+     * axes X and Y, the units are pixels.
+     * @param maxVelocity The maximum velocity that can be computed by this method.
+     * This value must be declared in the same unit as the units parameter. This value
+     * must be positive.
+     */
+    public static void computeCurrentVelocity(
+            @NonNull VelocityTracker tracker, int units, float maxVelocity) {
+        tracker.computeCurrentVelocity(units, maxVelocity);
+        VelocityTrackerFallback fallback = getFallbackTrackerOrNull(tracker);
+        if (fallback != null) {
+            fallback.computeCurrentVelocity(units, maxVelocity);
+        }
+    }
+
+    /**
+     * Equivalent to invoking {@link #computeCurrentVelocity(VelocityTracker, int, float)} with a
+     * maximum velocity of Float.MAX_VALUE.
+     */
+    public static void computeCurrentVelocity(@NonNull VelocityTracker tracker, int units) {
+        VelocityTrackerCompat.computeCurrentVelocity(tracker, units, Float.MAX_VALUE);
+    }
+
+    /**
+     * Add a user's movement to the tracker.
+     *
+     * <p>For pointer events, you should call this for the initial
+     * {@link MotionEvent#ACTION_DOWN}, the following
+     * {@link MotionEvent#ACTION_MOVE} events that you receive, and the final
+     * {@link MotionEvent#ACTION_UP}.  You can, however, call this
+     * for whichever events you desire.
+     *
+     * @param tracker The {@link VelocityTracker} to add the movement to.
+     * @param event The MotionEvent you received and would like to track.
+     */
+    public static void addMovement(@NonNull VelocityTracker tracker, @NonNull MotionEvent event) {
+        tracker.addMovement(event);
+        if (Build.VERSION.SDK_INT >= 34) {
+            // For API levels 34 and above, we currently do not support any compat logic.
+            return;
+        }
+
+        if (event.getSource() == InputDevice.SOURCE_ROTARY_ENCODER) {
+            // We support compat logic for AXIS_SCROLL.
+            // Initialize the compat instance if needed.
+            if (!sFallbackTrackers.containsKey(tracker)) {
+                sFallbackTrackers.put(tracker, new VelocityTrackerFallback());
+            }
+            sFallbackTrackers.get(tracker).addMovement(event);
+        }
+    }
+
+    private static void removeFallbackForTracker(VelocityTracker tracker) {
+        sFallbackTrackers.remove(tracker);
+    }
+
+    @Nullable
+    private static VelocityTrackerFallback getFallbackTrackerOrNull(VelocityTracker tracker) {
+        return sFallbackTrackers.get(tracker);
     }
 
     @RequiresApi(34)
diff --git a/core/core/src/main/java/androidx/core/view/VelocityTrackerFallback.java b/core/core/src/main/java/androidx/core/view/VelocityTrackerFallback.java
new file mode 100644
index 0000000..3743472
--- /dev/null
+++ b/core/core/src/main/java/androidx/core/view/VelocityTrackerFallback.java
@@ -0,0 +1,182 @@
+/*
+ * Copyright 2023 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 androidx.core.view;
+
+import static androidx.core.view.MotionEventCompat.AXIS_SCROLL;
+
+import android.view.MotionEvent;
+
+import androidx.annotation.NonNull;
+
+/**
+ * A fallback implementation of {@link android.view.VelocityTracker}. The methods its provide
+ * mirror the platform's implementation.
+ *
+ * <p>It will be used to provide velocity tracking logic for certain axes that may not be
+ * supported at different API levels, so that {@link VelocityTrackerCompat} can provide compat
+ * service to its clients.
+ *
+ * <p>Currently, it supports AXIS_SCROLL with the default pointer ID.
+ */
+class VelocityTrackerFallback {
+    private static final long RANGE_MS = 100L;
+    private static final int HISTORY_SIZE = 20;
+    /**
+     * If there's no data beyond this period of time, we assume that the previous chain of motion
+     * from the pointer has stopped, and we handle subsequent data points separately.
+     */
+    private static final long ASSUME_POINTER_STOPPED_MS = 40L;
+
+    private final float[] mMovements = new float[HISTORY_SIZE];
+    private final long[] mEventTimes = new long[HISTORY_SIZE];
+
+    /** Cached value of the last computed velocity, for a O(1) get operation. */
+    private float mLastComputedVelocity = 0f;
+
+    /** Number of data points that are potential velocity calculation candidates. */
+    private int mDataPointsBufferSize = 0;
+    /**
+     * The last index in the circular buffer where a data point was added. Irrelevant if {@code
+     * dataPointsBufferSize} == 0.
+     */
+    private int mDataPointsBufferLastUsedIndex = 0;
+
+    /** Adds a motion for velocity tracking. */
+    void addMovement(@NonNull MotionEvent event) {
+        long eventTime = event.getEventTime();
+        if (mDataPointsBufferSize != 0
+                && (eventTime - mEventTimes[mDataPointsBufferLastUsedIndex]
+                > ASSUME_POINTER_STOPPED_MS)) {
+            // There has been at least `ASSUME_POINTER_STOPPED_MS` since the last recorded event.
+            // When this happens, consider that the pointer has stopped until this new event. Thus,
+            // clear all past events.
+            clear();
+        }
+
+        mDataPointsBufferLastUsedIndex = (mDataPointsBufferLastUsedIndex + 1) % HISTORY_SIZE;
+        // We do not need to increase size if the size is already `HISTORY_SIZE`, since we always
+        // will  have at most `HISTORY_SIZE` data points stored, due to the circular buffer.
+        if (mDataPointsBufferSize != HISTORY_SIZE) {
+            mDataPointsBufferSize += 1;
+        }
+
+        mMovements[mDataPointsBufferLastUsedIndex] = event.getAxisValue(AXIS_SCROLL);
+        mEventTimes[mDataPointsBufferLastUsedIndex] = eventTime;
+    }
+
+    /** Same as {@link #computeCurrentVelocity} with {@link Float#MAX_VALUE} as the max velocity. */
+    void computeCurrentVelocity(int units) {
+        computeCurrentVelocity(units, Float.MAX_VALUE);
+    }
+
+    /** Computes the current velocity with the given unit and max velocity. */
+    void computeCurrentVelocity(int units, float maxVelocity) {
+        mLastComputedVelocity = getCurrentVelocity() * units;
+
+        // Fix the velocity as per the max velocity
+        // (i.e. clamp it between [-maxVelocity, maxVelocity])
+        if (mLastComputedVelocity < -Math.abs(maxVelocity)) {
+            mLastComputedVelocity = -Math.abs(maxVelocity);
+        } else if (mLastComputedVelocity > Math.abs(maxVelocity)) {
+            mLastComputedVelocity = Math.abs(maxVelocity);
+        }
+    }
+
+    /** Returns the computed velocity for the given {@code axis}. */
+    float getAxisVelocity(int axis) {
+        if (axis != AXIS_SCROLL) {
+            return 0;
+        }
+        return mLastComputedVelocity;
+    }
+
+    private void clear() {
+        mDataPointsBufferSize = 0;
+        mLastComputedVelocity = 0;
+    }
+
+    private float getCurrentVelocity() {
+        // At least 2 data points needed to get Impulse velocity.
+        if (mDataPointsBufferSize < 2) {
+            return 0f;
+        }
+
+        // The first valid index that contains a data point that should be part of the velocity
+        // calculation, as long as it's within `RANGE_MS` from the latest data point.
+        int firstValidIndex =
+                (mDataPointsBufferLastUsedIndex + HISTORY_SIZE - (mDataPointsBufferSize - 1))
+                        % HISTORY_SIZE;
+        long lastEventTime = mEventTimes[mDataPointsBufferLastUsedIndex];
+        while (lastEventTime - mEventTimes[firstValidIndex] > RANGE_MS) {
+            // Decrementing the size is equivalent to practically "removing" this data point.
+            mDataPointsBufferSize--;
+            // Increment the `firstValidIndex`, since we just found out that the current
+            // `firstValidIndex` is not valid (not within `RANGE_MS`).
+            firstValidIndex = (firstValidIndex + 1) % HISTORY_SIZE;
+        }
+
+        // At least 2 data points needed to get Impulse velocity.
+        if (mDataPointsBufferSize < 2) {
+            return 0;
+        }
+
+        if (mDataPointsBufferSize == 2) {
+            int lastIndex = (firstValidIndex + 1) % HISTORY_SIZE;
+            if (mEventTimes[firstValidIndex] == mEventTimes[lastIndex]) {
+                return 0f;
+            }
+            return mMovements[lastIndex] / (mEventTimes[lastIndex] - mEventTimes[firstValidIndex]);
+        }
+
+        float work = 0;
+        int numDataPointsProcessed = 0;
+        // Loop from the `firstValidIndex`, to the "second to last" valid index. We need to go only
+        // to the "second to last" element, since the body of the loop checks against the next data
+        // point, so we cannot go all the way to the end.
+        for (int i = 0; i < mDataPointsBufferSize - 1; i++) {
+            int currentIndex = i + firstValidIndex;
+            long eventTime = mEventTimes[currentIndex % HISTORY_SIZE];
+            int nextIndex = (currentIndex + 1) % HISTORY_SIZE;
+
+            // Duplicate timestamp. Skip this data point.
+            if (mEventTimes[nextIndex] == eventTime) {
+                continue;
+            }
+
+            numDataPointsProcessed++;
+            float vPrev = kineticEnergyToVelocity(work);
+            float delta = mMovements[nextIndex];
+            float vCurr = delta / (mEventTimes[nextIndex] - eventTime);
+
+            work += (vCurr - vPrev) * Math.abs(vCurr);
+
+            // Note that we are intentionally checking against `numDataPointsProcessed`, instead of
+            // just checking `i` against `firstValidIndex`. This is to cover cases where there are
+            // multiple data points that have the same timestamp as the one at `firstValidIndex`.
+            if (numDataPointsProcessed == 1) {
+                work = work * 0.5f;
+            }
+        }
+
+        return kineticEnergyToVelocity(work);
+    }
+
+    /** Based on the formula: Kinetic Energy = (0.5 * mass * velocity^2), with mass = 1. */
+    private static float kineticEnergyToVelocity(float work) {
+        return (work < 0 ? -1.0f : 1.0f) * (float) Math.sqrt(2f * Math.abs(work));
+    }
+}
diff --git a/recyclerview/recyclerview/src/main/java/androidx/recyclerview/widget/DifferentialMotionFlingHelper.java b/recyclerview/recyclerview/src/main/java/androidx/recyclerview/widget/DifferentialMotionFlingHelper.java
index f7abfd5..1fae9e2 100644
--- a/recyclerview/recyclerview/src/main/java/androidx/recyclerview/widget/DifferentialMotionFlingHelper.java
+++ b/recyclerview/recyclerview/src/main/java/androidx/recyclerview/widget/DifferentialMotionFlingHelper.java
@@ -233,8 +233,8 @@
     }
 
     private static float getCurrentVelocity(VelocityTracker vt, MotionEvent event, int axis) {
-        vt.addMovement(event);
-        vt.computeCurrentVelocity(1000);
+        VelocityTrackerCompat.addMovement(vt, event);
+        VelocityTrackerCompat.computeCurrentVelocity(vt, 1000);
         return VelocityTrackerCompat.getAxisVelocity(vt, axis);
     }
 }