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 com.android.contacts.CallDetailActivity.EXTRA_VOICEMAIL_START_PLAYBACK;
20import static com.android.contacts.CallDetailActivity.EXTRA_VOICEMAIL_URI;
21
22import com.android.common.io.MoreCloseables;
23import com.android.contacts.ProximitySensorAware;
24import com.android.contacts.R;
25import com.android.contacts.util.AsyncTaskExecutors;
26import com.android.ex.variablespeed.MediaPlayerProxy;
27import com.android.ex.variablespeed.VariableSpeed;
28import com.google.common.base.Preconditions;
29
30import android.app.Activity;
31import android.app.Fragment;
32import android.content.ContentResolver;
33import android.content.Context;
34import android.content.Intent;
35import android.database.ContentObserver;
36import android.database.Cursor;
37import android.media.AudioManager;
38import android.net.Uri;
39import android.os.Bundle;
40import android.os.PowerManager;
41import android.provider.VoicemailContract;
42import android.util.Log;
43import android.view.LayoutInflater;
44import android.view.View;
45import android.view.ViewGroup;
46import android.widget.ImageButton;
47import android.widget.SeekBar;
48import android.widget.TextView;
49
50import java.util.concurrent.ExecutorService;
51import java.util.concurrent.Executors;
52import java.util.concurrent.ScheduledExecutorService;
53import java.util.concurrent.TimeUnit;
54
55import javax.annotation.concurrent.GuardedBy;
56import javax.annotation.concurrent.NotThreadSafe;
57
58/**
59 * Displays and plays back a single voicemail.
60 * <p>
61 * When the Activity containing this Fragment is created, voicemail playback
62 * will begin immediately. The Activity is expected to be started via an intent
63 * containing a suitable voicemail uri to playback.
64 * <p>
65 * This class is not thread-safe, it is thread-confined. All calls to all public
66 * methods on this class are expected to come from the main ui thread.
67 */
68@NotThreadSafe
69public class VoicemailPlaybackFragment extends Fragment {
70    private static final String TAG = "VoicemailPlayback";
71    private static final int NUMBER_OF_THREADS_IN_POOL = 2;
72    private static final String[] HAS_CONTENT_PROJECTION = new String[] {
73        VoicemailContract.Voicemails.HAS_CONTENT,
74    };
75
76    private VoicemailPlaybackPresenter mPresenter;
77    private ScheduledExecutorService mScheduledExecutorService;
78    private View mPlaybackLayout;
79
80    @Override
81    public View onCreateView(LayoutInflater inflater, ViewGroup container,
82            Bundle savedInstanceState) {
83        mPlaybackLayout = inflater.inflate(R.layout.playback_layout, null);
84        return mPlaybackLayout;
85    }
86
87    @Override
88    public void onActivityCreated(Bundle savedInstanceState) {
89        super.onActivityCreated(savedInstanceState);
90        mScheduledExecutorService = createScheduledExecutorService();
91        Bundle arguments = getArguments();
92        Preconditions.checkNotNull(arguments, "fragment must be started with arguments");
93        Uri voicemailUri = arguments.getParcelable(EXTRA_VOICEMAIL_URI);
94        Preconditions.checkNotNull(voicemailUri, "fragment must contain EXTRA_VOICEMAIL_URI");
95        boolean startPlayback = arguments.getBoolean(EXTRA_VOICEMAIL_START_PLAYBACK, false);
96        PowerManager powerManager =
97                (PowerManager) getActivity().getSystemService(Context.POWER_SERVICE);
98        PowerManager.WakeLock wakeLock =
99                powerManager.newWakeLock(
100                        PowerManager.SCREEN_DIM_WAKE_LOCK, getClass().getSimpleName());
101        mPresenter = new VoicemailPlaybackPresenter(createPlaybackViewImpl(),
102                createMediaPlayer(mScheduledExecutorService), voicemailUri,
103                mScheduledExecutorService, startPlayback,
104                AsyncTaskExecutors.createAsyncTaskExecutor(), wakeLock);
105        mPresenter.onCreate(savedInstanceState);
106    }
107
108    @Override
109    public void onSaveInstanceState(Bundle outState) {
110        mPresenter.onSaveInstanceState(outState);
111        super.onSaveInstanceState(outState);
112    }
113
114    @Override
115    public void onDestroy() {
116        mPresenter.onDestroy();
117        mScheduledExecutorService.shutdown();
118        super.onDestroy();
119    }
120
121    @Override
122    public void onPause() {
123        mPresenter.onPause();
124        super.onPause();
125    }
126
127    private PlaybackViewImpl createPlaybackViewImpl() {
128        return new PlaybackViewImpl(new ActivityReference(), getActivity().getApplicationContext(),
129                mPlaybackLayout);
130    }
131
132    private MediaPlayerProxy createMediaPlayer(ExecutorService executorService) {
133        return VariableSpeed.createVariableSpeed(executorService);
134    }
135
136    private ScheduledExecutorService createScheduledExecutorService() {
137        return Executors.newScheduledThreadPool(NUMBER_OF_THREADS_IN_POOL);
138    }
139
140    /**
141     * Formats a number of milliseconds as something that looks like {@code 00:05}.
142     * <p>
143     * We always use four digits, two for minutes two for seconds.  In the very unlikely event
144     * that the voicemail duration exceeds 99 minutes, the display is capped at 99 minutes.
145     */
146    private static String formatAsMinutesAndSeconds(int millis) {
147        int seconds = millis / 1000;
148        int minutes = seconds / 60;
149        seconds -= minutes * 60;
150        if (minutes > 99) {
151            minutes = 99;
152        }
153        return String.format("%02d:%02d", minutes, seconds);
154    }
155
156    /**
157     * An object that can provide us with an Activity.
158     * <p>
159     * Fragments suffer the drawback that the Activity they belong to may sometimes be null. This
160     * can happen if the Fragment is detached, for example. In that situation a call to
161     * {@link Fragment#getString(int)} will throw and {@link IllegalStateException}. Also, calling
162     * {@link Fragment#getActivity()} is dangerous - it may sometimes return null. And thus blindly
163     * calling a method on the result of getActivity() is dangerous too.
164     * <p>
165     * To work around this, I have made the {@link PlaybackViewImpl} class static, so that it does
166     * not have access to any Fragment methods directly. Instead it uses an application Context for
167     * things like accessing strings, accessing system services. It only uses the Activity when it
168     * absolutely needs it - and does so through this class. This makes it easy to see where we have
169     * to check for null properly.
170     */
171    private final class ActivityReference {
172        /** Gets this Fragment's Activity: <b>may be null</b>. */
173        public final Activity get() {
174            return getActivity();
175        }
176    }
177
178    /**  Methods required by the PlaybackView for the VoicemailPlaybackPresenter. */
179    private static final class PlaybackViewImpl implements VoicemailPlaybackPresenter.PlaybackView {
180        private final ActivityReference mActivityReference;
181        private final Context mApplicationContext;
182        private final SeekBar mPlaybackSeek;
183        private final ImageButton mStartStopButton;
184        private final ImageButton mPlaybackSpeakerphone;
185        private final ImageButton mRateDecreaseButton;
186        private final ImageButton mRateIncreaseButton;
187        private final TextViewWithMessagesController mTextController;
188
189        public PlaybackViewImpl(ActivityReference activityReference, Context applicationContext,
190                View playbackLayout) {
191            Preconditions.checkNotNull(activityReference);
192            Preconditions.checkNotNull(applicationContext);
193            Preconditions.checkNotNull(playbackLayout);
194            mActivityReference = activityReference;
195            mApplicationContext = applicationContext;
196            mPlaybackSeek = (SeekBar) playbackLayout.findViewById(R.id.playback_seek);
197            mStartStopButton = (ImageButton) playbackLayout.findViewById(
198                    R.id.playback_start_stop);
199            mPlaybackSpeakerphone = (ImageButton) playbackLayout.findViewById(
200                    R.id.playback_speakerphone);
201            mRateDecreaseButton = (ImageButton) playbackLayout.findViewById(
202                    R.id.rate_decrease_button);
203            mRateIncreaseButton = (ImageButton) playbackLayout.findViewById(
204                    R.id.rate_increase_button);
205            mTextController = new TextViewWithMessagesController(
206                    (TextView) playbackLayout.findViewById(R.id.playback_position_text),
207                    (TextView) playbackLayout.findViewById(R.id.playback_speed_text));
208        }
209
210        @Override
211        public void finish() {
212            Activity activity = mActivityReference.get();
213            if (activity != null) {
214                activity.finish();
215            }
216        }
217
218        @Override
219        public void runOnUiThread(Runnable runnable) {
220            Activity activity = mActivityReference.get();
221            if (activity != null) {
222                activity.runOnUiThread(runnable);
223            }
224        }
225
226        @Override
227        public Context getDataSourceContext() {
228            return mApplicationContext;
229        }
230
231        @Override
232        public void setRateDecreaseButtonListener(View.OnClickListener listener) {
233            mRateDecreaseButton.setOnClickListener(listener);
234        }
235
236        @Override
237        public void setRateIncreaseButtonListener(View.OnClickListener listener) {
238            mRateIncreaseButton.setOnClickListener(listener);
239        }
240
241        @Override
242        public void setStartStopListener(View.OnClickListener listener) {
243            mStartStopButton.setOnClickListener(listener);
244        }
245
246        @Override
247        public void setSpeakerphoneListener(View.OnClickListener listener) {
248            mPlaybackSpeakerphone.setOnClickListener(listener);
249        }
250
251        @Override
252        public void setRateDisplay(float rate, int stringResourceId) {
253            mTextController.setTemporaryText(
254                    mApplicationContext.getString(stringResourceId), 1, TimeUnit.SECONDS);
255        }
256
257        @Override
258        public void setPositionSeekListener(SeekBar.OnSeekBarChangeListener listener) {
259            mPlaybackSeek.setOnSeekBarChangeListener(listener);
260        }
261
262        @Override
263        public void playbackStarted() {
264            mStartStopButton.setImageResource(R.drawable.ic_hold_pause_holo_dark);
265        }
266
267        @Override
268        public void playbackStopped() {
269            mStartStopButton.setImageResource(R.drawable.ic_play);
270        }
271
272        @Override
273        public void enableProximitySensor() {
274            // Only change the state if the activity is still around.
275            Activity activity = mActivityReference.get();
276            if (activity != null && activity instanceof ProximitySensorAware) {
277                ((ProximitySensorAware) activity).enableProximitySensor();
278            }
279        }
280
281        @Override
282        public void disableProximitySensor() {
283            // Only change the state if the activity is still around.
284            Activity activity = mActivityReference.get();
285            if (activity != null && activity instanceof ProximitySensorAware) {
286                ((ProximitySensorAware) activity).disableProximitySensor(true);
287            }
288        }
289
290        @Override
291        public void registerContentObserver(Uri uri, ContentObserver observer) {
292            mApplicationContext.getContentResolver().registerContentObserver(uri, false, observer);
293        }
294
295        @Override
296        public void unregisterContentObserver(ContentObserver observer) {
297            mApplicationContext.getContentResolver().unregisterContentObserver(observer);
298        }
299
300        @Override
301        public void setClipPosition(int clipPositionInMillis, int clipLengthInMillis) {
302            int seekBarPosition = Math.max(0, clipPositionInMillis);
303            int seekBarMax = Math.max(seekBarPosition, clipLengthInMillis);
304            if (mPlaybackSeek.getMax() != seekBarMax) {
305                mPlaybackSeek.setMax(seekBarMax);
306            }
307            mPlaybackSeek.setProgress(seekBarPosition);
308            mTextController.setPermanentText(
309                    formatAsMinutesAndSeconds(seekBarMax - seekBarPosition));
310        }
311
312        private String getString(int resId) {
313            return mApplicationContext.getString(resId);
314        }
315
316        @Override
317        public void setIsBuffering() {
318            disableUiElements();
319            mTextController.setPermanentText(getString(R.string.voicemail_buffering));
320        }
321
322        @Override
323        public void setIsFetchingContent() {
324            disableUiElements();
325            mTextController.setPermanentText(getString(R.string.voicemail_fetching_content));
326        }
327
328        @Override
329        public void setFetchContentTimeout() {
330            disableUiElements();
331            mTextController.setPermanentText(getString(R.string.voicemail_fetching_timout));
332        }
333
334        @Override
335        public int getDesiredClipPosition() {
336            return mPlaybackSeek.getProgress();
337        }
338
339        @Override
340        public void disableUiElements() {
341            mRateIncreaseButton.setEnabled(false);
342            mRateDecreaseButton.setEnabled(false);
343            mStartStopButton.setEnabled(false);
344            mPlaybackSpeakerphone.setEnabled(false);
345            mPlaybackSeek.setProgress(0);
346            mPlaybackSeek.setEnabled(false);
347        }
348
349        @Override
350        public void playbackError(Exception e) {
351            disableUiElements();
352            mTextController.setPermanentText(getString(R.string.voicemail_playback_error));
353            Log.e(TAG, "Could not play voicemail", e);
354        }
355
356        @Override
357        public void enableUiElements() {
358            mRateIncreaseButton.setEnabled(true);
359            mRateDecreaseButton.setEnabled(true);
360            mStartStopButton.setEnabled(true);
361            mPlaybackSpeakerphone.setEnabled(true);
362            mPlaybackSeek.setEnabled(true);
363        }
364
365        @Override
366        public void sendFetchVoicemailRequest(Uri voicemailUri) {
367            Intent intent = new Intent(VoicemailContract.ACTION_FETCH_VOICEMAIL, voicemailUri);
368            mApplicationContext.sendBroadcast(intent);
369        }
370
371        @Override
372        public boolean queryHasContent(Uri voicemailUri) {
373            ContentResolver contentResolver = mApplicationContext.getContentResolver();
374            Cursor cursor = contentResolver.query(
375                    voicemailUri, HAS_CONTENT_PROJECTION, null, null, null);
376            try {
377                if (cursor != null && cursor.moveToNext()) {
378                    return cursor.getInt(cursor.getColumnIndexOrThrow(
379                            VoicemailContract.Voicemails.HAS_CONTENT)) == 1;
380                }
381            } finally {
382                MoreCloseables.closeQuietly(cursor);
383            }
384            return false;
385        }
386
387        private AudioManager getAudioManager() {
388            return (AudioManager) mApplicationContext.getSystemService(Context.AUDIO_SERVICE);
389        }
390
391        @Override
392        public boolean isSpeakerPhoneOn() {
393            return getAudioManager().isSpeakerphoneOn();
394        }
395
396        @Override
397        public void setSpeakerPhoneOn(boolean on) {
398            getAudioManager().setSpeakerphoneOn(on);
399            if (on) {
400                mPlaybackSpeakerphone.setImageResource(R.drawable.ic_speakerphone_on);
401            } else {
402                mPlaybackSpeakerphone.setImageResource(R.drawable.ic_speakerphone_off);
403            }
404        }
405
406        @Override
407        public void setVolumeControlStream(int streamType) {
408            Activity activity = mActivityReference.get();
409            if (activity != null) {
410                activity.setVolumeControlStream(streamType);
411            }
412        }
413    }
414
415    /**
416     * Controls a TextView with dynamically changing text.
417     * <p>
418     * There are two methods here of interest,
419     * {@link TextViewWithMessagesController#setPermanentText(String)} and
420     * {@link TextViewWithMessagesController#setTemporaryText(String, long, TimeUnit)}.  The
421     * former is used to set the text on the text view immediately, and is used in our case for
422     * the countdown of duration remaining during voicemail playback.  The second is used to
423     * temporarily replace this countdown with a message, in our case faster voicemail speed or
424     * slower voicemail speed, before returning to the countdown display.
425     * <p>
426     * All the methods on this class must be called from the ui thread.
427     */
428    private static final class TextViewWithMessagesController {
429        private static final float VISIBLE = 1;
430        private static final float INVISIBLE = 0;
431        private static final long SHORT_ANIMATION_MS = 200;
432        private static final long LONG_ANIMATION_MS = 400;
433        private final Object mLock = new Object();
434        private final TextView mPermanentTextView;
435        private final TextView mTemporaryTextView;
436        @GuardedBy("mLock") private Runnable mRunnable;
437
438        public TextViewWithMessagesController(TextView permanentTextView,
439                TextView temporaryTextView) {
440            mPermanentTextView = permanentTextView;
441            mTemporaryTextView = temporaryTextView;
442        }
443
444        public void setPermanentText(String text) {
445            mPermanentTextView.setText(text);
446        }
447
448        public void setTemporaryText(String text, long duration, TimeUnit units) {
449            synchronized (mLock) {
450                mTemporaryTextView.setText(text);
451                mTemporaryTextView.animate().alpha(VISIBLE).setDuration(SHORT_ANIMATION_MS);
452                mPermanentTextView.animate().alpha(INVISIBLE).setDuration(SHORT_ANIMATION_MS);
453                mRunnable = new Runnable() {
454                    @Override
455                    public void run() {
456                        synchronized (mLock) {
457                            // We check for (mRunnable == this) becuase if not true, then another
458                            // setTemporaryText call has taken place in the meantime, and this
459                            // one is now defunct and needs to take no action.
460                            if (mRunnable == this) {
461                                mRunnable = null;
462                                mTemporaryTextView.animate()
463                                        .alpha(INVISIBLE).setDuration(LONG_ANIMATION_MS);
464                                mPermanentTextView.animate()
465                                        .alpha(VISIBLE).setDuration(LONG_ANIMATION_MS);
466                            }
467                        }
468                    }
469                };
470                mTemporaryTextView.postDelayed(mRunnable, units.toMillis(duration));
471            }
472        }
473    }
474}
475