Back to Repositories

Validating ExoPlayer Hosted Playback Framework in SmartTube

This test suite implements a comprehensive hosted testing framework for ExoPlayer playback functionality in Android applications. It provides a robust infrastructure for validating media playback behaviors, decoder performance, and player state management.

Test Coverage Overview

The test suite provides extensive coverage of ExoPlayer’s core playback capabilities and state management. Key areas include:

  • Playback state transitions and timing verification
  • Media duration and playing time validation
  • Decoder performance monitoring for audio and video
  • DRM session management testing
  • Surface rendering and display validation

Implementation Analysis

The implementation utilizes a hosted test architecture that manages the ExoPlayer lifecycle and execution environment. It employs a flexible action scheduling system for orchestrating test sequences and implements comprehensive analytics listeners for monitoring playback events.

The testing approach leverages Android’s ConditionVariable for synchronization and provides precise timing measurements for playback validation.

Technical Details

Key technical components include:

  • Custom DecoderCounters for tracking audio/video decoder performance
  • EventLogger integration for detailed playback monitoring
  • Configurable DRM session management
  • Customizable track selector implementation
  • Surface handling for video output
  • Handler-based action scheduling system

Best Practices Demonstrated

The test suite exemplifies several testing best practices:

  • Comprehensive error handling and validation
  • Precise timing measurements and assertions
  • Clean separation of test configuration and execution
  • Flexible test scheduling and control
  • Detailed event logging and monitoring
  • Proper resource cleanup and management

yuliskov/smarttube

exoplayer-amzn-2.10.6/testutils/src/main/java/com/google/android/exoplayer2/testutil/ExoHostedTest.java

            
/*
 * Copyright (C) 2016 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 com.google.android.exoplayer2.testutil;

import static com.google.common.truth.Truth.assertWithMessage;

import android.os.ConditionVariable;
import android.os.Looper;
import android.os.SystemClock;
import android.view.Surface;
import com.google.android.exoplayer2.C;
import com.google.android.exoplayer2.DefaultLoadControl;
import com.google.android.exoplayer2.DefaultRenderersFactory;
import com.google.android.exoplayer2.ExoPlaybackException;
import com.google.android.exoplayer2.ExoPlayer;
import com.google.android.exoplayer2.ExoPlayerFactory;
import com.google.android.exoplayer2.Player;
import com.google.android.exoplayer2.RenderersFactory;
import com.google.android.exoplayer2.SimpleExoPlayer;
import com.google.android.exoplayer2.analytics.AnalyticsListener;
import com.google.android.exoplayer2.audio.DefaultAudioSink;
import com.google.android.exoplayer2.decoder.DecoderCounters;
import com.google.android.exoplayer2.drm.DrmSessionManager;
import com.google.android.exoplayer2.drm.FrameworkMediaCrypto;
import com.google.android.exoplayer2.source.MediaSource;
import com.google.android.exoplayer2.testutil.HostActivity.HostedTest;
import com.google.android.exoplayer2.trackselection.AdaptiveTrackSelection;
import com.google.android.exoplayer2.trackselection.DefaultTrackSelector;
import com.google.android.exoplayer2.trackselection.MappingTrackSelector;
import com.google.android.exoplayer2.util.Clock;
import com.google.android.exoplayer2.util.EventLogger;
import com.google.android.exoplayer2.util.HandlerWrapper;
import com.google.android.exoplayer2.util.Log;
import com.google.android.exoplayer2.util.Util;

/** A {@link HostedTest} for {@link ExoPlayer} playback tests. */
public abstract class ExoHostedTest implements AnalyticsListener, HostedTest {

  static {
    // DefaultAudioSink is able to work around spurious timestamps reported by the platform (by
    // ignoring them). Disable this workaround, since we're interested in testing that the
    // underlying platform is behaving correctly.
    DefaultAudioSink.failOnSpuriousAudioTimestamp = true;
  }

  public static final long MAX_PLAYING_TIME_DISCREPANCY_MS = 2000;
  public static final long EXPECTED_PLAYING_TIME_MEDIA_DURATION_MS = -2;
  public static final long EXPECTED_PLAYING_TIME_UNSET = -1;

  protected final String tag;

