Skip to content

java.lang.ArrayIndexOutOfBoundsException: length=0; index=0 on live streams with preroll advert when stop player #3423

Closed

Description

@amorenkov

Version

Media3 1.11.1

More version details

Reproducible from different versions (for example 1.8.1, 1.9.4, 1.10.1, 1.11.1)

Devices that reproduce the issue

Device independent, but there are some examples:
Dreame Smart 4KTV (android 14)
Honor X6c (android 15)
Samsung Galaxy A07 (android 16)

Devices that do not reproduce the issue

N/A

Reproducible in the demo app?

Not tested

Reproduction steps

We have a video-based application with custom ads logic (not IMA).
High level idea for reproducing:

  1. you have ExoPlayer with analytics listener added
  2. you have a live stream content (endless hls stream) with preroll advert (advert at 0 position)
  3. when preroll is playing you need to stop player (reason from real life - user doesn't want watch advert and closes player) and after this you need to do anything with player (in our case there is volume ajustment).

There is simplified test with robolectric but you can try to reproduce on real player:

Test (robolectric) code with comments
// Required libs:
// androidx.media3:media3-test-utils: 1.11.1
// androidx.media3:media3-test-utils-robolectric:1.11.1
// androidx.media3:media3-exoplayer-hls:1.11.1
@RunWith(RobolectricTestRunner::class)
class LiveAdStopAnalyticsCrashTest {

    private companion object {
        const val LIVE_URL = "https://demo.unified-streaming.com/k8s/live/scte35.isml/.m3u8"
        const val VOD_URL = "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8"
        const val AD_URL = "https://storage.googleapis.com/exoplayer-test-media-1/mp4/android-screens-10s.mp4"

        val PREROLL_ONLY_AD_STATE = AdPlaybackState(1234, 0L).withAdCount(0, 1)
        val PREROLL_MEDIA_ITEM = MediaItem.fromUri(AD_URL)
    }

    private class SimpleAdsLoader : AdsLoader {
        private var listener: AdsLoader.EventListener? = null
        private var adPlaybackState: AdPlaybackState? = null

        override fun start(s: AdsMediaSource, d: DataSpec, adsId: Any, v: AdViewProvider, l: AdsLoader.EventListener) {
            listener = l
            adPlaybackState = PREROLL_ONLY_AD_STATE
            l.onAdPlaybackState(adPlaybackState!!)
        }

        fun showAdNow() {
            adPlaybackState = adPlaybackState!!.withAvailableAdMediaItem(0, 0, PREROLL_MEDIA_ITEM)
            listener!!.onAdPlaybackState(adPlaybackState!!)
        }

        override fun stop(s: AdsMediaSource, l: AdsLoader.EventListener) = Unit
        override fun setPlayer(p: Player?) = Unit
        override fun release() = Unit
        override fun setSupportedContentTypes(vararg c: Int) = Unit
        override fun handlePrepareComplete(s: AdsMediaSource, g: Int, i: Int) = Unit
        override fun handlePrepareError(s: AdsMediaSource, g: Int, i: Int, e: IOException) = Unit
    }

    /** Lets the player run in real time (the test plays real streams over the network). */
    private fun ExoPlayer.runFor(millis: Long) {
        val end = System.currentTimeMillis() + millis
        while (System.currentTimeMillis() < end) {
            shadowOf(Looper.getMainLooper()).idleFor(Duration.ofMillis(50))
            Thread.sleep(50)
        }
    }

    private fun ExoPlayer.waitUntilStreamIsLoaded() {
        val window = Timeline.Window()
        val deadline = System.currentTimeMillis() + 20_000
        while (currentTimeline.isEmpty || currentTimeline.getWindow(0, window).isPlaceholder) {
            check(System.currentTimeMillis() < deadline) { "stream was not loaded" }
            runFor(50)
        }
    }

    private fun userClosesPlayerDuringAd(streamUrl: String) {
        val context = RuntimeEnvironment.getApplication()
        val adsLoader = SimpleAdsLoader()

        // 1. Create the player
        val player = TestExoPlayerBuilder(context)
            .setClock(Clock.DEFAULT)
            .setMediaSourceFactory(DefaultMediaSourceFactory(context).setLocalAdInsertionComponents({ adsLoader }, AdViewProvider { null }))
            .build()

        // !! Critical - use listener impl which calls DefaultPlaybackSessionManager inside
        player.addAnalyticsListener(PlaybackStatsListener(/* keepHistory= */ false, /* callback= */ null))

        // 2. Put a stream (live or regular video) with ads support into the player and start it
        player.setMediaItem(
            MediaItem.Builder()
                .setUri(streamUrl)
                .setAdsConfiguration(MediaItem.AdsConfiguration.Builder(Uri.parse("https://example.com/ad-tag")).build())
                .build()
        )
        player.prepare()
        player.play()
        player.waitUntilStreamIsLoaded()
        player.runFor(10)

        // 3. The app inserts an ad, the ad starts, the user watches it for 1 second
        adsLoader.showAdNow()
        TestPlayerRunHelper.run(player).untilPlayingAdIs(true)
        player.runFor(1000)
        assertTrue("the ad must still be playing", player.isPlayingAd)

        // 4. The user gets angry and closes the player
        player.stop()
        player.runFor(300)

        // 5. Example side effect on leaving player: volume is changed
        player.volume = 0.0f

        // 6. Host app still working...
        player.runFor(500) // <- crash for the live stream

        player.release()
    }

    // Will fail
    @Test
    fun liveStream_userClosesPlayerDuringAd() = userClosesPlayerDuringAd(LIVE_URL)

    // Will success
    @Test
    fun regularVideo_userClosesPlayerDuringAd() = userClosesPlayerDuringAd(VOD_URL)
}

Expected result

No player crash (behavior like on VOD or simple video as mp4 for example)

Actual result

Production stacktrace (test stacktrace is a little different in some lines but the idea is the same):

Fatal Exception: java.lang.ArrayIndexOutOfBoundsException: length=0; index=0
       at androidx.media3.common.AdPlaybackState.getAdGroup(AdPlaybackState.java:1035)
       at androidx.media3.common.Timeline$Period.getAdGroupTimeUs(Timeline.java:754)
       at androidx.media3.exoplayer.analytics.DefaultPlaybackSessionManager.updateSessions(DefaultPlaybackSessionManager.java:148)
       at androidx.media3.exoplayer.analytics.MediaMetricsListener.maybeAddSessions(MediaMetricsListener.java:334)
       at androidx.media3.exoplayer.analytics.MediaMetricsListener.onEvents(MediaMetricsListener.java:311)
       at androidx.media3.exoplayer.analytics.DefaultAnalyticsCollector.lambda$setPlayer$0(DefaultAnalyticsCollector.java:138)
       at androidx.media3.common.util.ListenerSet$ListenerHolder.iterationFinished(ListenerSet.java:477)
       at androidx.media3.common.util.ListenerSet.handleMessage(ListenerSet.java:421)
       at android.os.Handler.dispatchMessage(Handler.java:128)
       at android.os.Looper.dispatchMessage(Looper.java:358)
       at android.os.Looper.loopOnce(Looper.java:288)
       at android.os.Looper.loop(Looper.java:392)
       at android.app.ActivityThread.main(ActivityThread.java:10346)
       at java.lang.reflect.Method.invoke(Method.java)
       at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:638)
       at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:972)

