1/*
2 * Copyright (C) 2011 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.contacts.voicemail;
18
19import static android.util.MathUtils.constrain;
20
21import com.android.contacts.R;
22import com.android.contacts.util.AsyncTaskExecutor;
23import com.android.ex.variablespeed.MediaPlayerProxy;
24import com.android.ex.variablespeed.SingleThreadedMediaPlayerProxy;
25import com.google.common.annotations.VisibleForTesting;
26import com.google.common.base.Preconditions;
27
28import android.content.Context;
29import android.database.ContentObserver;
30import android.media.AudioManager;
31import android.media.MediaPlayer;
32import android.net.Uri;
33import android.os.AsyncTask;
34import android.os.Bundle;
35import android.os.Handler;
36import android.os.PowerManager;
37import android.view.View;
38import android.widget.SeekBar;
39
40import java.util.concurrent.ScheduledExecutorService;
41import java.util.concurrent.ScheduledFuture;
42import java.util.concurrent.TimeUnit;
43import java.util.concurrent.atomic.AtomicBoolean;
44import java.util.concurrent.atomic.AtomicInteger;
45
46import javax.annotation.concurrent.GuardedBy;
47import javax.annotation.concurrent.NotThreadSafe;
48import javax.annotation.concurrent.ThreadSafe;
49
50/**
51 * Contains the controlling logic for a voicemail playback ui.
52 * <p>
53 * Specifically right now this class is used to control the
54 * {@link com.android.contacts.voicemail.VoicemailPlaybackFragment}.
55 * <p>
56 * This class is not thread safe. The thread policy for this class is
57 * thread-confinement, all calls into this class from outside must be done from
58 * the main ui thread.
59 */
60@NotThreadSafe
61@VisibleForTesting
62public class VoicemailPlaybackPresenter {
63    /** The stream used to playback voicemail. */
64    private static final int PLAYBACK_STREAM = AudioManager.STREAM_VOICE_CALL;
65
66    /** Contract describing the behaviour we need from the ui we are controlling. */
67    public interface PlaybackView {
68        Context getDataSourceContext();
69        void runOnUiThread(Runnable runnable);
70        void setStartStopListener(View.OnClickListener listener);
71        void setPositionSeekListener(SeekBar.OnSeekBarChangeListener listener);
72        void setSpeakerphoneListener(View.OnClickListener listener);
73        void setIsBuffering();
74        void setClipPosition(int clipPositionInMillis, int clipLengthInMillis);
75        int getDesiredClipPosition();
76        void playbackStarted();
77        void playbackStopped();
78        void playbackError(Exception e);
79        boolean isSpeakerPhoneOn();
80        void setSpeakerPhoneOn(boolean on);
81        void finish();
82        void setRateDisplay(float rate, int stringResourceId);
83        void setRateIncreaseButtonListener(View.OnClickListener listener);
84        void setRateDecreaseButtonListener(View.OnClickListener listener);
85        void setIsFetchingContent();
86        void disableUiElements();
87        void enableUiElements();
88        void sendFetchVoicemailRequest(Uri voicemailUri);
89        boolean queryHasContent(Uri voicemailUri);
90        void setFetchContentTimeout();
91        void registerContentObserver(Uri uri, ContentObserver observer);
92        void unregisterContentObserver(ContentObserver observer);
93        void enableProximitySensor();
94        void disableProximitySensor();
95        void setVolumeControlStream(int streamType);
96    }
97
98    /** The enumeration of {@link AsyncTask} objects we use in this class. */
99    public enum Tasks {
100        CHECK_FOR_CONTENT,
101        CHECK_CONTENT_AFTER_CHANGE,
102        PREPARE_MEDIA_PLAYER,
103        RESET_PREPARE_START_MEDIA_PLAYER,
104    }
105
106    /** Update rate for the slider, 30fps. */
107    private static final int SLIDER_UPDATE_PERIOD_MILLIS = 1000 / 30;
108    /** Time our ui will wait for content to be fetched before reporting not available. */
109    private static final long FETCH_CONTENT_TIMEOUT_MS = 20000;
110    /**
111     * If present in the saved instance bundle, we should not resume playback on
112     * create.
113     */
114    private static final String PAUSED_STATE_KEY = VoicemailPlaybackPresenter.class.getName()
115            + ".PAUSED_STATE_KEY";
116    /**
117     * If present in the saved instance bundle, indicates where to set the
118     * playback slider.
119     */
120    private static final String CLIP_POSITION_KEY = VoicemailPlaybackPresenter.class.getName()
121            + ".CLIP_POSITION_KEY";
122
123    /** The preset variable-speed rates.  Each is greater than the previous by 25%. */
124    private static final float[] PRESET_RATES = new float[] {
125        0.64f, 0.8f, 1.0f, 1.25f, 1.5625f
126    };
127    /** The string resource ids corresponding to the names given to the above preset rates. */
128    private static final int[] PRESET_NAMES = new int[] {
129        R.string.voicemail_speed_slowest,
130        R.string.voicemail_speed_slower,
131        R.string.voicemail_speed_normal,
132        R.string.voicemail_speed_faster,
133        R.string.voicemail_speed_fastest,
134    };
135
136    /**
137     * Pointer into the {@link VoicemailPlaybackPresenter#PRESET_RATES} array.
138     * <p>
139     * This doesn't need to be synchronized, it's used only by the {@link RateChangeListener}
140     * which in turn is only executed on the ui thread.  This can't be encapsulated inside the
141     * rate change listener since multiple rate change listeners must share the same value.
142     */
143    private int mRateIndex = 2;
144
145    /**
146     * The most recently calculated duration.
147     * <p>
148     * We cache this in a field since we don't want to keep requesting it from the player, as
149     * this can easily lead to throwing {@link IllegalStateException} (any time the player is
150     * released, it's illegal to ask for the duration).
151     */
152    private final AtomicInteger mDuration = new AtomicInteger(0);
153
154    private final PlaybackView mView;
155    private final MediaPlayerProxy mPlayer;
156    private final PositionUpdater mPositionUpdater;
157
158    /** Voicemail uri to play. */
159    private final Uri mVoicemailUri;
160    /** Start playing in onCreate iff this is true. */
161    private final boolean mStartPlayingImmediately;
162    /** Used to run async tasks that need to interact with the ui. */
163    private final AsyncTaskExecutor mAsyncTaskExecutor;
164
165    /**
166     * Used to handle the result of a successful or time-out fetch result.
167     * <p>
168     * This variable is thread-contained, accessed only on the ui thread.
169     */
170    private FetchResultHandler mFetchResultHandler;
171    private PowerManager.WakeLock mWakeLock;
172    private AsyncTask<Void, ?, ?> mPrepareTask;
173
174    public VoicemailPlaybackPresenter(PlaybackView view, MediaPlayerProxy player,
175            Uri voicemailUri, ScheduledExecutorService executorService,
176            boolean startPlayingImmediately, AsyncTaskExecutor asyncTaskExecutor,
177            PowerManager.WakeLock wakeLock) {
178        mView = view;
179        mPlayer = player;
180        mVoicemailUri = voicemailUri;
181        mStartPlayingImmediately = startPlayingImmediately;
182        mAsyncTaskExecutor = asyncTaskExecutor;
183        mPositionUpdater = new PositionUpdater(executorService, SLIDER_UPDATE_PERIOD_MILLIS);
184        mWakeLock = wakeLock;
185    }
186
187    public void onCreate(Bundle bundle) {
188        mView.setVolumeControlStream(PLAYBACK_STREAM);
189        checkThatWeHaveContent();
190    }
191
192    /**
193     * Checks to see if we have content available for this voicemail.
194     * <p>
195     * This method will be called once, after the fragment has been created, before we know if the
196     * voicemail we've been asked to play has any content available.
197     * <p>
198     * This method will notify the user through the ui that we are fetching the content, then check
199     * to see if the content field in the db is set. If set, we proceed to
200     * {@link #postSuccessfullyFetchedContent()} method. If not set, we will make a request to fetch
201     * the content asynchronously via {@link #makeRequestForContent()}.
202     */
203    private void checkThatWeHaveContent() {
204        mView.setIsFetchingContent();
205        mAsyncTaskExecutor.submit(Tasks.CHECK_FOR_CONTENT, new AsyncTask<Void, Void, Boolean>() {
206            @Override
207            public Boolean doInBackground(Void... params) {
208                return mView.queryHasContent(mVoicemailUri);
209            }
210
211            @Override
212            public void onPostExecute(Boolean hasContent) {
213                if (hasContent) {
214                    postSuccessfullyFetchedContent();
215                } else {
216                    makeRequestForContent();
217                }
218            }
219        });
220    }
221
222    /**
223     * Makes a broadcast request to ask that a voicemail source fetch this content.
224     * <p>
225     * This method <b>must be called on the ui thread</b>.
226     * <p>
227     * This method will be called when we realise that we don't have content for this voicemail. It
228     * will trigger a broadcast to request that the content be downloaded. It will add a listener to
229     * the content resolver so that it will be notified when the has_content field changes. It will
230     * also set a timer. If the has_content field changes to true within the allowed time, we will
231     * proceed to {@link #postSuccessfullyFetchedContent()}. If the has_content field does not
232     * become true within the allowed time, we will update the ui to reflect the fact that content
233     * was not available.
234     */
235    private void makeRequestForContent() {
236        Handler handler = new Handler();
237        Preconditions.checkState(mFetchResultHandler == null, "mFetchResultHandler should be null");
238        mFetchResultHandler = new FetchResultHandler(handler);
239        mView.registerContentObserver(mVoicemailUri, mFetchResultHandler);
240        handler.postDelayed(mFetchResultHandler.getTimeoutRunnable(), FETCH_CONTENT_TIMEOUT_MS);
241        mView.sendFetchVoicemailRequest(mVoicemailUri);
242    }
243
244    @ThreadSafe
245    private class FetchResultHandler extends ContentObserver implements Runnable {
246        private AtomicBoolean mResultStillPending = new AtomicBoolean(true);
247        private final Handler mHandler;
248
249        public FetchResultHandler(Handler handler) {
250            super(handler);
251            mHandler = handler;
252        }
253
254        public Runnable getTimeoutRunnable() {
255            return this;
256        }
257
258        @Override
259        public void run() {
260            if (mResultStillPending.getAndSet(false)) {
261                mView.unregisterContentObserver(FetchResultHandler.this);
262                mView.setFetchContentTimeout();
263            }
264        }
265
266        public void destroy() {
267            if (mResultStillPending.getAndSet(false)) {
268                mView.unregisterContentObserver(FetchResultHandler.this);
269                mHandler.removeCallbacks(this);
270            }
271        }
272
273        @Override
274        public void onChange(boolean selfChange) {
275            mAsyncTaskExecutor.submit(Tasks.CHECK_CONTENT_AFTER_CHANGE,
276                    new AsyncTask<Void, Void, Boolean>() {
277                @Override
278                public Boolean doInBackground(Void... params) {
279                    return mView.queryHasContent(mVoicemailUri);
280                }
281
282                @Override
283                public void onPostExecute(Boolean hasContent) {
284                    if (hasContent) {
285                        if (mResultStillPending.getAndSet(false)) {
286                            mView.unregisterContentObserver(FetchResultHandler.this);
287                            postSuccessfullyFetchedContent();
288                        }
289                    }
290                }
291            });
292        }
293    }
294
295    /**
296     * Prepares the voicemail content for playback.
297     * <p>
298     * This method will be called once we know that our voicemail has content (according to the
299     * content provider). This method will try to prepare the data source through the media player.
300     * If preparing the media player works, we will call through to
301     * {@link #postSuccessfulPrepareActions()}. If preparing the media player fails (perhaps the
302     * file the content provider points to is actually missing, perhaps it is of an unknown file
303     * format that we can't play, who knows) then we will show an error on the ui.
304     */
305    private void postSuccessfullyFetchedContent() {
306        mView.setIsBuffering();
307        mAsyncTaskExecutor.submit(Tasks.PREPARE_MEDIA_PLAYER,
308                new AsyncTask<Void, Void, Exception>() {
309                    @Override
310                    public Exception doInBackground(Void... params) {
311                        try {
312                            mPlayer.reset();
313                            mPlayer.setDataSource(mView.getDataSourceContext(), mVoicemailUri);
314                            mPlayer.setAudioStreamType(PLAYBACK_STREAM);
315                            mPlayer.prepare();
316                            return null;
317                        } catch (Exception e) {
318                            return e;
319                        }
320                    }
321
322                    @Override
323                    public void onPostExecute(Exception exception) {
324                        if (exception == null) {
325                            postSuccessfulPrepareActions();
326                        } else {
327                            mView.playbackError(exception);
328                        }
329                    }
330                });
331    }
332
333    /**
334     * Enables the ui, and optionally starts playback immediately.
335     * <p>
336     * This will be called once we have successfully prepared the media player, and will optionally
337     * playback immediately.
338     */
339    private void postSuccessfulPrepareActions() {
340        mView.enableUiElements();
341        mView.setPositionSeekListener(new PlaybackPositionListener());
342        mView.setStartStopListener(new StartStopButtonListener());
343        mView.setSpeakerphoneListener(new SpeakerphoneListener());
344        mPlayer.setOnErrorListener(new MediaPlayerErrorListener());
345        mPlayer.setOnCompletionListener(new MediaPlayerCompletionListener());
346        mView.setSpeakerPhoneOn(mView.isSpeakerPhoneOn());
347        mView.setRateDecreaseButtonListener(createRateDecreaseListener());
348        mView.setRateIncreaseButtonListener(createRateIncreaseListener());
349        mView.setClipPosition(0, mPlayer.getDuration());
350        mView.playbackStopped();
351        // Always disable on stop.
352        mView.disableProximitySensor();
353        if (mStartPlayingImmediately) {
354            resetPrepareStartPlaying(0);
355        }
356        // TODO: Now I'm ignoring the bundle, when previously I was checking for contains against
357        // the PAUSED_STATE_KEY, and CLIP_POSITION_KEY.
358    }
359
360    public void onSaveInstanceState(Bundle outState) {
361        outState.putInt(CLIP_POSITION_KEY, mView.getDesiredClipPosition());
362        if (!mPlayer.isPlaying()) {
363            outState.putBoolean(PAUSED_STATE_KEY, true);
364        }
365    }
366
367    public void onDestroy() {
368        mPlayer.release();
369        if (mFetchResultHandler != null) {
370            mFetchResultHandler.destroy();
371            mFetchResultHandler = null;
372        }
373        mPositionUpdater.stopUpdating();
374        if (mWakeLock.isHeld()) {
375            mWakeLock.release();
376        }
377    }
378
379    private class MediaPlayerErrorListener implements MediaPlayer.OnErrorListener {
380        @Override
381        public boolean onError(MediaPlayer mp, int what, int extra) {
382            mView.runOnUiThread(new Runnable() {
383                @Override
384                public void run() {
385                    handleError(new IllegalStateException("MediaPlayer error listener invoked"));
386                }
387            });
388            return true;
389        }
390    }
391
392    private class MediaPlayerCompletionListener implements MediaPlayer.OnCompletionListener {
393        @Override
394        public void onCompletion(final MediaPlayer mp) {
395            mView.runOnUiThread(new Runnable() {
396                @Override
397                public void run() {
398                    handleCompletion(mp);
399                }
400            });
401        }
402    }
403
404    public View.OnClickListener createRateDecreaseListener() {
405        return new RateChangeListener(false);
406    }
407
408    public View.OnClickListener createRateIncreaseListener() {
409        return new RateChangeListener(true);
410    }
411
412    /**
413     * Listens to clicks on the rate increase and decrease buttons.
414     * <p>
415     * This class is not thread-safe, but all interactions with it will happen on the ui thread.
416     */
417    private class RateChangeListener implements View.OnClickListener {
418        private final boolean mIncrease;
419
420        public RateChangeListener(boolean increase) {
421            mIncrease = increase;
422        }
423
424        @Override
425        public void onClick(View v) {
426            // Adjust the current rate, then clamp it to the allowed values.
427            mRateIndex = constrain(mRateIndex + (mIncrease ? 1 : -1), 0, PRESET_RATES.length - 1);
428            // Whether or not we have actually changed the index, call changeRate().
429            // This will ensure that we show the "fastest" or "slowest" text on the ui to indicate
430            // to the user that it doesn't get any faster or slower.
431            changeRate(PRESET_RATES[mRateIndex], PRESET_NAMES[mRateIndex]);
432        }
433    }
434
435    private void resetPrepareStartPlaying(final int clipPositionInMillis) {
436        if (mPrepareTask != null) {
437            mPrepareTask.cancel(false);
438        }
439        mPrepareTask = mAsyncTaskExecutor.submit(Tasks.RESET_PREPARE_START_MEDIA_PLAYER,
440                new AsyncTask<Void, Void, Exception>() {
441                    @Override
442                    public Exception doInBackground(Void... params) {
443                        try {
444                            mPlayer.reset();
445                            mPlayer.setDataSource(mView.getDataSourceContext(), mVoicemailUri);
446                            mPlayer.setAudioStreamType(PLAYBACK_STREAM);
447                            mPlayer.prepare();
448                            return null;
449                        } catch (Exception e) {
450                            return e;
451                        }
452                    }
453
454                    @Override
455                    public void onPostExecute(Exception exception) {
456                        mPrepareTask = null;
457                        if (exception == null) {
458                            mDuration.set(mPlayer.getDuration());
459                            int startPosition =
460                                    constrain(clipPositionInMillis, 0, mDuration.get());
461                            mView.setClipPosition(startPosition, mDuration.get());
462                            mPlayer.seekTo(startPosition);
463                            mPlayer.start();
464                            mView.playbackStarted();
465                            if (!mWakeLock.isHeld()) {
466                                mWakeLock.acquire();
467                            }
468                            // Only enable if we are not currently using the speaker phone.
469                            if (!mView.isSpeakerPhoneOn()) {
470                                mView.enableProximitySensor();
471                            }
472                            mPositionUpdater.startUpdating(startPosition, mDuration.get());
473                        } else {
474                            handleError(exception);
475                        }
476                    }
477                });
478    }
479
480    private void handleError(Exception e) {
481        mView.playbackError(e);
482        mPositionUpdater.stopUpdating();
483        mPlayer.release();
484    }
485
486    public void handleCompletion(MediaPlayer mediaPlayer) {
487        stopPlaybackAtPosition(0, mDuration.get());
488    }
489
490    private void stopPlaybackAtPosition(int clipPosition, int duration) {
491        mPositionUpdater.stopUpdating();
492        mView.playbackStopped();
493        if (mWakeLock.isHeld()) {
494            mWakeLock.release();
495        }
496        // Always disable on stop.
497        mView.disableProximitySensor();
498        mView.setClipPosition(clipPosition, duration);
499        if (mPlayer.isPlaying()) {
500            mPlayer.pause();
501        }
502    }
503
504    private class PlaybackPositionListener implements SeekBar.OnSeekBarChangeListener {
505        private boolean mShouldResumePlaybackAfterSeeking;
506
507        @Override
508        public void onStartTrackingTouch(SeekBar arg0) {
509            if (mPlayer.isPlaying()) {
510                mShouldResumePlaybackAfterSeeking = true;
511                stopPlaybackAtPosition(mPlayer.getCurrentPosition(), mDuration.get());
512            } else {
513                mShouldResumePlaybackAfterSeeking = false;
514            }
515        }
516
517        @Override
518        public void onStopTrackingTouch(SeekBar arg0) {
519            if (mPlayer.isPlaying()) {
520                stopPlaybackAtPosition(mPlayer.getCurrentPosition(), mDuration.get());
521            }
522            if (mShouldResumePlaybackAfterSeeking) {
523                resetPrepareStartPlaying(mView.getDesiredClipPosition());
524            }
525        }
526
527        @Override
528        public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
529            mView.setClipPosition(seekBar.getProgress(), seekBar.getMax());
530        }
531    }
532
533    private void changeRate(float rate, int stringResourceId) {
534        ((SingleThreadedMediaPlayerProxy) mPlayer).setVariableSpeed(rate);
535        mView.setRateDisplay(rate, stringResourceId);
536    }
537
538    private class SpeakerphoneListener implements View.OnClickListener {
539        @Override
540        public void onClick(View v) {
541            boolean previousState = mView.isSpeakerPhoneOn();
542            mView.setSpeakerPhoneOn(!previousState);
543            if (mPlayer.isPlaying() && previousState) {
544                // If we are currently playing and we are disabling the speaker phone, enable the
545                // sensor.
546                mView.enableProximitySensor();
547            } else {
548                // If we are not currently playing, disable the sensor.
549                mView.disableProximitySensor();
550            }
551        }
552    }
553
554    private class StartStopButtonListener implements View.OnClickListener {
555        @Override
556        public void onClick(View arg0) {
557            if (mPlayer.isPlaying()) {
558                stopPlaybackAtPosition(mPlayer.getCurrentPosition(), mDuration.get());
559            } else {
560                resetPrepareStartPlaying(mView.getDesiredClipPosition());
561            }
562        }
563    }
564
565    /**
566     * Controls the animation of the playback slider.
567     */
568    @ThreadSafe
569    private final class PositionUpdater implements Runnable {
570        private final ScheduledExecutorService mExecutorService;
571        private final int mPeriodMillis;
572        private final Object mLock = new Object();
573        @GuardedBy("mLock") private ScheduledFuture<?> mScheduledFuture;
574        private final Runnable mSetClipPostitionRunnable = new Runnable() {
575            @Override
576            public void run() {
577                int currentPosition = 0;
578                synchronized (mLock) {
579                    if (mScheduledFuture == null) {
580                        // This task has been canceled. Just stop now.
581                        return;
582                    }
583                    currentPosition = mPlayer.getCurrentPosition();
584                }
585                mView.setClipPosition(currentPosition, mDuration.get());
586            }
587        };
588
589        public PositionUpdater(ScheduledExecutorService executorService, int periodMillis) {
590            mExecutorService = executorService;
591            mPeriodMillis = periodMillis;
592        }
593
594        @Override
595        public void run() {
596            mView.runOnUiThread(mSetClipPostitionRunnable);
597        }
598
599        public void startUpdating(int beginPosition, int endPosition) {
600            synchronized (mLock) {
601                if (mScheduledFuture != null) {
602                    mScheduledFuture.cancel(false);
603                }
604                mScheduledFuture = mExecutorService.scheduleAtFixedRate(this, 0, mPeriodMillis,
605                        TimeUnit.MILLISECONDS);
606            }
607        }
608
609        public void stopUpdating() {
610            synchronized (mLock) {
611                if (mScheduledFuture != null) {
612                    mScheduledFuture.cancel(false);
613                    mScheduledFuture = null;
614                }
615            }
616        }
617    }
618
619    public void onPause() {
620        if (mPlayer.isPlaying()) {
621            stopPlaybackAtPosition(mPlayer.getCurrentPosition(), mDuration.get());
622        }
623        if (mPrepareTask != null) {
624            mPrepareTask.cancel(false);
625        }
626        if (mWakeLock.isHeld()) {
627            mWakeLock.release();
628        }
629    }
630}
631