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.ContentUris;
20import android.content.Context;
21import android.content.Intent;
22import android.database.Cursor;
23import android.graphics.drawable.Drawable;
24import android.net.Uri;
25import android.os.AsyncTask;
26import android.os.Handler;
27import android.util.AttributeSet;
28import android.support.design.widget.Snackbar;
29import android.view.LayoutInflater;
30import android.view.View;
31import android.widget.ImageButton;
32import android.widget.LinearLayout;
33import android.widget.SeekBar;
34import android.widget.SeekBar.OnSeekBarChangeListener;
35import android.widget.Space;
36import android.widget.TextView;
37import android.widget.Toast;
38
39import com.android.common.io.MoreCloseables;
40import com.android.dialer.PhoneCallDetails;
41import com.android.dialer.R;
42import com.android.dialer.calllog.CallLogAsyncTaskUtil;
43
44import com.android.dialer.database.VoicemailArchiveContract;
45import com.android.dialer.database.VoicemailArchiveContract.VoicemailArchive;
46import com.android.dialer.util.AsyncTaskExecutor;
47import com.android.dialer.util.AsyncTaskExecutors;
48import com.android.dialerbind.ObjectFactory;
49import com.google.common.annotations.VisibleForTesting;
50
51import java.util.ArrayList;
52import java.util.HashMap;
53import java.util.Objects;
54import java.util.concurrent.TimeUnit;
55import java.util.concurrent.ScheduledFuture;
56import java.util.concurrent.ScheduledExecutorService;
57
58import javax.annotation.Nullable;
59import javax.annotation.concurrent.GuardedBy;
60import javax.annotation.concurrent.NotThreadSafe;
61import javax.annotation.concurrent.ThreadSafe;
62
63/**
64 * Displays and plays a single voicemail. See {@link VoicemailPlaybackPresenter} for
65 * details on the voicemail playback implementation.
66 *
67 * This class is not thread-safe, it is thread-confined. All calls to all public
68 * methods on this class are expected to come from the main ui thread.
69 */
70@NotThreadSafe
71public class VoicemailPlaybackLayout extends LinearLayout
72        implements VoicemailPlaybackPresenter.PlaybackView,
73        CallLogAsyncTaskUtil.CallLogAsyncTaskListener {
74    private static final String TAG = VoicemailPlaybackLayout.class.getSimpleName();
75    private static final int VOICEMAIL_DELETE_DELAY_MS = 3000;
76    private static final int VOICEMAIL_ARCHIVE_DELAY_MS = 3000;
77
78    /** The enumeration of {@link AsyncTask} objects we use in this class. */
79    public enum Tasks {
80        QUERY_ARCHIVED_STATUS
81    }
82
83    /**
84     * Controls the animation of the playback slider.
85     */
86    @ThreadSafe
87    private final class PositionUpdater implements Runnable {
88
89        /** Update rate for the slider, 30fps. */
90        private static final int SLIDER_UPDATE_PERIOD_MILLIS = 1000 / 30;
91
92        private int mDurationMs;
93        private final ScheduledExecutorService mExecutorService;
94        private final Object mLock = new Object();
95        @GuardedBy("mLock") private ScheduledFuture<?> mScheduledFuture;
96
97        private Runnable mUpdateClipPositionRunnable = new Runnable() {
98            @Override
99            public void run() {
100                int currentPositionMs = 0;
101                synchronized (mLock) {
102                    if (mScheduledFuture == null || mPresenter == null) {
103                        // This task has been canceled. Just stop now.
104                        return;
105                    }
106                    currentPositionMs = mPresenter.getMediaPlayerPosition();
107                }
108                setClipPosition(currentPositionMs, mDurationMs);
109            }
110        };
111
112        public PositionUpdater(int durationMs, ScheduledExecutorService executorService) {
113            mDurationMs = durationMs;
114            mExecutorService = executorService;
115        }
116
117        @Override
118        public void run() {
119            post(mUpdateClipPositionRunnable);
120        }
121
122        public void startUpdating() {
123            synchronized (mLock) {
124                cancelPendingRunnables();
125                mScheduledFuture = mExecutorService.scheduleAtFixedRate(
126                        this, 0, SLIDER_UPDATE_PERIOD_MILLIS, TimeUnit.MILLISECONDS);
127            }
128        }
129
130        public void stopUpdating() {
131            synchronized (mLock) {
132                cancelPendingRunnables();
133            }
134        }
135
136        private void cancelPendingRunnables() {
137            if (mScheduledFuture != null) {
138                mScheduledFuture.cancel(true);
139                mScheduledFuture = null;
140            }
141            removeCallbacks(mUpdateClipPositionRunnable);
142        }
143    }
144
145    /**
146     * Handle state changes when the user manipulates the seek bar.
147     */
148    private final OnSeekBarChangeListener mSeekBarChangeListener = new OnSeekBarChangeListener() {
149        @Override
150        public void onStartTrackingTouch(SeekBar seekBar) {
151            if (mPresenter != null) {
152                mPresenter.pausePlaybackForSeeking();
153            }
154        }
155
156        @Override
157        public void onStopTrackingTouch(SeekBar seekBar) {
158            if (mPresenter != null) {
159                mPresenter.resumePlaybackAfterSeeking(seekBar.getProgress());
160            }
161        }
162
163        @Override
164        public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
165            setClipPosition(progress, seekBar.getMax());
166            // Update the seek position if user manually changed it. This makes sure position gets
167            // updated when user use volume button to seek playback in talkback mode.
168            if (fromUser) {
169                mPresenter.seek(progress);
170            }
171        }
172    };
173
174    /**
175     * Click listener to toggle speakerphone.
176     */
177    private final View.OnClickListener mSpeakerphoneListener = new View.OnClickListener() {
178        @Override
179        public void onClick(View v) {
180            if (mPresenter != null) {
181                mPresenter.toggleSpeakerphone();
182            }
183        }
184    };
185
186    /**
187     * Click listener to play or pause voicemail playback.
188     */
189    private final View.OnClickListener mStartStopButtonListener = new View.OnClickListener() {
190        @Override
191        public void onClick(View view) {
192            if (mPresenter == null) {
193                return;
194            }
195
196            if (mIsPlaying) {
197                mPresenter.pausePlayback();
198            } else {
199                mPresenter.resumePlayback();
200            }
201        }
202    };
203
204    private final View.OnClickListener mDeleteButtonListener = new View.OnClickListener() {
205        @Override
206        public void onClick(View view ) {
207            if (mPresenter == null) {
208                return;
209            }
210            mPresenter.pausePlayback();
211            mPresenter.onVoicemailDeleted();
212
213            final Uri deleteUri = mVoicemailUri;
214            final Runnable deleteCallback = new Runnable() {
215                @Override
216                public void run() {
217                    if (Objects.equals(deleteUri, mVoicemailUri)) {
218                        CallLogAsyncTaskUtil.deleteVoicemail(mContext, deleteUri,
219                                VoicemailPlaybackLayout.this);
220                    }
221                }
222            };
223
224            final Handler handler = new Handler();
225            // Add a little buffer time in case the user clicked "undo" at the end of the delay
226            // window.
227            handler.postDelayed(deleteCallback, VOICEMAIL_DELETE_DELAY_MS + 50);
228
229            Snackbar.make(VoicemailPlaybackLayout.this, R.string.snackbar_voicemail_deleted,
230                            Snackbar.LENGTH_LONG)
231                    .setDuration(VOICEMAIL_DELETE_DELAY_MS)
232                    .setAction(R.string.snackbar_voicemail_deleted_undo,
233                            new View.OnClickListener() {
234                                @Override
235                                public void onClick(View view) {
236                                    mPresenter.onVoicemailDeleteUndo();
237                                        handler.removeCallbacks(deleteCallback);
238                                }
239                            })
240                    .setActionTextColor(
241                            mContext.getResources().getColor(
242                                    R.color.dialer_snackbar_action_text_color))
243                    .show();
244        }
245    };
246
247    private final View.OnClickListener mArchiveButtonListener = new View.OnClickListener() {
248        @Override
249        public void onClick(View v) {
250            if (mPresenter == null || isArchiving(mVoicemailUri)) {
251                return;
252            }
253            mIsArchiving.add(mVoicemailUri);
254            mPresenter.pausePlayback();
255            updateArchiveUI(mVoicemailUri);
256            disableUiElements();
257            mPresenter.archiveContent(mVoicemailUri, true);
258        }
259    };
260
261    private final View.OnClickListener mShareButtonListener = new View.OnClickListener() {
262        @Override
263        public void onClick(View v) {
264            if (mPresenter == null || isArchiving(mVoicemailUri)) {
265                return;
266            }
267            disableUiElements();
268            mPresenter.archiveContent(mVoicemailUri, false);
269        }
270    };
271
272    private Context mContext;
273    private VoicemailPlaybackPresenter mPresenter;
274    private Uri mVoicemailUri;
275    private final AsyncTaskExecutor mAsyncTaskExecutor =
276            AsyncTaskExecutors.createAsyncTaskExecutor();
277    private boolean mIsPlaying = false;
278    /**
279     * Keeps track of which voicemails are currently being archived in order to update the voicemail
280     * card UI every time a user opens a new card.
281     */
282    private static final ArrayList<Uri> mIsArchiving = new ArrayList<>();
283
284    private SeekBar mPlaybackSeek;
285    private ImageButton mStartStopButton;
286    private ImageButton mPlaybackSpeakerphone;
287    private ImageButton mDeleteButton;
288    private ImageButton mArchiveButton;
289    private ImageButton mShareButton;
290
291    private Space mArchiveSpace;
292    private Space mShareSpace;
293
294    private TextView mStateText;
295    private TextView mPositionText;
296    private TextView mTotalDurationText;
297
298    private PositionUpdater mPositionUpdater;
299    private Drawable mVoicemailSeekHandleEnabled;
300    private Drawable mVoicemailSeekHandleDisabled;
301
302    public VoicemailPlaybackLayout(Context context) {
303        this(context, null);
304    }
305
306    public VoicemailPlaybackLayout(Context context, AttributeSet attrs) {
307        super(context, attrs);
308        mContext = context;
309        LayoutInflater inflater =
310                (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
311        inflater.inflate(R.layout.voicemail_playback_layout, this);
312    }
313
314    @Override
315    public void setPresenter(VoicemailPlaybackPresenter presenter, Uri voicemailUri) {
316        mPresenter = presenter;
317        mVoicemailUri = voicemailUri;
318        if (ObjectFactory.isVoicemailArchiveEnabled(mContext)) {
319            updateArchiveUI(mVoicemailUri);
320            updateArchiveButton(mVoicemailUri);
321        }
322
323        if (ObjectFactory.isVoicemailShareEnabled(mContext)) {
324            // Show share button and space before it
325            mShareSpace.setVisibility(View.VISIBLE);
326            mShareButton.setVisibility(View.VISIBLE);
327        }
328    }
329
330    @Override
331    protected void onFinishInflate() {
332        super.onFinishInflate();
333
334        mPlaybackSeek = (SeekBar) findViewById(R.id.playback_seek);
335        mStartStopButton = (ImageButton) findViewById(R.id.playback_start_stop);
336        mPlaybackSpeakerphone = (ImageButton) findViewById(R.id.playback_speakerphone);
337        mDeleteButton = (ImageButton) findViewById(R.id.delete_voicemail);
338        mArchiveButton =(ImageButton) findViewById(R.id.archive_voicemail);
339        mShareButton = (ImageButton) findViewById(R.id.share_voicemail);
340
341        mArchiveSpace = (Space) findViewById(R.id.space_before_archive_voicemail);
342        mShareSpace = (Space) findViewById(R.id.space_before_share_voicemail);
343
344        mStateText = (TextView) findViewById(R.id.playback_state_text);
345        mPositionText = (TextView) findViewById(R.id.playback_position_text);
346        mTotalDurationText = (TextView) findViewById(R.id.total_duration_text);
347
348        mPlaybackSeek.setOnSeekBarChangeListener(mSeekBarChangeListener);
349        mStartStopButton.setOnClickListener(mStartStopButtonListener);
350        mPlaybackSpeakerphone.setOnClickListener(mSpeakerphoneListener);
351        mDeleteButton.setOnClickListener(mDeleteButtonListener);
352        mArchiveButton.setOnClickListener(mArchiveButtonListener);
353        mShareButton.setOnClickListener(mShareButtonListener);
354
355        mPositionText.setText(formatAsMinutesAndSeconds(0));
356        mTotalDurationText.setText(formatAsMinutesAndSeconds(0));
357
358        mVoicemailSeekHandleEnabled = getResources().getDrawable(
359                R.drawable.ic_voicemail_seek_handle, mContext.getTheme());
360        mVoicemailSeekHandleDisabled = getResources().getDrawable(
361                R.drawable.ic_voicemail_seek_handle_disabled, mContext.getTheme());
362    }
363
364    @Override
365    public void onPlaybackStarted(int duration, ScheduledExecutorService executorService) {
366        mIsPlaying = true;
367
368        mStartStopButton.setImageResource(R.drawable.ic_pause);
369
370        if (mPositionUpdater != null) {
371            mPositionUpdater.stopUpdating();
372            mPositionUpdater = null;
373        }
374        mPositionUpdater = new PositionUpdater(duration, executorService);
375        mPositionUpdater.startUpdating();
376    }
377
378    @Override
379    public void onPlaybackStopped() {
380        mIsPlaying = false;
381
382        mStartStopButton.setImageResource(R.drawable.ic_play_arrow);
383
384        if (mPositionUpdater != null) {
385            mPositionUpdater.stopUpdating();
386            mPositionUpdater = null;
387        }
388    }
389
390    @Override
391    public void onPlaybackError() {
392        if (mPositionUpdater != null) {
393            mPositionUpdater.stopUpdating();
394        }
395
396        disableUiElements();
397        mStateText.setText(getString(R.string.voicemail_playback_error));
398    }
399
400    @Override
401    public void onSpeakerphoneOn(boolean on) {
402        if (on) {
403            mPlaybackSpeakerphone.setImageResource(R.drawable.ic_volume_up_24dp);
404            // Speaker is now on, tapping button will turn it off.
405            mPlaybackSpeakerphone.setContentDescription(
406                    mContext.getString(R.string.voicemail_speaker_off));
407        } else {
408            mPlaybackSpeakerphone.setImageResource(R.drawable.ic_volume_down_24dp);
409            // Speaker is now off, tapping button will turn it on.
410            mPlaybackSpeakerphone.setContentDescription(
411                    mContext.getString(R.string.voicemail_speaker_on));
412        }
413    }
414
415    @Override
416    public void setClipPosition(int positionMs, int durationMs) {
417        int seekBarPositionMs = Math.max(0, positionMs);
418        int seekBarMax = Math.max(seekBarPositionMs, durationMs);
419        if (mPlaybackSeek.getMax() != seekBarMax) {
420            mPlaybackSeek.setMax(seekBarMax);
421        }
422
423        mPlaybackSeek.setProgress(seekBarPositionMs);
424
425        mPositionText.setText(formatAsMinutesAndSeconds(seekBarPositionMs));
426        mTotalDurationText.setText(formatAsMinutesAndSeconds(durationMs));
427    }
428
429    @Override
430    public void setSuccess() {
431        mStateText.setText(null);
432    }
433
434    @Override
435    public void setIsFetchingContent() {
436        disableUiElements();
437        mStateText.setText(getString(R.string.voicemail_fetching_content));
438    }
439
440    @Override
441    public void setFetchContentTimeout() {
442        mStartStopButton.setEnabled(true);
443        mStateText.setText(getString(R.string.voicemail_fetching_timout));
444    }
445
446    @Override
447    public int getDesiredClipPosition() {
448        return mPlaybackSeek.getProgress();
449    }
450
451    @Override
452    public void disableUiElements() {
453        mStartStopButton.setEnabled(false);
454        resetSeekBar();
455    }
456
457    @Override
458    public void enableUiElements() {
459        mDeleteButton.setEnabled(true);
460        mStartStopButton.setEnabled(true);
461        mPlaybackSeek.setEnabled(true);
462        mPlaybackSeek.setThumb(mVoicemailSeekHandleEnabled);
463    }
464
465    @Override
466    public void resetSeekBar() {
467        mPlaybackSeek.setProgress(0);
468        mPlaybackSeek.setEnabled(false);
469        mPlaybackSeek.setThumb(mVoicemailSeekHandleDisabled);
470    }
471
472    @Override
473    public void onDeleteCall() {}
474
475    @Override
476    public void onDeleteVoicemail() {
477        mPresenter.onVoicemailDeletedInDatabase();
478    }
479
480    @Override
481    public void onGetCallDetails(PhoneCallDetails[] details) {}
482
483    private String getString(int resId) {
484        return mContext.getString(resId);
485    }
486
487    /**
488     * Formats a number of milliseconds as something that looks like {@code 00:05}.
489     * <p>
490     * We always use four digits, two for minutes two for seconds.  In the very unlikely event
491     * that the voicemail duration exceeds 99 minutes, the display is capped at 99 minutes.
492     */
493    private String formatAsMinutesAndSeconds(int millis) {
494        int seconds = millis / 1000;
495        int minutes = seconds / 60;
496        seconds -= minutes * 60;
497        if (minutes > 99) {
498            minutes = 99;
499        }
500        return String.format("%02d:%02d", minutes, seconds);
501    }
502
503    /**
504     * Called when a voicemail archive succeeded. If the expanded voicemail was being
505     * archived, update the card UI. Either way, display a snackbar linking user to archive.
506     */
507    @Override
508    public void onVoicemailArchiveSucceded(Uri voicemailUri) {
509        if (isArchiving(voicemailUri)) {
510            mIsArchiving.remove(voicemailUri);
511            if (Objects.equals(voicemailUri, mVoicemailUri)) {
512                onVoicemailArchiveResult();
513                hideArchiveButton();
514            }
515        }
516
517        Snackbar.make(this, R.string.snackbar_voicemail_archived,
518                Snackbar.LENGTH_LONG)
519                .setDuration(VOICEMAIL_ARCHIVE_DELAY_MS)
520                .setAction(R.string.snackbar_voicemail_archived_goto,
521                        new View.OnClickListener() {
522                            @Override
523                            public void onClick(View view) {
524                                Intent intent = new Intent(mContext,
525                                        VoicemailArchiveActivity.class);
526                                mContext.startActivity(intent);
527                            }
528                        })
529                .setActionTextColor(
530                        mContext.getResources().getColor(R.color.dialer_snackbar_action_text_color))
531                .show();
532    }
533
534    /**
535     * If a voicemail archive failed, and the expanded card was being archived, update the card UI.
536     * Either way, display a toast saying the voicemail archive failed.
537     */
538    @Override
539    public void onVoicemailArchiveFailed(Uri voicemailUri) {
540        if (isArchiving(voicemailUri)) {
541            mIsArchiving.remove(voicemailUri);
542            if (Objects.equals(voicemailUri, mVoicemailUri)) {
543                onVoicemailArchiveResult();
544            }
545        }
546        String toastStr = mContext.getString(R.string.voicemail_archive_failed);
547        Toast.makeText(mContext, toastStr, Toast.LENGTH_SHORT).show();
548    }
549
550    public void hideArchiveButton() {
551        mArchiveSpace.setVisibility(View.GONE);
552        mArchiveButton.setVisibility(View.GONE);
553        mArchiveButton.setClickable(false);
554        mArchiveButton.setEnabled(false);
555    }
556
557    /**
558     * Whenever a voicemail archive succeeds or fails, clear the text displayed in the voicemail
559     * card.
560     */
561    private void onVoicemailArchiveResult() {
562        enableUiElements();
563        mStateText.setText(null);
564        mArchiveButton.setColorFilter(null);
565    }
566
567    /**
568     * Whether or not the voicemail with the given uri is being archived.
569     */
570    private boolean isArchiving(@Nullable Uri uri) {
571        return uri != null && mIsArchiving.contains(uri);
572    }
573
574    /**
575     * Show the proper text and hide the archive button if the voicemail is still being archived.
576     */
577    private void updateArchiveUI(@Nullable Uri voicemailUri) {
578        if (!Objects.equals(voicemailUri, mVoicemailUri)) {
579            return;
580        }
581        if (isArchiving(voicemailUri)) {
582            // If expanded card was in the middle of archiving, disable buttons and display message
583            disableUiElements();
584            mDeleteButton.setEnabled(false);
585            mArchiveButton.setColorFilter(getResources().getColor(R.color.setting_disabled_color));
586            mStateText.setText(getString(R.string.voicemail_archiving_content));
587        } else {
588            onVoicemailArchiveResult();
589        }
590    }
591
592    /**
593     * Hides the archive button if the voicemail has already been archived, shows otherwise.
594     * @param voicemailUri the URI of the voicemail for which the archive button needs to be updated
595     */
596    private void updateArchiveButton(@Nullable final Uri voicemailUri) {
597        if (voicemailUri == null ||
598                !Objects.equals(voicemailUri, mVoicemailUri) || isArchiving(voicemailUri) ||
599                Objects.equals(voicemailUri.getAuthority(),VoicemailArchiveContract.AUTHORITY)) {
600            return;
601        }
602        mAsyncTaskExecutor.submit(Tasks.QUERY_ARCHIVED_STATUS,
603                new AsyncTask<Void, Void, Boolean>() {
604            @Override
605            public Boolean doInBackground(Void... params) {
606                Cursor cursor = mContext.getContentResolver().query(VoicemailArchive.CONTENT_URI,
607                        null, VoicemailArchive.SERVER_ID + "=" + ContentUris.parseId(mVoicemailUri)
608                        + " AND " + VoicemailArchive.ARCHIVED + "= 1", null, null);
609                boolean archived = cursor != null && cursor.getCount() > 0;
610                cursor.close();
611                return archived;
612            }
613
614            @Override
615            public void onPostExecute(Boolean archived) {
616                if (!Objects.equals(voicemailUri, mVoicemailUri)) {
617                    return;
618                }
619
620                if (archived) {
621                    hideArchiveButton();
622                } else {
623                    mArchiveSpace.setVisibility(View.VISIBLE);
624                    mArchiveButton.setVisibility(View.VISIBLE);
625                    mArchiveButton.setClickable(true);
626                    mArchiveButton.setEnabled(true);
627                }
628
629            }
630        });
631    }
632
633    @VisibleForTesting
634    public String getStateText() {
635        return mStateText.getText().toString();
636    }
637}
638