org.robolectric.shadows.ShadowMediaPlayer Maven / Gradle / Ivy
package org.robolectric.shadows;
import java.io.FileDescriptor;
import java.io.IOException;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Random;
import java.util.HashMap;
import java.util.TreeMap;
import android.content.Context;
import android.media.MediaPlayer;
import android.net.Uri;
import android.os.Handler;
import android.os.Message;
import android.os.SystemClock;
import org.robolectric.annotation.Implementation;
import org.robolectric.annotation.Implements;
import org.robolectric.annotation.RealObject;
import org.robolectric.annotation.Resetter;
import org.robolectric.internal.Shadow;
import org.robolectric.shadows.util.DataSource;
import static org.robolectric.Shadows.shadowOf;
import static org.robolectric.shadows.ShadowMediaPlayer.State.*;
import static org.robolectric.shadows.util.DataSource.toDataSource;
/**
* Shadow for {@link android.media.MediaPlayer}.
*
* 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 ShadowMediaPlayer
with Robolectric.
*
* This shadow implementation provides much of the functionality needed to
* emulate {@link MediaPlayer} initialization & 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 seek delay}.
* - 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}.
* - 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 or a {@link ShadowMediaPlayer.MediaInfo} instance for that data source
* (using {@link #addException} or {@link addMediaInfo} respectively) before
* calling {@link #setDataSource}, otherwise you'll get an
* {@link IllegalArgumentException}.
*
* The current features of ShadowMediaPlayer
were focussed 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 {
public static void __staticInitializer__() {
// don't bind the JNI library
}
/**
* 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 mediaInfo = new HashMap<>();
@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 static 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 static 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 MediaInfo
object with default duration (1000ms)
* and default preparation delay (0ms).
*/
public MediaInfo() {
this(1000, 0);
}
/**
* Creates a new MediaInfo
object with the given duration and
* preparation delay. A completion callback event is scheduled at
* 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 &
* 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 what
parameter to use in the call
* to
* {@link android.media.MediaPlayer.OnErrorListener#onError(MediaPlayer, int, int)
* onError()}.
* @param extra
* the value for the extra
parameter to use in the
* call toH
* {@link android.media.MediaPlayer.OnErrorListener#onError(MediaPlayer, int, int)
* onError()}.
* @return A reference to the MediaEvent object that was created & 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 what
parameter to use in the call
* to
* {@link android.media.MediaPlayer.OnInfoListener#onInfo(MediaPlayer, int, int)
* onInfo()}.
* @param extra
* the value for the 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 & 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 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 & 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;
/** 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
* what = -38
and 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
public static MediaPlayer create(Context context, int resId) {
MediaPlayer mp = new MediaPlayer();
ShadowMediaPlayer shadow = shadowOf(mp);
shadow.sourceResId = resId;
try {
shadow.setState(INITIALIZED);
mp.prepare();
} catch (Exception e) {
return null;
}
return mp;
}
@Implementation
public 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;
}
public 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;
handler = new Handler() {
@Override
public void handleMessage(Message msg) {
switch (msg.what) {
case MEDIA_EVENT:
MediaEvent e = (MediaEvent)msg.obj;
e.run(player, ShadowMediaPlayer.this);
scheduleNextPlaybackEvent();
break;
}
}
};
// 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);
}
/**
* Common code path for all 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
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;
}
/**
* 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) {
if (mediaInfo.get(dataSource) == 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.dataSource = dataSource;
}
@Implementation
public void setDataSource(String path) throws IOException {
setDataSource(toDataSource(path));
}
@Implementation
public void setDataSource(Context context, Uri uri, Map headers) throws IOException {
setDataSource(toDataSource(context, uri, headers));
sourceUri = uri;
}
@Implementation
public void setDataSource(String uri, Map headers) throws IOException {
setDataSource(toDataSource(uri, headers));
}
@Implementation
public void setDataSource(FileDescriptor fd, long offset, long length) throws IOException {
setDataSource(toDataSource(fd, offset, length));
}
public static MediaInfo getMediaInfo(DataSource dataSource) {
return mediaInfo.get(dataSource);
}
public static void addMediaInfo(DataSource dataSource, MediaInfo info) {
mediaInfo.put(dataSource, info);
}
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 onError()
if
* they are invoked from the END state.
*
* This method will either emulate this behavior by posting an
* 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
public void setOnCompletionListener(MediaPlayer.OnCompletionListener listener) {
completionListener = listener;
}
@Implementation
public void setOnSeekCompleteListener(
MediaPlayer.OnSeekCompleteListener listener) {
seekCompleteListener = listener;
}
@Implementation
public void setOnPreparedListener(MediaPlayer.OnPreparedListener listener) {
preparedListener = listener;
}
@Implementation
public void setOnInfoListener(MediaPlayer.OnInfoListener listener) {
infoListener = listener;
}
@Implementation
public void setOnErrorListener(MediaPlayer.OnErrorListener listener) {
errorListener = listener;
}
@Implementation
public boolean isLooping() {
checkStateException("isLooping()", nonEndStates);
return looping;
}
static private final EnumSet nonEndStates = EnumSet
.complementOf(EnumSet.of(END));
static private final EnumSet nonErrorStates = EnumSet
.complementOf(EnumSet.of(ERROR, END));
@Implementation
public void setLooping(boolean looping) {
checkStateError("setLooping()", nonErrorStates);
this.looping = looping;
}
@Implementation
public void setVolume(float left, float right) {
checkStateError("setVolume()", nonErrorStates);
leftVolume = left;
rightVolume = right;
}
@Implementation
public 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 preparationDelay
is not positigetve and non-zero, there is no
* sleep.
*
* @see MediaInfo#setPreparationDelay(int)
* @see #invokePreparedListener()
*/
@Implementation
public void prepare() {
checkStateException("prepare()", preparableStates);
MediaInfo info = getMediaInfo();
if (info.preparationDelay > 0) {
SystemClock.sleep(info.preparationDelay);
}
invokePreparedListener();
}
/**
* 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
public 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
public 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 & 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 true
if the player is really playing or
* 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
public 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
public 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
public void _reset() {
checkStateException("reset()", nonEndStates);
doStop();
state = IDLE;
handler.removeMessages(MEDIA_EVENT);
startOffset = 0;
}
static private 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
public 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
public void attachAuxEffect(int effectId) {
checkStateError("attachAuxEffect()", attachableStates);
auxEffect = effectId;
}
@Implementation
public 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
public 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
*/
@Implementation
public int getDuration() {
checkStateError("getDuration()", stoppableStates);
return getMediaInfo().duration;
}
@Implementation
public int getVideoHeight() {
checkStateLog("getVideoHeight()", attachableStates);
return videoHeight;
}
@Implementation
public 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
public void seekTo(int seekTo) {
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 = seekTo;
if (seekDelay >= 0) {
postEventDelayed(seekCompleteCallback, seekDelay);
}
}
}
static private final EnumSet idleState = EnumSet.of(IDLE);
@Implementation
public void setAudioSessionId(int sessionId) {
checkStateError("setAudioSessionId()", idleState);
audioSessionId = sessionId;
}
static private final EnumSet nonPlayingStates = EnumSet.of(IDLE,
INITIALIZED, STOPPED);
@Implementation
public 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
* 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):
*
*
* - 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.
* - 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
onError()
. An example is
* {@link #getVideoHeight()}.
* - Synchronous error handling: methods always throw an exception (usually
* {@link IllegalStateException} but don't invoke
onError()
.
* Examples are {@link #prepare()} and {@link #setDataSource(String)}.
* - Asynchronous error handling: methods don't throw an exception but
* invoke
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.
* - 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 & eliminated at development &
* 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
* setDataSource()
, playback events, etc.
*
* @return The currently selected {@link MediaInfo}.
* @see #addMediaInfo
* @see #doSetDataSource(DataSource)
*/
public MediaInfo getMediaInfo() {
return mediaInfo.get(dataSource);
}
/**
* Sets the current position, bypassing the normal state checking. Use with
* care. Non-Android setter.
*
* @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. Non-Android
* accessor.
*
* @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. Non-Android accessor.
*
* @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. Non-Android accessor.
* Used for assertions.
*
* @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;
}
/**
* Non-Android accessor. Note: This has a funny name at the
* moment to avoid having to produce an API-specific shadow -
* if it were called getAudioStreamType()
then
* the 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;
}
/**
* Non-Android accessor.
*
* @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;
}
/**
* Non-Android accessor. Used for assertions.
*
* @return The current 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 -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)}.
*
* Non-Android accessor. Use for assertions.
*
* @return The source passed in to 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)}.
*
* Non-Android accessor. Use for assertions.
*
* @return The source Uri passed in to setDataSource
.
*/
public Uri getSourceUri() {
return sourceUri;
}
/**
* Retrieves the resource ID used in the call to {@link #create(Context, int)}
* (if any).
*
* Non-Android accessor. Use for assertions.
*
* @return The resource ID passed in to create()
, or
* -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.
*
* Non-Android accessor. Use for assertions.
*
* @return The left channel volume.
*/
public float getLeftVolume() {
return leftVolume;
}
/**
* Non-Android accessor. Use for assertions.
*
* @return The right channel volume.
*/
public float getRightVolume() {
return rightVolume;
}
private static EnumSet preparedStates = EnumSet.of(PREPARED, STARTED,
PAUSED, PLAYBACK_COMPLETED);
/**
* Tests to see if the player is in the PREPARED state. Non-Android accessor.
* Use for assertions. This is mainly used for backward compatibility.
* {@link #getState} may be more useful for new testing applications.
*
* @return true
if the MediaPlayer is in the PREPARED state,
* false otherwise.
*/
public boolean isPrepared() {
return preparedStates.contains(state);
}
/**
* Non-Android accessor. Use for assertions.
*
* @return the OnCompletionListener
*/
public MediaPlayer.OnCompletionListener getOnCompletionListener() {
return completionListener;
}
/**
* Non-Android accessor. Use for assertions.
* @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 what
in
* {@link MediaPlayer.OnInfoListener#onInfo onInfo()}.
* @param extra
* parameter to pass in to extra
in
* {@link MediaPlayer.OnInfoListener#onInfo onInfo()}.
*/
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 what
in
* {@link MediaPlayer.OnErrorListener#onError onError()}.
* @param extra
* parameter to pass in to extra
in
* {@link MediaPlayer.OnErrorListener#onError onError()}.
*/
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;
exceptions.clear();
mediaInfo.clear();
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy