Prevent crashes in AndroidFlingSpline#flingPosition

Clamps time position received when calculating the resulting fling position to prevent IndexOutOfBoundsException when the play time happens to be lower than the start time.

This is a workaround, as the playTime is otherwise expected to be non-decreasing for the fling calculation.

Bug: 313685022
Test: AndroidFlingSplineTest#testClampedOutOfRangePosition
Change-Id: Ib1e20d87f1cb1e89aea8194e24841a05df4b280b
diff --git a/compose/animation/animation/src/androidUnitTest/kotlin/androidx/compose/animation/AndroidFlingSplineTest.kt b/compose/animation/animation/src/androidUnitTest/kotlin/androidx/compose/animation/AndroidFlingSplineTest.kt
new file mode 100644
index 0000000..45c7383
--- /dev/null
+++ b/compose/animation/animation/src/androidUnitTest/kotlin/androidx/compose/animation/AndroidFlingSplineTest.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright 2024 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.compose.animation
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.junit.runners.JUnit4
+
+@RunWith(JUnit4::class)
+class AndroidFlingSplineTest {
+
+    @Test
+    fun testClampedOutOfRangePosition() {
+        val controlStart = AndroidFlingSpline.flingPosition(0f)
+        val controlEnd = AndroidFlingSpline.flingPosition(1f)
+
+        assertEquals(controlStart, AndroidFlingSpline.flingPosition(-10f))
+        assertEquals(controlStart, AndroidFlingSpline.flingPosition(-1f))
+        assertEquals(controlStart, AndroidFlingSpline.flingPosition(-0.06f))
+
+        assertEquals(controlEnd, AndroidFlingSpline.flingPosition(1.5f))
+        assertEquals(controlEnd, AndroidFlingSpline.flingPosition(10f))
+    }
+}
diff --git a/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SplineBasedDecay.kt b/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SplineBasedDecay.kt
index 04e8097..8c6e47f 100644
--- a/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SplineBasedDecay.kt
+++ b/compose/animation/animation/src/commonMain/kotlin/androidx/compose/animation/SplineBasedDecay.kt
@@ -87,7 +87,10 @@
      * @param time progress through the fling animation from 0-1
      */
     fun flingPosition(time: Float): FlingResult {
-        val index = (NbSamples * time).toInt()
+        // We clamp the time to prevent crashes from a clock providing playTime values lower than
+        // the start time, which leads to an IOO here. See b/313685022.
+        val clampedTime = time.coerceIn(0f, 1f)
+        val index = (NbSamples * clampedTime).toInt()
         var distanceCoef = 1f
         var velocityCoef = 0f
         if (index < NbSamples) {
@@ -96,7 +99,7 @@
             val dInf = SplinePositions[index]
             val dSup = SplinePositions[index + 1]
             velocityCoef = (dSup - dInf) / (tSup - tInf)
-            distanceCoef = dInf + (time - tInf) * velocityCoef
+            distanceCoef = dInf + (clampedTime - tInf) * velocityCoef
         }
         return FlingResult(distanceCoefficient = distanceCoef, velocityCoefficient = velocityCoef)
     }