  private final boolean failOnPlayerError;
  private final long expectedPlayingTimeMs;
  private final DecoderCounters videoDecoderCounters;
  private final DecoderCounters audioDecoderCounters;
  private final ConditionVariable testFinished;

  private ActionSchedule pendingSchedule;
  private HandlerWrapper actionHandler;
  private DefaultTrackSelector trackSelector;
  private SimpleExoPlayer player;
  private Surface surface;
  private ExoPlaybackException playerError;
  private boolean playerWasPrepared;

  private boolean playing;
  private long totalPlayingTimeMs;
  private long lastPlayingStartTimeMs;
  private long sourceDurationMs;

  /**
   * @param tag A tag to use for logging.
   * @param fullPlaybackNoSeeking Whether the test will play the target media in full without
   *     seeking. If set to true, the test will assert that the total time spent playing the media
   *     was within {@link #MAX_PLAYING_TIME_DISCREPANCY_MS} of the media duration. If set to false,
   *     the test will not assert an expected playing time.
   */
  public ExoHostedTest(String tag, boolean fullPlaybackNoSeeking) {
    this(tag, fullPlaybackNoSeeking ? EXPECTED_PLAYING_TIME_MEDIA_DURATION_MS
        : EXPECTED_PLAYING_TIME_UNSET, true);
  }

  /**
   * @param tag A tag to use for logging.
   * @param expectedPlayingTimeMs The expected playing time. If set to a non-negative value, the
   *     test will assert that the total time spent playing the media was within
   *     {@link #MAX_PLAYING_TIME_DISCREPANCY_MS} of the specified value.
   *     {@link #EXPECTED_PLAYING_TIME_MEDIA_DURATION_MS} should be passed to assert that the
   *     expected playing time equals the duration of the media being played. Else
   *     {@link #EXPECTED_PLAYING_TIME_UNSET} should be passed to indicate that the test should not
   *     assert an expected playing time.
   * @param failOnPlayerError Whether a player error should be considered a test failure.
   */
  public ExoHostedTest(String tag, long expectedPlayingTimeMs, boolean failOnPlayerError) {
    this.tag = tag;
    this.expectedPlayingTimeMs = expectedPlayingTimeMs;
    this.failOnPlayerError = failOnPlayerError;
    this.testFinished = new ConditionVariable();
    this.videoDecoderCounters = new DecoderCounters();
    this.audioDecoderCounters = new DecoderCounters();
  }

  /**
   * Sets a schedule to be applied during the test.
   *
   * @param schedule The schedule.
   */
  public final void setSchedule(ActionSchedule schedule) {
    if (player == null) {
      pendingSchedule = schedule;
    } else {
      schedule.start(player, trackSelector, surface, actionHandler, /* callback= */ null);
    }
  }

  // HostedTest implementation

  @Override
  public final void onStart(HostActivity host, Surface surface) {
    this.surface = surface;
    // Build the player.
    trackSelector = buildTrackSelector(host);
    String userAgent = "ExoPlayerPlaybackTests";
    DrmSessionManager<FrameworkMediaCrypto> drmSessionManager = buildDrmSessionManager(userAgent);
    player = buildExoPlayer(host, surface, trackSelector, drmSessionManager);
    player.setPlayWhenReady(true);
    player.addAnalyticsListener(this);
    player.addAnalyticsListener(new EventLogger(trackSelector, tag));
    // Schedule any pending actions.
    actionHandler = Clock.DEFAULT.createHandler(Looper.myLooper(), /* callback= */ null);
    if (pendingSchedule != null) {
      pendingSchedule.start(player, trackSelector, surface, actionHandler, /* callback= */ null);
      pendingSchedule = null;
    }
    player.prepare(buildSource(host, Util.getUserAgent(host, userAgent)));
  }

  @Override
  public final boolean blockUntilStopped(long timeoutMs) {
    return testFinished.block(timeoutMs);
  }

  @Override
  public final boolean forceStop() {
    return stopTest();
  }

