All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.robolectric.shadows.ShadowMediaPlayer Maven / Gradle / Ivy

package org.robolectric.shadows;

import static android.os.Build.VERSION_CODES.ICE_CREAM_SANDWICH;
import static android.os.Build.VERSION_CODES.M;
import static android.os.Build.VERSION_CODES.N;
import static android.os.Build.VERSION_CODES.N_MR1;
import static android.os.Build.VERSION_CODES.O;
import static android.os.Build.VERSION_CODES.P;
import static org.robolectric.shadows.ShadowMediaPlayer.State.END;
import static org.robolectric.shadows.ShadowMediaPlayer.State.ERROR;
import static org.robolectric.shadows.ShadowMediaPlayer.State.IDLE;
import static org.robolectric.shadows.ShadowMediaPlayer.State.INITIALIZED;
import static org.robolectric.shadows.ShadowMediaPlayer.State.PAUSED;
import static org.robolectric.shadows.ShadowMediaPlayer.State.PLAYBACK_COMPLETED;
import static org.robolectric.shadows.ShadowMediaPlayer.State.PREPARED;
import static org.robolectric.shadows.ShadowMediaPlayer.State.PREPARING;
import static org.robolectric.shadows.ShadowMediaPlayer.State.STARTED;
import static org.robolectric.shadows.ShadowMediaPlayer.State.STOPPED;
import static org.robolectric.shadows.util.DataSource.toDataSource;

import android.content.Context;
import android.content.res.AssetFileDescriptor;
import android.media.MediaDataSource;
import android.media.MediaPlayer;
import android.net.Uri;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.os.SystemClock;
import java.io.FileDescriptor;
import java.io.IOException;
import java.net.HttpCookie;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Random;
import java.util.TreeMap;
import org.robolectric.annotation.Implementation;
import org.robolectric.annotation.Implements;
import org.robolectric.annotation.RealObject;
import org.robolectric.annotation.Resetter;
import org.robolectric.shadow.api.Shadow;
import org.robolectric.shadows.util.DataSource;

