Fixed possibility of slideIndex being greater than slideCount

There are two issues addressed here
* `AutoScrollSideEffect` has a `LaunchedEffect` which would not get
  updates to slideCount. Addressed this by using rememberUpdatedState.
* The slide-count could change when `AnimatedContent` is animating
  from one slide to the next. Addressed this by verifying the
  slideIndex against slideCount.

Bug: b/266160424

Test: Added a unit-test that fails without the current fix.
Change-Id: Idd3e84fcd30eec71b185a36cd6cafb59e871eb89
diff --git a/tv/tv-material/src/androidTest/java/androidx/tv/material3/CarouselTest.kt b/tv/tv-material/src/androidTest/java/androidx/tv/material3/CarouselTest.kt
index adb434b..5e2da37 100644
--- a/tv/tv-material/src/androidTest/java/androidx/tv/material3/CarouselTest.kt
+++ b/tv/tv-material/src/androidTest/java/androidx/tv/material3/CarouselTest.kt
@@ -65,6 +65,7 @@
 import androidx.compose.ui.unit.dp
 import androidx.test.platform.app.InstrumentationRegistry
 import com.google.common.truth.Truth.assertThat
+import kotlinx.coroutines.delay
 import org.junit.Rule
 import org.junit.Test
 
@@ -619,6 +620,7 @@
         rule.onNodeWithText("Play ${finalSlide + 3}").assertIsFocused()
     }
 
+    @Test
     fun carousel_manualScrolling_onDpadLongPress() {
         rule.setContent {
             SampleCarousel(slideCount = 6) { index ->
@@ -757,6 +759,39 @@
         // Assert that slide 1 is in view
         rule.onNodeWithText("Button 1").assertIsDisplayed()
     }
+
+    @Test
+    fun carousel_slideCountChangesDuringAnimation_shouldNotCrash() {
+        val slideDisplayDurationMs: Long = 100
+        var slideChanges = 0
+        // number of slides will fall from 4 to 2, but 4 slide transitions should happen without a
+        // crash
+        val minSuccessfulSlideChanges = 4
+        rule.setContent {
+            var slideCount by remember { mutableStateOf(4) }
+            LaunchedEffect(Unit) {
+                while (slideCount >= 2) {
+                    delay(slideDisplayDurationMs)
+                    slideCount--
+                }
+            }
+            SampleCarousel(
+                slideCount = slideCount,
+                timeToDisplaySlideMillis = slideDisplayDurationMs
+            ) { index ->
+                if (index >= slideCount) {
+                    // slideIndex requested should not be greater than slideCount. User could be
+                    // using a data-structure that could throw an IndexOutOfBoundsException.
+                    // This can happen when the slideCount changes during the transition between
+                    // slides.
+                    throw Exception("Index is larger, index=$index, slideCount=$slideCount")
+                }
+                slideChanges++
+            }
+        }
+
+        rule.waitUntil(timeoutMillis = 5000) { slideChanges > minSuccessfulSlideChanges }
+    }
 }
 
 @OptIn(ExperimentalTvMaterial3Api::class)
diff --git a/tv/tv-material/src/main/java/androidx/tv/material3/Carousel.kt b/tv/tv-material/src/main/java/androidx/tv/material3/Carousel.kt
index 70c5480..2d04b43 100644
--- a/tv/tv-material/src/main/java/androidx/tv/material3/Carousel.kt
+++ b/tv/tv-material/src/main/java/androidx/tv/material3/Carousel.kt
@@ -43,6 +43,7 @@
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberUpdatedState
 import androidx.compose.runtime.setValue
 import androidx.compose.runtime.snapshotFlow
 import androidx.compose.ui.Alignment
@@ -147,7 +148,7 @@
         AnimatedContent(
             targetState = carouselState.activeSlideIndex,
             transitionSpec = { enterTransition.with(exitTransition) }
-        ) {
+        ) { slideIndex ->
             LaunchedEffect(Unit) {
                 this@AnimatedContent.onAnimationCompletion {
                     // Outer box is focused
@@ -157,7 +158,13 @@
                     }
                 }
             }
-            content.invoke(it)
+            // it is possible for the slideCount to have changed during the transition.
+            // This can cause the slideIndex to be greater than or equal to slideCount and cause
+            // IndexOutOfBoundsException. Guarding against this by checking against slideCount
+            // before invoking.
+            if (slideCount > 0) {
+                content.invoke(if (slideIndex < slideCount) slideIndex else 0)
+            }
         }
         this.carouselIndicator()
     }
@@ -186,6 +193,8 @@
     doAutoScroll: Boolean,
     onAutoScrollChange: (isAutoScrollActive: Boolean) -> Unit = {},
 ) {
+    // Needed to ensure that the code within LaunchedEffect receives updates to the slideCount.
+    val updatedSlideCount by rememberUpdatedState(newValue = slideCount)
     if (doAutoScroll) {
         LaunchedEffect(carouselState) {
             while (true) {
@@ -195,7 +204,7 @@
                     snapshotFlow { carouselState.activePauseHandlesCount }
                         .first { pauseHandleCount -> pauseHandleCount == 0 }
                 }
-                carouselState.moveToNextSlide(slideCount)
+                carouselState.moveToNextSlide(updatedSlideCount)
             }
         }
     }