Test stacktrace:

Index 0 out of bounds for length 0
java.lang.ArrayIndexOutOfBoundsException: Index 0 out of bounds for length 0
	at androidx.media3.common.AdPlaybackState.getAdGroup(AdPlaybackState.java:1118)
	at androidx.media3.common.Timeline$Period.getAdGroupTimeUs(Timeline.java:753)
	at androidx.media3.exoplayer.analytics.PlaybackStatsListener.findBestEventTime(PlaybackStatsListener.java:325)
	at androidx.media3.exoplayer.analytics.PlaybackStatsListener.onEvents(PlaybackStatsListener.java:250)
	at androidx.media3.exoplayer.analytics.DefaultAnalyticsCollector.lambda$setPlayer$0(DefaultAnalyticsCollector.java:138)
	at androidx.media3.common.util.ListenerSet$ListenerHolder.iterationFinished(ListenerSet.java:477)
	at androidx.media3.common.util.ListenerSet.handleMessage(ListenerSet.java:421)
	at android.os.Handler.dispatchMessage(Handler.java:106)
	at org.robolectric.shadows.ShadowPausedLooper$IdlingRunnable.doRun(ShadowPausedLooper.java:677)
	at org.robolectric.shadows.ShadowPausedLooper$IdleForRunnable.doRun(ShadowPausedLooper.java:658)
	at org.robolectric.shadows.ShadowPausedLooper$ControlRunnable.run(ShadowPausedLooper.java:599)
	at org.robolectric.shadows.ShadowPausedLooper$HandlerExecutor.execute(ShadowPausedLooper.java:849)
	at org.robolectric.shadows.ShadowPausedLooper.executeOnLooper(ShadowPausedLooper.java:765)
	at org.robolectric.shadows.ShadowPausedLooper.idleFor(ShadowPausedLooper.java:114)
	at LiveAdStopAnalyticsCrashTest.runFor(LiveAdStopAnalyticsCrashTest.kt:69)
	at LiveAdStopAnalyticsCrashTest.userClosesPlayerDuringAd(LiveAdStopAnalyticsCrashTest.kt:122)
	at LiveAdStopAnalyticsCrashTest.liveStream_userClosesPlayerDuringAd(LiveAdStopAnalyticsCrashTest.kt:128)