/**
 * Automated testing of media playback can be a difficult thing - especially testing that your code
 * properly handles asynchronous errors and events. This near impossible task is made quite
 * straightforward using this implementation of {@link MediaPlayer} with Robolectric.
 *
 * 

This shadow implementation provides much of the functionality needed to emulate {@link * MediaPlayer} initialization and playback behavior without having to play actual media files. A * summary of the features included are: * *

    *
  • Construction-time callback hook {@link CreateListener} so that newly-created {@link * MediaPlayer} instances can have their shadows configured before they are used. *
  • Emulation of the {@link android.media.MediaPlayer.OnCompletionListener * OnCompletionListener}, {@link android.media.MediaPlayer.OnErrorListener OnErrorListener}, * {@link android.media.MediaPlayer.OnInfoListener OnInfoListener}, {@link * android.media.MediaPlayer.OnPreparedListener OnPreparedListener} and {@link * android.media.MediaPlayer.OnSeekCompleteListener OnSeekCompleteListener}. *
  • Full support of the {@link MediaPlayer} internal states and their transition map. *
  • Configure time parameters such as playback duration, preparation delay and {@link * #setSeekDelay(int)}. *
  • Emulation of asynchronous callback events during playback through Robolectric's scheduling * system using the {@link MediaInfo} inner class. *
  • Emulation of error behavior when methods are called from invalid states, or to throw * assertions when methods are invoked in invalid states (using {@link * #setInvalidStateBehavior}). *
  • Emulation of different playback behaviors based on the current data source, as passed in to * {@link #setDataSource(String) setDataSource()}, using {@link #addMediaInfo} or {@link * #setMediaInfoProvider(MediaInfoProvider)}. *
  • Emulation of exceptions when calling {@link #setDataSource} using {@link #addException}. *
* * Note: One gotcha with this shadow is that you need to either configure an exception using * {@link #addException(DataSource, IOException)} or a {@link ShadowMediaPlayer.MediaInfo} instance * for that data source using {@link #addMediaInfo(DataSource, MediaInfo)} or {@link * #setMediaInfoProvider(MediaInfoProvider)} before calling {@link #setDataSource}, otherwise * you'll get an {@link IllegalArgumentException}. * *

The current features of {@code ShadowMediaPlayer} were focused on development for testing * playback of audio tracks. Thus support for emulating timed text and video events is incomplete. * None of these features would be particularly onerous to add/fix - contributions welcome, of * course! * * @author Fr Jeremy Krieg, Holy Monastery of St Nectarios, Adelaide, Australia */ @Implements(MediaPlayer.class) public class ShadowMediaPlayer extends ShadowPlayerBase { @Implementation protected static void __staticInitializer__() { // don't bind the JNI library } /** Provides a {@link MediaInfo} for a given {@link DataSource}. */ public interface MediaInfoProvider { MediaInfo get(DataSource dataSource); } /** * Listener that is called when a new MediaPlayer is constructed. * * @see #setCreateListener(CreateListener) */ protected static CreateListener createListener; private static final Map exceptions = new HashMap<>(); private static final Map mediaInfoMap = new HashMap<>(); private static final MediaInfoProvider DEFAULT_MEDIA_INFO_PROVIDER = mediaInfoMap::get; private static MediaInfoProvider mediaInfoProvider = DEFAULT_MEDIA_INFO_PROVIDER; @RealObject private MediaPlayer player; /** * Possible states for the media player to be in. These states are as defined in the documentation * for {@link android.media.MediaPlayer}. */ public enum State { IDLE, INITIALIZED, PREPARING, PREPARED, STARTED, STOPPED, PAUSED, PLAYBACK_COMPLETED, END, ERROR } /** * Possible behavior modes for the media player when a method is invoked in an invalid state. * * @see #setInvalidStateBehavior */ public enum InvalidStateBehavior { SILENT, EMULATE, ASSERT } /** * Reference to the next playback event scheduled to run. We keep a reference to this handy in * case we need to cancel it. */ private RunList nextPlaybackEvent; /** * Class for grouping events that are meant to fire at the same time. Also schedules the next * event to run. */ @SuppressWarnings("serial") private static class RunList extends ArrayList implements MediaEvent { public RunList() { // Set the default size to one as most of the time we will // only have one event. super(1); } @Override public void run(MediaPlayer mp, ShadowMediaPlayer smp) { for (MediaEvent e : this) { e.run(mp, smp); } } } public interface MediaEvent { public void run(MediaPlayer mp, ShadowMediaPlayer smp); } /** * Class specifying information for an emulated media object. Used by ShadowMediaPlayer when * setDataSource() is called to populate the shadow player with the specified values. */ public static class MediaInfo { public int duration; private int preparationDelay; /** Map that maps time offsets to media events. */ public TreeMap events = new TreeMap<>(); /** * Creates a new {@code MediaInfo} object with default duration (1000ms) and default preparation * delay (0ms). */ public MediaInfo() { this(1000, 0); } /** * Creates a new {@code MediaInfo} object with the given duration and preparation delay. A * completion callback event is scheduled at {@code duration} ms from the end. * * @param duration the duration (in ms) of this emulated media. A callback event will be * scheduled at this offset to stop playback simulation and invoke the completion callback. * @param preparationDelay the preparation delay (in ms) to emulate for this media. If set to * -1, then {@link #prepare()} will complete instantly but {@link #prepareAsync()} will not * complete automatically; you will need to call {@link #invokePreparedListener()} manually. */ public MediaInfo(int duration, int preparationDelay) { this.duration = duration; this.preparationDelay = preparationDelay; scheduleEventAtOffset(duration, completionCallback); } /** * Retrieves the current preparation delay for this media. * * @return The current preparation delay (in ms). */ public int getPreparationDelay() { return preparationDelay; } /** * Sets the current preparation delay for this media. * * @param preparationDelay the new preparation delay (in ms). */ public void setPreparationDelay(int preparationDelay) { this.preparationDelay = preparationDelay; } /** * Schedules a generic event to run at the specified playback offset. Events are run on the * thread on which the {@link android.media.MediaPlayer MediaPlayer} was created. * * @param offset the offset from the start of playback at which this event will run. * @param event the event to run. */ public void scheduleEventAtOffset(int offset, MediaEvent event) { RunList runList = events.get(offset); if (runList == null) { // Given that most run lists will only contain one event, // we use 1 as the default capacity. runList = new RunList(); events.put(offset, runList); } runList.add(event); } /** * Schedules an error event to run at the specified playback offset. A reference to the actual * MediaEvent that is scheduled is returned, which can be used in a subsequent call to {@link * #removeEventAtOffset}. * * @param offset the offset from the start of playback at which this error will trigger. * @param what the value for the {@code what} parameter to use in the call to {@link * android.media.MediaPlayer.OnErrorListener#onError(MediaPlayer, int, int) onError()}. * @param extra the value for the {@code extra} parameter to use in the call to {@link * android.media.MediaPlayer.OnErrorListener#onError(MediaPlayer, int, int) onError()}. * @return A reference to the MediaEvent object that was created and scheduled. */ public MediaEvent scheduleErrorAtOffset(int offset, int what, int extra) { ErrorCallback callback = new ErrorCallback(what, extra); scheduleEventAtOffset(offset, callback); return callback; } /** * Schedules an info event to run at the specified playback offset. A reference to the actual * MediaEvent that is scheduled is returned, which can be used in a subsequent call to {@link * #removeEventAtOffset}. * * @param offset the offset from the start of playback at which this event will trigger. * @param what the value for the {@code what} parameter to use in the call to {@link * android.media.MediaPlayer.OnInfoListener#onInfo(MediaPlayer, int, int) onInfo()}. * @param extra the value for the {@code extra} parameter to use in the call to {@link * android.media.MediaPlayer.OnInfoListener#onInfo(MediaPlayer, int, int) onInfo()}. * @return A reference to the MediaEvent object that was created and scheduled. */ public MediaEvent scheduleInfoAtOffset(int offset, final int what, final int extra) { MediaEvent callback = new MediaEvent() { @Override public void run(MediaPlayer mp, ShadowMediaPlayer smp) { smp.invokeInfoListener(what, extra); } }; scheduleEventAtOffset(offset, callback); return callback; } /** * Schedules a simulated buffer underrun event to run at the specified playback offset. A * reference to the actual MediaEvent that is scheduled is returned, which can be used in a * subsequent call to {@link #removeEventAtOffset}. * *

This event will issue an {@link MediaPlayer.OnInfoListener#onInfo onInfo()} callback with * {@link MediaPlayer#MEDIA_INFO_BUFFERING_START} to signal the start of buffering and then call * {@link #doStop()} to internally pause playback. Finally it will schedule an event to fire * after {@code length} ms which fires a {@link MediaPlayer#MEDIA_INFO_BUFFERING_END} info event * and invokes {@link #doStart()} to resume playback. * * @param offset the offset from the start of playback at which this underrun will trigger. * @param length the length of time (in ms) for which playback will be paused. * @return A reference to the MediaEvent object that was created and scheduled. */ public MediaEvent scheduleBufferUnderrunAtOffset(int offset, final int length) { final MediaEvent restart = new MediaEvent() { @Override public void run(MediaPlayer mp, ShadowMediaPlayer smp) { smp.invokeInfoListener(MediaPlayer.MEDIA_INFO_BUFFERING_END, 0); smp.doStart(); } }; MediaEvent callback = new MediaEvent() { @Override public void run(MediaPlayer mp, ShadowMediaPlayer smp) { smp.doStop(); smp.invokeInfoListener(MediaPlayer.MEDIA_INFO_BUFFERING_START, 0); smp.postEventDelayed(restart, length); } }; scheduleEventAtOffset(offset, callback); return callback; } /** * Removes the specified event from the playback schedule at the given playback offset. * * @param offset the offset at which the event was scheduled. * @param event the event to remove. * @see ShadowMediaPlayer.MediaInfo#removeEvent(ShadowMediaPlayer.MediaEvent) */ public void removeEventAtOffset(int offset, MediaEvent event) { RunList runList = events.get(offset); if (runList != null) { runList.remove(event); if (runList.isEmpty()) { events.remove(offset); } } } /** * Removes the specified event from the playback schedule at all playback offsets where it has * been scheduled. * * @param event the event to remove. * @see ShadowMediaPlayer.MediaInfo#removeEventAtOffset(int,ShadowMediaPlayer.MediaEvent) */ public void removeEvent(MediaEvent event) { for (Iterator> iter = events.entrySet().iterator(); iter.hasNext(); ) { Entry entry = iter.next(); RunList runList = entry.getValue(); runList.remove(event); if (runList.isEmpty()) { iter.remove(); } } } } public void postEvent(MediaEvent e) { Message msg = handler.obtainMessage(MEDIA_EVENT, e); handler.sendMessage(msg); } public void postEventDelayed(MediaEvent e, long delay) { Message msg = handler.obtainMessage(MEDIA_EVENT, e); handler.sendMessageDelayed(msg, delay); } /** * Callback interface for clients that wish to be informed when a new {@link MediaPlayer} instance * is constructed. * * @see #setCreateListener */ public static interface CreateListener { /** * Method that is invoked when a new {@link MediaPlayer} is created. This method is invoked at * the end of the constructor, after all of the default setup has been completed. * * @param player reference to the newly-created media player object. * @param shadow reference to the corresponding shadow object for the newly-created media player * (provided for convenience). */ public void onCreate(MediaPlayer player, ShadowMediaPlayer shadow); } /** Current state of the media player. */ private State state = IDLE; /** Delay for calls to {@link #seekTo} (in ms). */ private int seekDelay = 0; private int auxEffect; private int audioSessionId; private int audioStreamType; private boolean looping; private int pendingSeek = -1; /** Various source variables from setDataSource() */ private Uri sourceUri; private int sourceResId; private DataSource dataSource; private MediaInfo mediaInfo; /** The time (in ms) at which playback was last started/resumed. */ private long startTime = -1; /** * The offset (in ms) from the start of the current clip at which the last call to seek/pause was. * If the MediaPlayer is not in the STARTED state, then this is equal to currentPosition; if it is * in the STARTED state and no seek is pending then you need to add the number of ms since start() * was called to get the current position (see {@link #startTime}). */ private int startOffset = 0; private int videoHeight; private int videoWidth; private float leftVolume; private float rightVolume; private MediaPlayer.OnCompletionListener completionListener; private MediaPlayer.OnSeekCompleteListener seekCompleteListener; private MediaPlayer.OnPreparedListener preparedListener; private MediaPlayer.OnInfoListener infoListener; private MediaPlayer.OnErrorListener errorListener; /** * Flag indicating how the shadow media player should behave when a method is invoked in an * invalid state. */ private InvalidStateBehavior invalidStateBehavior = InvalidStateBehavior.SILENT; private Handler handler; private static final MediaEvent completionCallback = new MediaEvent() { @Override public void run(MediaPlayer mp, ShadowMediaPlayer smp) { if (mp.isLooping()) { smp.startOffset = 0; smp.doStart(); } else { smp.doStop(); smp.invokeCompletionListener(); } } }; private static final MediaEvent preparedCallback = new MediaEvent() { @Override public void run(MediaPlayer mp, ShadowMediaPlayer smp) { smp.invokePreparedListener(); } }; private static final MediaEvent seekCompleteCallback = new MediaEvent() { @Override public void run(MediaPlayer mp, ShadowMediaPlayer smp) { smp.invokeSeekCompleteListener(); } }; /** * Callback to use when a method is invoked from an invalid state. Has {@code what = -38} and * {@code extra = 0}, which are values that were determined by inspection. */ private static final ErrorCallback invalidStateErrorCallback = new ErrorCallback(-38, 0); public static final int MEDIA_EVENT = 1; /** Callback to use for scheduled errors. */ private static class ErrorCallback implements MediaEvent { private int what; private int extra; public ErrorCallback(int what, int extra) { this.what = what; this.extra = extra; } @Override public void run(MediaPlayer mp, ShadowMediaPlayer smp) { smp.invokeErrorListener(what, extra); } } @Implementation protected static MediaPlayer create(Context context, int resId) { MediaPlayer mp = new MediaPlayer(); ShadowMediaPlayer shadow = Shadow.extract(mp); shadow.sourceResId = resId; try { shadow.setState(INITIALIZED); mp.setDataSource("android.resource://" + context.getPackageName() + "/" + resId); mp.prepare(); } catch (Exception e) { return null; } return mp; } @Implementation protected static MediaPlayer create(Context context, Uri uri) { MediaPlayer mp = new MediaPlayer(); try { mp.setDataSource(context, uri); mp.prepare(); } catch (Exception e) { return null; } return mp; } @Implementation protected void __constructor__() { // Contract of audioSessionId is that if it is 0 (which represents // the master mix) then that's an error. By default it generates // an ID that is unique system-wide. We could simulate guaranteed // uniqueness (get AudioManager's help?) but it's probably not // worth the effort - a random non-zero number will probably do. Random random = new Random(); audioSessionId = random.nextInt(Integer.MAX_VALUE) + 1; Looper myLooper = Looper.myLooper(); if (myLooper != null) { handler = getHandler(myLooper); } else { handler = getHandler(Looper.getMainLooper()); } // This gives test suites a chance to customize the MP instance // and its shadow when it is created, without having to modify // the code under test in order to do so. if (createListener != null) { createListener.onCreate(player, this); } // Ensure that the real object is set up properly. Shadow.invokeConstructor(MediaPlayer.class, player); } private Handler getHandler(Looper looper) { return new Handler(looper) { @Override public void handleMessage(Message msg) { switch (msg.what) { case MEDIA_EVENT: MediaEvent e = (MediaEvent) msg.obj; e.run(player, ShadowMediaPlayer.this); scheduleNextPlaybackEvent(); break; } } }; } /** * Common code path for all {@code setDataSource()} implementations. * *

* Checks for any specified exceptions for the specified data source and throws them. * * Checks the current state and throws an exception if it is in an invalid state. * If no * exception is thrown in either of the previous two steps, then {@link * #doSetDataSource(DataSource)} is called to set the data source. * Sets the player state to * {@code INITIALIZED}. Usually this method would not be called directly, but indirectly through * one of the other {@link #setDataSource(String)} implementations, which use {@link * DataSource#toDataSource(String)} methods to convert their discrete parameters into a single * {@link DataSource} instance. * * @param dataSource the data source that is being set. * @throws IOException if the specified data source has been configured to throw an IO exception. * @see #addException(DataSource, IOException) * @see #addException(DataSource, RuntimeException) * @see #doSetDataSource(DataSource) */ public void setDataSource(DataSource dataSource) throws IOException { Exception e = exceptions.get(dataSource); if (e != null) { e.fillInStackTrace(); if (e instanceof IOException) { throw (IOException) e; } else if (e instanceof RuntimeException) { throw (RuntimeException) e; } throw new AssertionError("Invalid exception type for setDataSource: <" + e + '>'); } checkStateException("setDataSource()", idleState); doSetDataSource(dataSource); state = INITIALIZED; } @Implementation protected void setDataSource(String path) throws IOException { setDataSource(toDataSource(path)); } @Implementation(maxSdk = N_MR1) protected void setDataSource(Context context, Uri uri) throws IOException { setDataSource(context, uri, null, null); } @Implementation(minSdk = ICE_CREAM_SANDWICH, maxSdk = N_MR1) protected void setDataSource(Context context, Uri uri, Map headers) throws IOException { setDataSource(context, uri, headers, null); } @Implementation(minSdk = O) protected void setDataSource( Context context, Uri uri, Map headers, List cookies) throws IOException { setDataSource(toDataSource(context, uri, headers, cookies)); sourceUri = uri; } @Implementation protected void setDataSource(String uri, Map headers) throws IOException { setDataSource(toDataSource(uri, headers)); } @Implementation protected void setDataSource(FileDescriptor fd, long offset, long length) throws IOException { setDataSource(toDataSource(fd, offset, length)); } @Implementation(minSdk = M) protected void setDataSource(MediaDataSource mediaDataSource) throws IOException { setDataSource(toDataSource(mediaDataSource)); } @Implementation(minSdk = N) protected void setDataSource(AssetFileDescriptor assetFileDescriptor) throws IOException { setDataSource(toDataSource(assetFileDescriptor)); } /** * Sets the data source without doing any other emulation. Sets the internal data source only. * Calling directly can be useful for setting up a {@link ShadowMediaPlayer} instance during * specific testing so that you don't have to clutter your tests catching exceptions you know * won't be thrown. * * @param dataSource the data source that is being set. * @see #setDataSource(DataSource) */ public void doSetDataSource(DataSource dataSource) { MediaInfo mediaInfo = mediaInfoProvider.get(dataSource); if (mediaInfo == null) { throw new IllegalArgumentException( "Don't know what to do with dataSource " + dataSource + " - either add an exception with addException() or media info with " + "addMediaInfo()"); } this.mediaInfo = mediaInfo; this.dataSource = dataSource; } public static MediaInfo getMediaInfo(DataSource dataSource) { return mediaInfoProvider.get(dataSource); } /** * Adds a {@link MediaInfo} for a {@link DataSource}. * *

This overrides any {@link MediaInfoProvider} previously set by calling {@link * #setMediaInfoProvider}, i.e., the provider will not be used for any {@link DataSource}. */ public static void addMediaInfo(DataSource dataSource, MediaInfo info) { ShadowMediaPlayer.mediaInfoProvider = DEFAULT_MEDIA_INFO_PROVIDER; mediaInfoMap.put(dataSource, info); } /** * Sets a {@link MediaInfoProvider} to be used to get {@link MediaInfo} for any {@link * DataSource}. * *

This overrides any {@link MediaInfo} previously set by calling {@link #addMediaInfo}, i.e., * {@link MediaInfo} provided by this {@link MediaInfoProvider} will be used instead. */ public static void setMediaInfoProvider(MediaInfoProvider mediaInfoProvider) { ShadowMediaPlayer.mediaInfoProvider = mediaInfoProvider; } public static void addException(DataSource dataSource, RuntimeException e) { exceptions.put(dataSource, e); } public static void addException(DataSource dataSource, IOException e) { exceptions.put(dataSource, e); } /** * Checks states for methods that only log when there is an error. Such methods throw an {@link * IllegalArgumentException} when invoked in the END state, but log an error in other disallowed * states. This method will either emulate this behavior or else will generate an assertion if * invoked from a disallowed state if {@link #setAssertOnError assertOnError} is set. * * @param method the name of the method being tested. * @param allowedStates the states that this method is allowed to be called from. * @see #setAssertOnError * @see #checkStateError(String, EnumSet) * @see #checkStateException(String, EnumSet) */ private void checkStateLog(String method, EnumSet allowedStates) { switch (invalidStateBehavior) { case SILENT: break; case EMULATE: if (state == END) { String msg = "Can't call " + method + " from state " + state; throw new IllegalStateException(msg); } break; case ASSERT: if (!allowedStates.contains(state) || state == END) { String msg = "Can't call " + method + " from state " + state; throw new AssertionError(msg); } } } /** * Checks states for methods that asynchronously invoke {@link * android.media.MediaPlayer.OnErrorListener#onError(MediaPlayer, int, int) onError()} when * invoked in an illegal state. Such methods always throw {@link IllegalStateException} rather * than invoke {@code onError()} if they are invoked from the END state. * *

This method will either emulate this behavior by posting an {@code onError()} callback to * the current thread's message queue (or throw an {@link IllegalStateException} if invoked from * the END state), or else it will generate an assertion if {@link #setAssertOnError * assertOnError} is set. * * @param method the name of the method being tested. * @param allowedStates the states that this method is allowed to be called from. * @see #getHandler * @see #setAssertOnError * @see #checkStateLog(String, EnumSet) * @see #checkStateException(String, EnumSet) */ private boolean checkStateError(String method, EnumSet allowedStates) { if (!allowedStates.contains(state)) { switch (invalidStateBehavior) { case SILENT: break; case EMULATE: if (state == END) { String msg = "Can't call " + method + " from state " + state; throw new IllegalStateException(msg); } state = ERROR; postEvent(invalidStateErrorCallback); return false; case ASSERT: String msg = "Can't call " + method + " from state " + state; throw new AssertionError(msg); } } return true; } /** * Checks states for methods that synchronously throw an exception when invoked in an illegal * state. This method will likewise throw an {@link IllegalArgumentException} if it determines * that the method has been invoked from a disallowed state, or else it will generate an assertion * if {@link #setAssertOnError assertOnError} is set. * * @param method the name of the method being tested. * @param allowedStates the states that this method is allowed to be called from. * @see #setAssertOnError * @see #checkStateLog(String, EnumSet) * @see #checkStateError(String, EnumSet) */ private void checkStateException(String method, EnumSet allowedStates) { if (!allowedStates.contains(state)) { String msg = "Can't call " + method + " from state " + state; switch (invalidStateBehavior) { case SILENT: break; case EMULATE: throw new IllegalStateException(msg); case ASSERT: throw new AssertionError(msg); } } } @Implementation protected void setOnCompletionListener(MediaPlayer.OnCompletionListener listener) { completionListener = listener; } @Implementation protected void setOnSeekCompleteListener(MediaPlayer.OnSeekCompleteListener listener) { seekCompleteListener = listener; } @Implementation protected void setOnPreparedListener(MediaPlayer.OnPreparedListener listener) { preparedListener = listener; } @Implementation protected void setOnInfoListener(MediaPlayer.OnInfoListener listener) { infoListener = listener; } @Implementation protected void setOnErrorListener(MediaPlayer.OnErrorListener listener) { errorListener = listener; } @Implementation protected boolean isLooping() { checkStateException("isLooping()", nonEndStates); return looping; } private static final EnumSet nonEndStates = EnumSet.complementOf(EnumSet.of(END)); private static final EnumSet nonErrorStates = EnumSet.complementOf(EnumSet.of(ERROR, END)); @Implementation protected void setLooping(boolean looping) { checkStateError("setLooping()", nonErrorStates); this.looping = looping; } @Implementation protected void setVolume(float left, float right) { checkStateError("setVolume()", nonErrorStates); leftVolume = left; rightVolume = right; } @Implementation protected boolean isPlaying() { checkStateError("isPlaying()", nonErrorStates); return state == STARTED; } private static EnumSet preparableStates = EnumSet.of(INITIALIZED, STOPPED); /** * Simulates {@link MediaPlayer#prepareAsync()}. Sleeps for {@link MediaInfo#getPreparationDelay() * preparationDelay} ms by calling {@link SystemClock#sleep(long)} before calling {@link * #invokePreparedListener()}. * *

If {@code preparationDelay} is not positive and non-zero, there is no sleep. * * @see MediaInfo#setPreparationDelay(int) * @see #invokePreparedListener() */ @Implementation protected void prepare() { checkStateException("prepare()", preparableStates); MediaInfo info = getMediaInfo(); if (info.preparationDelay > 0) { SystemClock.sleep(info.preparationDelay); } state = PREPARED; postEvent( (mp, smp) -> { if (preparedListener != null) { preparedListener.onPrepared(mp); } }); } /** * Simulates {@link MediaPlayer#prepareAsync()}. Sets state to PREPARING and posts a callback to * {@link #invokePreparedListener()} if the current preparation delay for the current media (see * {@link #getMediaInfo()}) is >= 0, otherwise the test suite is responsible for calling {@link * #invokePreparedListener()} directly if required. * * @see MediaInfo#setPreparationDelay(int) * @see #invokePreparedListener() */ @Implementation protected void prepareAsync() { checkStateException("prepareAsync()", preparableStates); state = PREPARING; MediaInfo info = getMediaInfo(); if (info.preparationDelay >= 0) { postEventDelayed(preparedCallback, info.preparationDelay); } } private static EnumSet startableStates = EnumSet.of(PREPARED, STARTED, PAUSED, PLAYBACK_COMPLETED); /** * Simulates private native method {@link MediaPlayer#_start()}. Sets state to STARTED and calls * {@link #doStart()} to start scheduling playback callback events. * *

If the current state is PLAYBACK_COMPLETED, the current position is reset to zero before * starting playback. * * @see #doStart() */ @Implementation protected void start() { if (checkStateError("start()", startableStates)) { if (state == PLAYBACK_COMPLETED) { startOffset = 0; } state = STARTED; doStart(); } } private void scheduleNextPlaybackEvent() { if (!isReallyPlaying()) { return; } final int currentPosition = getCurrentPositionRaw(); MediaInfo info = getMediaInfo(); Entry event = info.events.higherEntry(currentPosition); if (event == null) { // This means we've "seeked" past the end. Get the last // event (which should be the completion event) and // invoke that, setting the position to the duration. postEvent(completionCallback); } else { final int runListOffset = event.getKey(); nextPlaybackEvent = event.getValue(); postEventDelayed(nextPlaybackEvent, runListOffset - currentPosition); } } /** * Tests to see if the player is really playing. * *

The player is defined as "really playing" if simulated playback events (including playback * completion) are being scheduled and invoked and {@link #getCurrentPosition currentPosition} is * being updated as time passes. Note that while the player will normally be really playing if in * the STARTED state, this is not always the case - for example, if a pending seek is in progress, * or perhaps a buffer underrun is being simulated. * * @return {@code true} if the player is really playing or {@code false} if the player is * internally paused. * @see #doStart * @see #doStop */ public boolean isReallyPlaying() { return startTime >= 0; } /** * Starts simulated playback. Until this method is called, the player is not "really playing" (see * {@link #isReallyPlaying} for a definition of "really playing"). * *

This method is used internally by the various shadow method implementations of the * MediaPlayer public API, but may also be called directly by the test suite if you wish to * simulate an internal pause. For example, to simulate a buffer underrun (player is in PLAYING * state but isn't actually advancing the current position through the media), you could call * {@link #doStop()} to mark the start of the buffer underrun and {@link #doStart()} to mark its * end and restart normal playback (which is what {@link * ShadowMediaPlayer.MediaInfo#scheduleBufferUnderrunAtOffset(int, int) * scheduleBufferUnderrunAtOffset()} does). * * @see #isReallyPlaying() * @see #doStop() */ public void doStart() { startTime = SystemClock.uptimeMillis(); scheduleNextPlaybackEvent(); } /** * Pauses simulated playback. After this method is called, the player is no longer "really * playing" (see {@link #isReallyPlaying} for a definition of "really playing"). * *

This method is used internally by the various shadow method implementations of the * MediaPlayer public API, but may also be called directly by the test suite if you wish to * simulate an internal pause. * * @see #isReallyPlaying() * @see #doStart() */ public void doStop() { startOffset = getCurrentPositionRaw(); if (nextPlaybackEvent != null) { handler.removeMessages(MEDIA_EVENT); nextPlaybackEvent = null; } startTime = -1; } private static final EnumSet pausableStates = EnumSet.of(STARTED, PAUSED, PLAYBACK_COMPLETED); /** * Simulates {@link MediaPlayer#_pause()}. Invokes {@link #doStop()} to suspend playback event * callbacks and sets the state to PAUSED. * * @see #doStop() */ @Implementation protected void _pause() { if (checkStateError("pause()", pausableStates)) { doStop(); state = PAUSED; } } static final EnumSet allStates = EnumSet.allOf(State.class); /** * Simulates call to {@link MediaPlayer#_release()}. Calls {@link #doStop()} to suspend playback * event callbacks and sets the state to END. */ @Implementation protected void _release() { checkStateException("release()", allStates); doStop(); state = END; handler.removeMessages(MEDIA_EVENT); } /** * Simulates call to {@link MediaPlayer#_reset()}. Calls {@link #doStop()} to suspend playback * event callbacks and sets the state to IDLE. */ @Implementation protected void _reset() { checkStateException("reset()", nonEndStates); doStop(); state = IDLE; handler.removeMessages(MEDIA_EVENT); startOffset = 0; } private static final EnumSet stoppableStates = EnumSet.of(PREPARED, STARTED, PAUSED, STOPPED, PLAYBACK_COMPLETED); /** * Simulates call to {@link MediaPlayer#release()}. Calls {@link #doStop()} to suspend playback * event callbacks and sets the state to STOPPED. */ @Implementation protected void _stop() { if (checkStateError("stop()", stoppableStates)) { doStop(); state = STOPPED; } } private static final EnumSet attachableStates = EnumSet.of(INITIALIZED, PREPARING, PREPARED, STARTED, PAUSED, STOPPED, PLAYBACK_COMPLETED); @Implementation protected void attachAuxEffect(int effectId) { checkStateError("attachAuxEffect()", attachableStates); auxEffect = effectId; } @Implementation protected int getAudioSessionId() { checkStateException("getAudioSessionId()", allStates); return audioSessionId; } /** * Simulates call to {@link MediaPlayer#getCurrentPosition()}. Simply does the state validity * checks and then invokes {@link #getCurrentPositionRaw()} to calculate the simulated playback * position. * * @return The current offset (in ms) of the simulated playback. * @see #getCurrentPositionRaw() */ @Implementation protected int getCurrentPosition() { checkStateError("getCurrentPosition()", attachableStates); return getCurrentPositionRaw(); } /** * Simulates call to {@link MediaPlayer#getDuration()}. Retrieves the duration as defined by the * current {@link MediaInfo} instance. * * @return The duration (in ms) of the current simulated playback. * @see #addMediaInfo(DataSource, MediaInfo) */ @Implementation protected int getDuration() { checkStateError("getDuration()", stoppableStates); return getMediaInfo().duration; } @Implementation protected int getVideoHeight() { checkStateLog("getVideoHeight()", attachableStates); return videoHeight; } @Implementation protected int getVideoWidth() { checkStateLog("getVideoWidth()", attachableStates); return videoWidth; } private static final EnumSet seekableStates = EnumSet.of(PREPARED, STARTED, PAUSED, PLAYBACK_COMPLETED); /** * Simulates seeking to specified position. The seek will complete after {@link #seekDelay} ms * (defaults to 0), or else if seekDelay is negative then the controlling test is expected to * simulate seek completion by manually invoking {@link #invokeSeekCompleteListener}. * * @param seekTo the offset (in ms) from the start of the track to seek to. */ @Implementation protected void seekTo(int seekTo) { seekTo(seekTo, MediaPlayer.SEEK_PREVIOUS_SYNC); } @Implementation(minSdk = O) protected void seekTo(long seekTo, int mode) { boolean success = checkStateError("seekTo()", seekableStates); // Cancel any pending seek operations. handler.removeMessages(MEDIA_EVENT, seekCompleteCallback); if (success) { // Need to call doStop() before setting pendingSeek, // because if pendingSeek is called it changes // the behavior of getCurrentPosition(), which doStop() // depends on. doStop(); pendingSeek = (int) seekTo; if (seekDelay >= 0) { postEventDelayed(seekCompleteCallback, seekDelay); } } } private static final EnumSet idleState = EnumSet.of(IDLE); @Implementation protected void setAudioSessionId(int sessionId) { checkStateError("setAudioSessionId()", idleState); audioSessionId = sessionId; } private static final EnumSet nonPlayingStates = EnumSet.of(IDLE, INITIALIZED, STOPPED); @Implementation protected void setAudioStreamType(int audioStreamType) { checkStateError("setAudioStreamType()", nonPlayingStates); this.audioStreamType = audioStreamType; } /** * Sets a listener that is invoked whenever a new shadowed {@link MediaPlayer} object is * constructed. * *

Registering a listener gives you a chance to customize the shadowed object appropriately * without needing to modify the application-under-test to provide access to the instance at the * appropriate point in its life cycle. This is useful because normally a new {@link MediaPlayer} * is created and {@link #setDataSource setDataSource()} is invoked soon after, without a break in * the code. Using this callback means you don't have to change this common pattern just so that * you can customize the shadow for testing. * * @param createListener the listener to be invoked */ public static void setCreateListener(CreateListener createListener) { ShadowMediaPlayer.createListener = createListener; } /** * Retrieves the {@link Handler} object used by this {@code ShadowMediaPlayer}. Can be used for * posting custom asynchronous events to the thread (eg, asynchronous errors). Use this for * scheduling events to take place at a particular "real" time (ie, time as measured by the * scheduler). For scheduling events to occur at a particular playback offset (no matter how long * playback may be paused for, or where you seek to, etc), see {@link * MediaInfo#scheduleEventAtOffset(int, ShadowMediaPlayer.MediaEvent)} and its various helpers. * * @return Handler object that can be used to schedule asynchronous events on this media player. */ public Handler getHandler() { return handler; } /** * Retrieves current flag specifying the behavior of the media player when a method is invoked in * an invalid state. See {@link #setInvalidStateBehavior(InvalidStateBehavior)} for a discussion * of the available modes and their associated behaviors. * * @return The current invalid state behavior mode. * @see #setInvalidStateBehavior */ public InvalidStateBehavior getInvalidStateBehavior() { return invalidStateBehavior; } /** * Specifies how the media player should behave when a method is invoked in an invalid state. * Three modes are supported (as defined by the {@link InvalidStateBehavior} enum): * *

{@link InvalidStateBehavior#SILENT SILENT}

* * No invalid state checking is done at all. All methods can be invoked from any state without * throwing any exceptions or invoking the error listener. * *

This mode is provided primarily for backwards compatibility, and for this reason it is the * default. For proper testing one of the other two modes is probably preferable. * *

{@link InvalidStateBehavior#EMULATE EMULATE}

* * The shadow will attempt to emulate the behavior of the actual {@link MediaPlayer} * implementation. This is based on a reading of the documentation and on actual experiments done * on a Jelly Bean device. The official documentation is not all that clear, but basically methods * fall into three categories: * *
    *
  • Those that log an error when invoked in an invalid state but don't throw an exception or * invoke {@code onError()}. An example is {@link #getVideoHeight()}. *
  • Synchronous error handling: methods always throw an exception (usually {@link * IllegalStateException} but don't invoke {@code onError()}. Examples are {@link * #prepare()} and {@link #setDataSource(String)}. *
  • Asynchronous error handling: methods don't throw an exception but invoke {@code * onError()}. *
* * Additionally, all three methods behave synchronously (throwing {@link IllegalStateException} * when invoked from the END state. * *

To complicate matters slightly, the official documentation sometimes contradicts observed * behavior. For example, the documentation says it is illegal to call {@link #setDataSource} from * the ERROR state - however, in practice it works fine. Conversely, the documentation says that * it is legal to invoke {@link #getCurrentPosition()} from the INITIALIZED state, however testing * showed that this caused an error. Wherever there is a discrepancy between documented and * observed behavior, this implementation has gone with the most conservative implementation (ie, * it is illegal to invoke {@link #setDataSource} from the ERROR state and likewise illegal to * invoke {@link #getCurrentPosition()} from the INITIALIZED state. * *

{@link InvalidStateBehavior#ASSERT ASSERT}

* * The shadow will raise an assertion any time that a method is invoked in an invalid state. The * philosophy behind this mode is that to invoke a method in an invalid state is a programming * error - a bug, pure and simple. As such it should be discovered and eliminated at development * and testing time, rather than anticipated and handled at runtime. Asserting is a way of testing * for these bugs during testing. * * @param invalidStateBehavior the behavior mode for this shadow to use during testing. * @see #getInvalidStateBehavior() */ public void setInvalidStateBehavior(InvalidStateBehavior invalidStateBehavior) { this.invalidStateBehavior = invalidStateBehavior; } /** * Retrieves the currently selected {@link MediaInfo}. This instance is used to define current * duration, preparation delay, exceptions for {@code setDataSource()}, playback events, etc. * * @return The currently selected {@link MediaInfo}. * @see #addMediaInfo * @see #setMediaInfoProvider * @see #doSetDataSource(DataSource) */ public MediaInfo getMediaInfo() { return mediaInfo; } /** * Sets the current position, bypassing the normal state checking. Use with care. * * @param position the new playback position. */ public void setCurrentPosition(int position) { startOffset = position; } /** * Retrieves the current position without doing the state checking that the emulated version of * {@link #getCurrentPosition()} does. * * @return The current playback position within the current clip. */ public int getCurrentPositionRaw() { int currentPos = startOffset; if (isReallyPlaying()) { currentPos += (int) (SystemClock.uptimeMillis() - startTime); } return currentPos; } /** * Retrieves the current duration without doing the state checking that the emulated version does. * * @return The duration of the current clip loaded by the player. */ public int getDurationRaw() { return getMediaInfo().duration; } /** * Retrieves the current state of the {@link MediaPlayer}. Uses the states as defined in the * {@link MediaPlayer} documentation. * * @return The current state of the {@link MediaPlayer}, as defined in the MediaPlayer * documentation. * @see #setState * @see MediaPlayer */ public State getState() { return state; } /** * Forces the @link MediaPlayer} into the specified state. Uses the states as defined in the * {@link MediaPlayer} documentation. * *

Note that by invoking this method directly you can get the player into an inconsistent state * that a real player could not be put in (eg, in the END state but with playback events still * happening). Use with care. * * @param state the new state of the {@link MediaPlayer}, as defined in the MediaPlayer * documentation. * @see #getState * @see MediaPlayer */ public void setState(State state) { this.state = state; } /** * Note: This has a funny name at the moment to avoid having to produce an API-specific shadow - * if it were called {@code getAudioStreamType()} then the {@code RobolectricWiringTest} will * inform us that it should be annotated with {@link Implementation}, because there is a private * method in the later API versions with the same name, however this would fail on earlier * versions. * * @return audioStreamType */ public int getTheAudioStreamType() { return audioStreamType; } /** @return seekDelay */ public int getSeekDelay() { return seekDelay; } /** * Sets the length of time (ms) that seekTo() will delay before completing. Default is 0. If set * to -1, then seekTo() will not call the OnSeekCompleteListener automatically; you will need to * call invokeSeekCompleteListener() manually. * * @param seekDelay length of time to delay (ms) */ public void setSeekDelay(int seekDelay) { this.seekDelay = seekDelay; } /** * Useful for assertions. * * @return The current {@code auxEffect} setting. */ public int getAuxEffect() { return auxEffect; } /** * Retrieves the pending seek setting. * * @return The position to which the shadow player is seeking for the seek in progress (ie, after * the call to {@link #seekTo} but before a call to {@link #invokeSeekCompleteListener()}). * Returns {@code -1} if no seek is in progress. */ public int getPendingSeek() { return pendingSeek; } /** * Retrieves the data source (if any) that was passed in to {@link #setDataSource(DataSource)}. * *

Useful for assertions. * * @return The source passed in to {@code setDataSource}. */ public DataSource getDataSource() { return dataSource; } /** * Retrieves the source path (if any) that was passed in to {@link * MediaPlayer#setDataSource(Context, Uri, Map)} or {@link MediaPlayer#setDataSource(Context, * Uri)}. * * @return The source Uri passed in to {@code setDataSource}. */ public Uri getSourceUri() { return sourceUri; } /** * Retrieves the resource ID used in the call to {@link #create(Context, int)} (if any). * * @return The resource ID passed in to {@code create()}, or {@code -1} if a different method of * setting the source was used. */ public int getSourceResId() { return sourceResId; } /** * Retrieves the current setting for the left channel volume. * * @return The left channel volume. */ public float getLeftVolume() { return leftVolume; } /** @return The right channel volume. */ public float getRightVolume() { return rightVolume; } @Implementation(minSdk = P) protected boolean native_setOutputDevice(int preferredDeviceId) { return true; } private static EnumSet preparedStates = EnumSet.of(PREPARED, STARTED, PAUSED, PLAYBACK_COMPLETED); /** * Tests to see if the player is in the PREPARED state. This is mainly used for backward * compatibility. {@link #getState} may be more useful for new testing applications. * * @return {@code true} if the MediaPlayer is in the PREPARED state, false otherwise. */ public boolean isPrepared() { return preparedStates.contains(state); } /** @return the OnCompletionListener */ public MediaPlayer.OnCompletionListener getOnCompletionListener() { return completionListener; } /** @return the OnPreparedListener */ public MediaPlayer.OnPreparedListener getOnPreparedListener() { return preparedListener; } /** * Allows test cases to simulate 'prepared' state by invoking callback. Sets the player's state to * PREPARED and invokes the {@link MediaPlayer.OnPreparedListener#onPrepared preparedListener()} */ public void invokePreparedListener() { state = PREPARED; if (preparedListener == null) { return; } preparedListener.onPrepared(player); } /** * Simulates end-of-playback. Changes the player into PLAYBACK_COMPLETED state and calls {@link * MediaPlayer.OnCompletionListener#onCompletion(MediaPlayer) onCompletion()} if a listener has * been set. */ public void invokeCompletionListener() { state = PLAYBACK_COMPLETED; if (completionListener == null) { return; } completionListener.onCompletion(player); } /** Allows test cases to simulate seek completion by invoking callback. */ public void invokeSeekCompleteListener() { int duration = getMediaInfo().duration; setCurrentPosition(pendingSeek > duration ? duration : pendingSeek < 0 ? 0 : pendingSeek); pendingSeek = -1; if (state == STARTED) { doStart(); } if (seekCompleteListener == null) { return; } seekCompleteListener.onSeekComplete(player); } /** * Allows test cases to directly simulate invocation of the OnInfo event. * * @param what parameter to pass in to {@code what} in {@link * MediaPlayer.OnInfoListener#onInfo(MediaPlayer, int, int)}. * @param extra parameter to pass in to {@code extra} in {@link * MediaPlayer.OnInfoListener#onInfo(MediaPlayer, int, int)}. */ public void invokeInfoListener(int what, int extra) { if (infoListener != null) { infoListener.onInfo(player, what, extra); } } /** * Allows test cases to directly simulate invocation of the OnError event. * * @param what parameter to pass in to {@code what} in {@link * MediaPlayer.OnErrorListener#onError(MediaPlayer, int, int)}. * @param extra parameter to pass in to {@code extra} in {@link * MediaPlayer.OnErrorListener#onError(MediaPlayer, int, int)}. */ public void invokeErrorListener(int what, int extra) { // Calling doStop() un-schedules the next event and // stops normal event flow from continuing. doStop(); state = ERROR; boolean handled = errorListener != null && errorListener.onError(player, what, extra); if (!handled) { // The documentation isn't very clear if onCompletion is // supposed to be called from non-playing states // (ie, states other than STARTED or PAUSED). Testing // revealed that onCompletion is invoked even if playback // hasn't started or is not in progress. invokeCompletionListener(); // Need to set this again because // invokeCompletionListener() will set the state // to PLAYBACK_COMPLETED state = ERROR; } } @Resetter public static void resetStaticState() { createListener = null; mediaInfoProvider = DEFAULT_MEDIA_INFO_PROVIDER; exceptions.clear(); mediaInfoMap.clear(); DataSource.reset(); } }





© 2015 - 2025 Weber Informatics LLC | Privacy Policy