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