Possible crash root cause (AI-accelerated investigation):

  1. player.stop() → ExoPlayerImplInternal.resetInternal(releaseMediaSourceList = true) → PlaylistTimeline.copyWithPlaceholderTimeline(). For live windows this replaces the period's AdPlaybackState with AdPlaybackState.NONE (adGroupCount == 0); for non-live windows the ad state is kept (only isPlaceholder = true). That is why only live streams crash. resetInternal itself also drops the ad metadata from the player's own MediaPeriodId for live windows, so the player is consistent.
  2. DefaultAnalyticsCollector.MediaPeriodQueueTracker is not: playingMediaPeriod / readingMediaPeriod are @MonotonicNonNull and are not cleared when the queue becomes empty in onQueueUpdated, so they still hold the ad MediaPeriodId (adGroupIndex = 0).
  3. updateMediaPeriodTimelines(preferredTimeline = player.getCurrentTimeline()) / addTimelineForMediaPeriodId maps that stale ad id to the new placeholder timeline because the period uid still exists in it. The ad id is now paired with a period that has no ad groups.
  4. Any later event that is created through generateReadingMediaPeriodEventTime() / generatePlayingMediaPeriodEventTime() (onVolumeChanged, onSurfaceSizeChanged, onVideoDecoderReleased, onAudioDisabled, onDroppedFrames, onRenderedFirstFrame, ...) gets an EventTime with mediaPeriodId.isAd() == true and the ad-less timeline. A surface size change explains the rotation we sometimes see before the crash in production.
  5. DefaultPlaybackSessionManager.updateSessions (lines 146-148) and PlaybackStatsListener.findBestEventTime (line 325) call period.getAdGroupTimeUs(adGroupIndex) on that period → adGroups[0] on an empty array.
  6. Some of these events are created on the playback thread (renderer events) while MediaPeriodQueueTracker is updated from both the playback thread (updateMediaPeriodQueueInfo) and the app thread (onTimelineChanged), so the exact ordering, and therefore the crash, is timing dependent. That matches how sporadic it is in production.

Media

Any endless hls live stream, (see example urls in attached test above)

Bug Report

Activity

  1. marcbaechinger commented on Sep 25, 2026

    @marcbaechinger
    Contributor

    We pushed the commit above to main that fixes the ArrayIndexOutOfBoundsException. Thanks for reporting!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions