blob: 2c7ba5278d13f4d21a4faabed258217a81551467 [file] [log] [blame]
/*
* Copyright (C) 2011 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.contacts.voicemail;
import static android.util.MathUtils.constrain;
import android.content.Context;
import android.database.ContentObserver;
import android.media.AudioManager;
import android.media.MediaPlayer;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
import android.os.Handler;
import android.os.PowerManager;
import android.view.View;
import android.widget.SeekBar;
import com.android.contacts.R;
import com.android.contacts.util.AsyncTaskExecutor;
import com.android.ex.variablespeed.MediaPlayerProxy;
import com.android.ex.variablespeed.SingleThreadedMediaPlayerProxy;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Preconditions;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import javax.annotation.concurrent.GuardedBy;
import javax.annotation.concurrent.NotThreadSafe;
import javax.annotation.concurrent.ThreadSafe;
/**
* Contains the controlling logic for a voicemail playback ui.
* <p>
* Specifically right now this class is used to control the
* {@link com.android.contacts.voicemail.VoicemailPlaybackFragment}.
* <p>
* This class is not thread safe. The thread policy for this class is
* thread-confinement, all calls into this class from outside must be done from
* the main ui thread.
*/
@NotThreadSafe
@VisibleForTesting
public class VoicemailPlaybackPresenter {
/** The stream used to playback voicemail. */
private static final int PLAYBACK_STREAM = AudioManager.STREAM_VOICE_CALL;
/** Contract describing the behaviour we need from the ui we are controlling. */
public interface PlaybackView {
Context getDataSourceContext();
void runOnUiThread(Runnable runnable);
void setStartStopListener(View.OnClickListener listener);
void setPositionSeekListener(SeekBar.OnSeekBarChangeListener listener);
void setSpeakerphoneListener(View.OnClickListener listener);
void setIsBuffering();
void setClipPosition(int clipPositionInMillis, int clipLengthInMillis);
int getDesiredClipPosition();
void playbackStarted();
void playbackStopped();
void playbackError(Exception e);
boolean isSpeakerPhoneOn();
void setSpeakerPhoneOn(boolean on);
void finish();
void setRateDisplay(float rate, int stringResourceId);
void setRateIncreaseButtonListener(View.OnClickListener listener);
void setRateDecreaseButtonListener(View.OnClickListener listener);
void setIsFetchingContent();
void disableUiElements();
void enableUiElements();
void sendFetchVoicemailRequest(Uri voicemailUri);
boolean queryHasContent(Uri voicemailUri);
void setFetchContentTimeout();
void registerContentObserver(Uri uri, ContentObserver observer);
void unregisterContentObserver(ContentObserver observer);
void enableProximitySensor();
void disableProximitySensor();
void setVolumeControlStream(int streamType);
}
/** The enumeration of {@link AsyncTask} objects we use in this class. */
public enum Tasks {
CHECK_FOR_CONTENT,
CHECK_CONTENT_AFTER_CHANGE,
PREPARE_MEDIA_PLAYER,
RESET_PREPARE_START_MEDIA_PLAYER,
}
/** Update rate for the slider, 30fps. */
private static final int SLIDER_UPDATE_PERIOD_MILLIS = 1000 / 30;
/** Time our ui will wait for content to be fetched before reporting not available. */
private static final long FETCH_CONTENT_TIMEOUT_MS = 20000;
/**
* If present in the saved instance bundle, we should not resume playback on
* create.
*/
private static final String PAUSED_STATE_KEY = VoicemailPlaybackPresenter.class.getName()
+ ".PAUSED_STATE_KEY";
/**
* If present in the saved instance bundle, indicates where to set the
* playback slider.
*/
private static final String CLIP_POSITION_KEY = VoicemailPlaybackPresenter.class.getName()
+ ".CLIP_POSITION_KEY";
/** The preset variable-speed rates. Each is greater than the previous by 25%. */
private static final float[] PRESET_RATES = new float[] {
0.64f, 0.8f, 1.0f, 1.25f, 1.5625f
};
/** The string resource ids corresponding to the names given to the above preset rates. */
private static final int[] PRESET_NAMES = new int[] {
R.string.voicemail_speed_slowest,
R.string.voicemail_speed_slower,
R.string.voicemail_speed_normal,
R.string.voicemail_speed_faster,
R.string.voicemail_speed_fastest,
};
/**
* Pointer into the {@link VoicemailPlaybackPresenter#PRESET_RATES} array.
* <p>
* This doesn't need to be synchronized, it's used only by the {@link RateChangeListener}
* which in turn is only executed on the ui thread. This can't be encapsulated inside the
* rate change listener since multiple rate change listeners must share the same value.
*/
private int mRateIndex = 2;
/**
* The most recently calculated duration.
* <p>
* We cache this in a field since we don't want to keep requesting it from the player, as
* this can easily lead to throwing {@link IllegalStateException} (any time the player is
* released, it's illegal to ask for the duration).
*/
private final AtomicInteger mDuration = new AtomicInteger(0);
private final PlaybackView mView;
private final MediaPlayerProxy mPlayer;
private final PositionUpdater mPositionUpdater;
/** Voicemail uri to play. */
private final Uri mVoicemailUri;
/** Start playing in onCreate iff this is true. */
private final boolean mStartPlayingImmediately;
/** Used to run async tasks that need to interact with the ui. */
private final AsyncTaskExecutor mAsyncTaskExecutor;
/**
* Used to handle the result of a successful or time-out fetch result.
* <p>
* This variable is thread-contained, accessed only on the ui thread.
*/
private FetchResultHandler mFetchResultHandler;
private PowerManager.WakeLock mWakeLock;
private AsyncTask<Void, ?, ?> mPrepareTask;
public VoicemailPlaybackPresenter(PlaybackView view, MediaPlayerProxy player,
Uri voicemailUri, ScheduledExecutorService executorService,
boolean startPlayingImmediately, AsyncTaskExecutor asyncTaskExecutor,
PowerManager.WakeLock wakeLock) {
mView = view;
mPlayer = player;
mVoicemailUri = voicemailUri;
mStartPlayingImmediately = startPlayingImmediately;
mAsyncTaskExecutor = asyncTaskExecutor;
mPositionUpdater = new PositionUpdater(executorService, SLIDER_UPDATE_PERIOD_MILLIS);
mWakeLock = wakeLock;
}
public void onCreate(Bundle bundle) {
mView.setVolumeControlStream(PLAYBACK_STREAM);
checkThatWeHaveContent();
}
/**
* Checks to see if we have content available for this voicemail.
* <p>
* This method will be called once, after the fragment has been created, before we know if the
* voicemail we've been asked to play has any content available.
* <p>
* This method will notify the user through the ui that we are fetching the content, then check
* to see if the content field in the db is set. If set, we proceed to
* {@link #postSuccessfullyFetchedContent()} method. If not set, we will make a request to fetch
* the content asynchronously via {@link #makeRequestForContent()}.
*/
private void checkThatWeHaveContent() {
mView.setIsFetchingContent();
mAsyncTaskExecutor.submit(Tasks.CHECK_FOR_CONTENT, new AsyncTask<Void, Void, Boolean>() {
@Override
public Boolean doInBackground(Void... params) {
return mView.queryHasContent(mVoicemailUri);
}
@Override
public void onPostExecute(Boolean hasContent) {
if (hasContent) {
postSuccessfullyFetchedContent();
} else {
makeRequestForContent();
}
}
});
}
/**
* Makes a broadcast request to ask that a voicemail source fetch this content.
* <p>
* This method <b>must be called on the ui thread</b>.
* <p>
* This method will be called when we realise that we don't have content for this voicemail. It
* will trigger a broadcast to request that the content be downloaded. It will add a listener to
* the content resolver so that it will be notified when the has_content field changes. It will
* also set a timer. If the has_content field changes to true within the allowed time, we will
* proceed to {@link #postSuccessfullyFetchedContent()}. If the has_content field does not
* become true within the allowed time, we will update the ui to reflect the fact that content
* was not available.
*/
private void makeRequestForContent() {
Handler handler = new Handler();
Preconditions.checkState(mFetchResultHandler == null, "mFetchResultHandler should be null");
mFetchResultHandler = new FetchResultHandler(handler);
mView.registerContentObserver(mVoicemailUri, mFetchResultHandler);
handler.postDelayed(mFetchResultHandler.getTimeoutRunnable(), FETCH_CONTENT_TIMEOUT_MS);
mView.sendFetchVoicemailRequest(mVoicemailUri);
}
@ThreadSafe
private class FetchResultHandler extends ContentObserver implements Runnable {
private AtomicBoolean mResultStillPending = new AtomicBoolean(true);
private final Handler mHandler;
public FetchResultHandler(Handler handler) {
super(handler);
mHandler = handler;
}
public Runnable getTimeoutRunnable() {
return this;
}
@Override
public void run() {
if (mResultStillPending.getAndSet(false)) {
mView.unregisterContentObserver(FetchResultHandler.this);
mView.setFetchContentTimeout();
}
}
public void destroy() {
if (mResultStillPending.getAndSet(false)) {
mView.unregisterContentObserver(FetchResultHandler.this);
mHandler.removeCallbacks(this);
}
}
@Override
public void onChange(boolean selfChange) {
mAsyncTaskExecutor.submit(Tasks.CHECK_CONTENT_AFTER_CHANGE,
new AsyncTask<Void, Void, Boolean>() {
@Override
public Boolean doInBackground(Void... params) {
return mView.queryHasContent(mVoicemailUri);
}
@Override
public void onPostExecute(Boolean hasContent) {
if (hasContent) {
if (mResultStillPending.getAndSet(false)) {
mView.unregisterContentObserver(FetchResultHandler.this);
postSuccessfullyFetchedContent();
}
}
}
});
}
}
/**
* Prepares the voicemail content for playback.
* <p>
* This method will be called once we know that our voicemail has content (according to the
* content provider). This method will try to prepare the data source through the media player.
* If preparing the media player works, we will call through to
* {@link #postSuccessfulPrepareActions()}. If preparing the media player fails (perhaps the
* file the content provider points to is actually missing, perhaps it is of an unknown file
* format that we can't play, who knows) then we will show an error on the ui.
*/
private void postSuccessfullyFetchedContent() {
mView.setIsBuffering();
mAsyncTaskExecutor.submit(Tasks.PREPARE_MEDIA_PLAYER,
new AsyncTask<Void, Void, Exception>() {
@Override
public Exception doInBackground(Void... params) {
try {
mPlayer.reset();
mPlayer.setDataSource(mView.getDataSourceContext(), mVoicemailUri);
mPlayer.setAudioStreamType(PLAYBACK_STREAM);
mPlayer.prepare();
return null;
} catch (Exception e) {
return e;
}
}
@Override
public void onPostExecute(Exception exception) {
if (exception == null) {
postSuccessfulPrepareActions();
} else {
mView.playbackError(exception);
}
}
});
}
/**
* Enables the ui, and optionally starts playback immediately.
* <p>
* This will be called once we have successfully prepared the media player, and will optionally
* playback immediately.
*/
private void postSuccessfulPrepareActions() {
mView.enableUiElements();
mView.setPositionSeekListener(new PlaybackPositionListener());
mView.setStartStopListener(new StartStopButtonListener());
mView.setSpeakerphoneListener(new SpeakerphoneListener());
mPlayer.setOnErrorListener(new MediaPlayerErrorListener());
mPlayer.setOnCompletionListener(new MediaPlayerCompletionListener());
mView.setSpeakerPhoneOn(mView.isSpeakerPhoneOn());
mView.setRateDecreaseButtonListener(createRateDecreaseListener());
mView.setRateIncreaseButtonListener(createRateIncreaseListener());
mView.setClipPosition(0, mPlayer.getDuration());
mView.playbackStopped();
// Always disable on stop.
mView.disableProximitySensor();
if (mStartPlayingImmediately) {
resetPrepareStartPlaying(0);
}
// TODO: Now I'm ignoring the bundle, when previously I was checking for contains against
// the PAUSED_STATE_KEY, and CLIP_POSITION_KEY.
}
public void onSaveInstanceState(Bundle outState) {
outState.putInt(CLIP_POSITION_KEY, mView.getDesiredClipPosition());
if (!mPlayer.isPlaying()) {
outState.putBoolean(PAUSED_STATE_KEY, true);
}
}
public void onDestroy() {
mPlayer.release();
if (mFetchResultHandler != null) {
mFetchResultHandler.destroy();
mFetchResultHandler = null;
}
mPositionUpdater.stopUpdating();
if (mWakeLock.isHeld()) {
mWakeLock.release();
}
}
private class MediaPlayerErrorListener implements MediaPlayer.OnErrorListener {
@Override
public boolean onError(MediaPlayer mp, int what, int extra) {
mView.runOnUiThread(new Runnable() {
@Override
public void run() {
handleError(new IllegalStateException("MediaPlayer error listener invoked"));
}
});
return true;
}
}
private class MediaPlayerCompletionListener implements MediaPlayer.OnCompletionListener {
@Override
public void onCompletion(final MediaPlayer mp) {
mView.runOnUiThread(new Runnable() {
@Override
public void run() {
handleCompletion(mp);
}
});
}
}
public View.OnClickListener createRateDecreaseListener() {
return new RateChangeListener(false);
}
public View.OnClickListener createRateIncreaseListener() {
return new RateChangeListener(true);
}
/**
* Listens to clicks on the rate increase and decrease buttons.
* <p>
* This class is not thread-safe, but all interactions with it will happen on the ui thread.
*/
private class RateChangeListener implements View.OnClickListener {
private final boolean mIncrease;
public RateChangeListener(boolean increase) {
mIncrease = increase;
}
@Override
public void onClick(View v) {
// Adjust the current rate, then clamp it to the allowed values.
mRateIndex = constrain(mRateIndex + (mIncrease ? 1 : -1), 0, PRESET_RATES.length - 1);
// Whether or not we have actually changed the index, call changeRate().
// This will ensure that we show the "fastest" or "slowest" text on the ui to indicate
// to the user that it doesn't get any faster or slower.
changeRate(PRESET_RATES[mRateIndex], PRESET_NAMES[mRateIndex]);
}
}
private void resetPrepareStartPlaying(final int clipPositionInMillis) {
if (mPrepareTask != null) {
mPrepareTask.cancel(false);
}
mPrepareTask = mAsyncTaskExecutor.submit(Tasks.RESET_PREPARE_START_MEDIA_PLAYER,
new AsyncTask<Void, Void, Exception>() {
@Override
public Exception doInBackground(Void... params) {
try {
mPlayer.reset();
mPlayer.setDataSource(mView.getDataSourceContext(), mVoicemailUri);
mPlayer.setAudioStreamType(PLAYBACK_STREAM);
mPlayer.prepare();
return null;
} catch (Exception e) {
return e;
}
}
@Override
public void onPostExecute(Exception exception) {
mPrepareTask = null;
if (exception == null) {
mDuration.set(mPlayer.getDuration());
int startPosition =
constrain(clipPositionInMillis, 0, mDuration.get());
mView.setClipPosition(startPosition, mDuration.get());
mPlayer.seekTo(startPosition);
mPlayer.start();
mView.playbackStarted();
if (!mWakeLock.isHeld()) {
mWakeLock.acquire();
}
// Only enable if we are not currently using the speaker phone.
if (!mView.isSpeakerPhoneOn()) {
mView.enableProximitySensor();
}
mPositionUpdater.startUpdating(startPosition, mDuration.get());
} else {
handleError(exception);
}
}
});
}
private void handleError(Exception e) {
mView.playbackError(e);
mPositionUpdater.stopUpdating();
mPlayer.release();
}
public void handleCompletion(MediaPlayer mediaPlayer) {
stopPlaybackAtPosition(0, mDuration.get());
}
private void stopPlaybackAtPosition(int clipPosition, int duration) {
mPositionUpdater.stopUpdating();
mView.playbackStopped();
if (mWakeLock.isHeld()) {
mWakeLock.release();
}
// Always disable on stop.
mView.disableProximitySensor();
mView.setClipPosition(clipPosition, duration);
if (mPlayer.isPlaying()) {
mPlayer.pause();
}
}
private class PlaybackPositionListener implements SeekBar.OnSeekBarChangeListener {
private boolean mShouldResumePlaybackAfterSeeking;
@Override
public void onStartTrackingTouch(SeekBar arg0) {
if (mPlayer.isPlaying()) {
mShouldResumePlaybackAfterSeeking = true;
stopPlaybackAtPosition(mPlayer.getCurrentPosition(), mDuration.get());
} else {
mShouldResumePlaybackAfterSeeking = false;
}
}
@Override
public void onStopTrackingTouch(SeekBar arg0) {
if (mPlayer.isPlaying()) {
stopPlaybackAtPosition(mPlayer.getCurrentPosition(), mDuration.get());
}
if (mShouldResumePlaybackAfterSeeking) {
resetPrepareStartPlaying(mView.getDesiredClipPosition());
}
}
@Override
public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
mView.setClipPosition(seekBar.getProgress(), seekBar.getMax());
}
}
private void changeRate(float rate, int stringResourceId) {
((SingleThreadedMediaPlayerProxy) mPlayer).setVariableSpeed(rate);
mView.setRateDisplay(rate, stringResourceId);
}
private class SpeakerphoneListener implements View.OnClickListener {
@Override
public void onClick(View v) {
boolean previousState = mView.isSpeakerPhoneOn();
mView.setSpeakerPhoneOn(!previousState);
if (mPlayer.isPlaying() && previousState) {
// If we are currently playing and we are disabling the speaker phone, enable the
// sensor.
mView.enableProximitySensor();
} else {
// If we are not currently playing, disable the sensor.
mView.disableProximitySensor();
}
}
}
private class StartStopButtonListener implements View.OnClickListener {
@Override
public void onClick(View arg0) {
if (mPlayer.isPlaying()) {
stopPlaybackAtPosition(mPlayer.getCurrentPosition(), mDuration.get());
} else {
resetPrepareStartPlaying(mView.getDesiredClipPosition());
}
}
}
/**
* Controls the animation of the playback slider.
*/
@ThreadSafe
private final class PositionUpdater implements Runnable {
private final ScheduledExecutorService mExecutorService;
private final int mPeriodMillis;
private final Object mLock = new Object();
@GuardedBy("mLock") private ScheduledFuture<?> mScheduledFuture;
private final Runnable mSetClipPostitionRunnable = new Runnable() {
@Override
public void run() {
int currentPosition = 0;
synchronized (mLock) {
if (mScheduledFuture == null) {
// This task has been canceled. Just stop now.
return;
}
currentPosition = mPlayer.getCurrentPosition();
}
mView.setClipPosition(currentPosition, mDuration.get());
}
};
public PositionUpdater(ScheduledExecutorService executorService, int periodMillis) {
mExecutorService = executorService;
mPeriodMillis = periodMillis;
}
@Override
public void run() {
mView.runOnUiThread(mSetClipPostitionRunnable);
}
public void startUpdating(int beginPosition, int endPosition) {
synchronized (mLock) {
if (mScheduledFuture != null) {
mScheduledFuture.cancel(false);
}
mScheduledFuture = mExecutorService.scheduleAtFixedRate(this, 0, mPeriodMillis,
TimeUnit.MILLISECONDS);
}
}
public void stopUpdating() {
synchronized (mLock) {
if (mScheduledFuture != null) {
mScheduledFuture.cancel(false);
mScheduledFuture = null;
}
}
}
}
public void onPause() {
if (mPlayer.isPlaying()) {
stopPlaybackAtPosition(mPlayer.getCurrentPosition(), mDuration.get());
}
if (mPrepareTask != null) {
mPrepareTask.cancel(false);
}
if (mWakeLock.isHeld()) {
mWakeLock.release();
}
}
}