| /* |
| * 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(); |
| } |
| } |
| } |