  @Override
  public final void onFinished() {
    onTestFinished(audioDecoderCounters, videoDecoderCounters);
    if (failOnPlayerError && playerError != null) {
      throw new Error(playerError);
    }
    if (expectedPlayingTimeMs != EXPECTED_PLAYING_TIME_UNSET) {
      long playingTimeToAssertMs = expectedPlayingTimeMs == EXPECTED_PLAYING_TIME_MEDIA_DURATION_MS
          ? sourceDurationMs : expectedPlayingTimeMs;
      // Assert that the playback spanned the correct duration of time.
      long minAllowedActualPlayingTimeMs = playingTimeToAssertMs - MAX_PLAYING_TIME_DISCREPANCY_MS;
      long maxAllowedActualPlayingTimeMs = playingTimeToAssertMs + MAX_PLAYING_TIME_DISCREPANCY_MS;
      assertWithMessage(
              "Total playing time: " + totalPlayingTimeMs + ". Expected: " + playingTimeToAssertMs)
          .that(
              minAllowedActualPlayingTimeMs <= totalPlayingTimeMs
                  && totalPlayingTimeMs <= maxAllowedActualPlayingTimeMs)
          .isTrue();
    }
  }

  // AnalyticsListener

  @Override
  public final void onPlayerStateChanged(
      EventTime eventTime, boolean playWhenReady, int playbackState) {
    Log.d(tag, "state [" + playWhenReady + ", " + playbackState + "]");
    playerWasPrepared |= playbackState != Player.STATE_IDLE;
    if (playbackState == Player.STATE_ENDED
        || (playbackState == Player.STATE_IDLE && playerWasPrepared)) {
      stopTest();
    }
    boolean playing = playWhenReady && playbackState == Player.STATE_READY;
    if (!this.playing && playing) {
      lastPlayingStartTimeMs = SystemClock.elapsedRealtime();
    } else if (this.playing && !playing) {
      totalPlayingTimeMs += SystemClock.elapsedRealtime() - lastPlayingStartTimeMs;
    }
    this.playing = playing;
  }

  @Override
  public final void onPlayerError(EventTime eventTime, ExoPlaybackException error) {
    playerWasPrepared = true;
    playerError = error;
    onPlayerErrorInternal(error);
  }

  @Override
  public void onDecoderDisabled(
      EventTime eventTime, int trackType, DecoderCounters decoderCounters) {
    if (trackType == C.TRACK_TYPE_AUDIO) {
      audioDecoderCounters.merge(decoderCounters);
    } else if (trackType == C.TRACK_TYPE_VIDEO) {
      videoDecoderCounters.merge(decoderCounters);
    }
  }

  // Internal logic

  private boolean stopTest() {
    if (player == null) {
      return false;
    }
    actionHandler.removeCallbacksAndMessages(null);
    sourceDurationMs = player.getDuration();
    player.release();
    player = null;
    // We post opening of the finished condition so that any events posted to the main thread as a
    // result of player.release() are guaranteed to be handled before the test returns.
    actionHandler.post(testFinished::open);
    return true;
  }

  protected DrmSessionManager<FrameworkMediaCrypto> buildDrmSessionManager(String userAgent) {
    // Do nothing. Interested subclasses may override.
    return null;
  }

  protected DefaultTrackSelector buildTrackSelector(HostActivity host) {
    return new DefaultTrackSelector(new AdaptiveTrackSelection.Factory());
  }

  protected SimpleExoPlayer buildExoPlayer(
      HostActivity host,
      Surface surface,
      MappingTrackSelector trackSelector,
      DrmSessionManager<FrameworkMediaCrypto> drmSessionManager) {
    RenderersFactory renderersFactory =
        new DefaultRenderersFactory(
            host,
            DefaultRenderersFactory.EXTENSION_RENDERER_MODE_OFF,
            /* allowedVideoJoiningTimeMs= */ 0);
    SimpleExoPlayer player =
        ExoPlayerFactory.newSimpleInstance(
            host, renderersFactory, trackSelector, new DefaultLoadControl(), drmSessionManager);
    player.setVideoSurface(surface);
    return player;
  }

  protected abstract MediaSource buildSource(HostActivity host, String userAgent);

  protected void onPlayerErrorInternal(ExoPlaybackException error) {
    // Do nothing. Interested subclasses may override.
  }

  protected void onTestFinished(DecoderCounters audioCounters, DecoderCounters videoCounters) {
    // Do nothing. Subclasses may override to add clean-up and assertions.
  }
}