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.app.voicemail;
18
19import android.content.Context;
20import android.graphics.drawable.Drawable;
21import android.net.Uri;
22import android.os.Handler;
23import android.support.annotation.VisibleForTesting;
24import android.support.design.widget.Snackbar;
25import android.util.AttributeSet;
26import android.view.LayoutInflater;
27import android.view.View;
28import android.widget.ImageButton;
29import android.widget.LinearLayout;
30import android.widget.SeekBar;
31import android.widget.SeekBar.OnSeekBarChangeListener;
32import android.widget.TextView;
33import com.android.dialer.app.R;
34import com.android.dialer.app.calllog.CallLogAsyncTaskUtil;
35import com.android.dialer.app.calllog.CallLogListItemViewHolder;
36import com.android.dialer.logging.DialerImpression;
37import com.android.dialer.logging.Logger;
38import java.util.Objects;
39import java.util.concurrent.ScheduledExecutorService;
40import java.util.concurrent.ScheduledFuture;
41import java.util.concurrent.TimeUnit;
42import javax.annotation.concurrent.GuardedBy;
43import javax.annotation.concurrent.NotThreadSafe;
44import javax.annotation.concurrent.ThreadSafe;
45
46/**
47 * Displays and plays a single voicemail. See {@link VoicemailPlaybackPresenter} for details on the
48 * voicemail playback implementation.
49 *
50 * <p>This class is not thread-safe, it is thread-confined. All calls to all public methods on this
51 * class are expected to come from the main ui thread.
52 */
53@NotThreadSafe
54public class VoicemailPlaybackLayout extends LinearLayout
55    implements VoicemailPlaybackPresenter.PlaybackView,
56        CallLogAsyncTaskUtil.CallLogAsyncTaskListener {
57
58  private static final String TAG = VoicemailPlaybackLayout.class.getSimpleName();
59  private static final int VOICEMAIL_DELETE_DELAY_MS = 3000;
60
61  private Context mContext;
62  private CallLogListItemViewHolder mViewHolder;
63  private VoicemailPlaybackPresenter mPresenter;
64  /** Click listener to toggle speakerphone. */
65  private final View.OnClickListener mSpeakerphoneListener =
66      new View.OnClickListener() {
67        @Override
68        public void onClick(View v) {
69          if (mPresenter != null) {
70            mPresenter.toggleSpeakerphone();
71          }
72        }
73      };
74
75  private Uri mVoicemailUri;
76  private final View.OnClickListener mDeleteButtonListener =
77      new View.OnClickListener() {
78        @Override
79        public void onClick(View view) {
80          Logger.get(mContext).logImpression(DialerImpression.Type.VOICEMAIL_DELETE_ENTRY);
81          if (mPresenter == null) {
82            return;
83          }
84
85          // When the undo button is pressed, the viewHolder we have is no longer valid because when
86          // we hide the view it is binded to something else, and the layout is not updated for
87          // hidden items. copy the adapter position so we can update the view upon undo.
88          // TODO: refactor this so the view holder will always be valid.
89          final int adapterPosition = mViewHolder.getAdapterPosition();
90
91          mPresenter.pausePlayback();
92          mPresenter.onVoicemailDeleted(mViewHolder);
93
94          final Uri deleteUri = mVoicemailUri;
95          final Runnable deleteCallback =
96              new Runnable() {
97                @Override
98                public void run() {
99                  if (Objects.equals(deleteUri, mVoicemailUri)) {
100                    CallLogAsyncTaskUtil.deleteVoicemail(
101                        mContext, deleteUri, VoicemailPlaybackLayout.this);
102                  }
103                }
104              };
105
106          final Handler handler = new Handler();
107          // Add a little buffer time in case the user clicked "undo" at the end of the delay
108          // window.
109          handler.postDelayed(deleteCallback, VOICEMAIL_DELETE_DELAY_MS + 50);
110
111          Snackbar.make(
112                  VoicemailPlaybackLayout.this,
113                  R.string.snackbar_voicemail_deleted,
114                  Snackbar.LENGTH_LONG)
115              .setDuration(VOICEMAIL_DELETE_DELAY_MS)
116              .setAction(
117                  R.string.snackbar_voicemail_deleted_undo,
118                  new View.OnClickListener() {
119                    @Override
120                    public void onClick(View view) {
121                      mPresenter.onVoicemailDeleteUndo(adapterPosition);
122                      handler.removeCallbacks(deleteCallback);
123                    }
124                  })
125              .setActionTextColor(
126                  mContext.getResources().getColor(R.color.dialer_snackbar_action_text_color))
127              .show();
128        }
129      };
130  private boolean mIsPlaying = false;
131  /** Click listener to play or pause voicemail playback. */
132  private final View.OnClickListener mStartStopButtonListener =
133      new View.OnClickListener() {
134        @Override
135        public void onClick(View view) {
136          if (mPresenter == null) {
137            return;
138          }
139
140          if (mIsPlaying) {
141            mPresenter.pausePlayback();
142          } else {
143            Logger.get(mContext)
144                .logImpression(DialerImpression.Type.VOICEMAIL_PLAY_AUDIO_AFTER_EXPANDING_ENTRY);
145            mPresenter.resumePlayback();
146          }
147        }
148      };
149
150  private SeekBar mPlaybackSeek;
151  private ImageButton mStartStopButton;
152  private ImageButton mPlaybackSpeakerphone;
153  private ImageButton mDeleteButton;
154  private TextView mStateText;
155  private TextView mPositionText;
156  private TextView mTotalDurationText;
157  /** Handle state changes when the user manipulates the seek bar. */
158  private final OnSeekBarChangeListener mSeekBarChangeListener =
159      new OnSeekBarChangeListener() {
160        @Override
161        public void onStartTrackingTouch(SeekBar seekBar) {
162          if (mPresenter != null) {
163            mPresenter.pausePlaybackForSeeking();
164          }
165        }
166
167        @Override
168        public void onStopTrackingTouch(SeekBar seekBar) {
169          if (mPresenter != null) {
170            mPresenter.resumePlaybackAfterSeeking(seekBar.getProgress());
171          }
172        }
173
174        @Override
175        public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
176          setClipPosition(progress, seekBar.getMax());
177          // Update the seek position if user manually changed it. This makes sure position gets
178          // updated when user use volume button to seek playback in talkback mode.
179          if (fromUser) {
180            mPresenter.seek(progress);
181          }
182        }
183      };
184
185  private PositionUpdater mPositionUpdater;
186  private Drawable mVoicemailSeekHandleEnabled;
187  private Drawable mVoicemailSeekHandleDisabled;
188
189  public VoicemailPlaybackLayout(Context context) {
190    this(context, null);
191  }
192
193  public VoicemailPlaybackLayout(Context context, AttributeSet attrs) {
194    super(context, attrs);
195    mContext = context;
196    LayoutInflater inflater =
197        (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
198    inflater.inflate(R.layout.voicemail_playback_layout, this);
199  }
200
201  public void setViewHolder(CallLogListItemViewHolder mViewHolder) {
202    this.mViewHolder = mViewHolder;
203  }
204
205  @Override
206  public void setPresenter(VoicemailPlaybackPresenter presenter, Uri voicemailUri) {
207    mPresenter = presenter;
208    mVoicemailUri = voicemailUri;
209  }
210
211  @Override
212  protected void onFinishInflate() {
213    super.onFinishInflate();
214
215    mPlaybackSeek = (SeekBar) findViewById(R.id.playback_seek);
216    mStartStopButton = (ImageButton) findViewById(R.id.playback_start_stop);
217    mPlaybackSpeakerphone = (ImageButton) findViewById(R.id.playback_speakerphone);
218    mDeleteButton = (ImageButton) findViewById(R.id.delete_voicemail);
219
220    mStateText = (TextView) findViewById(R.id.playback_state_text);
221    mStateText.setAccessibilityLiveRegion(ACCESSIBILITY_LIVE_REGION_POLITE);
222    mPositionText = (TextView) findViewById(R.id.playback_position_text);
223    mTotalDurationText = (TextView) findViewById(R.id.total_duration_text);
224
225    mPlaybackSeek.setOnSeekBarChangeListener(mSeekBarChangeListener);
226    mStartStopButton.setOnClickListener(mStartStopButtonListener);
227    mPlaybackSpeakerphone.setOnClickListener(mSpeakerphoneListener);
228    mDeleteButton.setOnClickListener(mDeleteButtonListener);
229
230    mPositionText.setText(formatAsMinutesAndSeconds(0));
231    mTotalDurationText.setText(formatAsMinutesAndSeconds(0));
232
233    mVoicemailSeekHandleEnabled =
234        getResources().getDrawable(R.drawable.ic_voicemail_seek_handle, mContext.getTheme());
235    mVoicemailSeekHandleDisabled =
236        getResources()
237            .getDrawable(R.drawable.ic_voicemail_seek_handle_disabled, mContext.getTheme());
238  }
239
240  @Override
241  public void onPlaybackStarted(int duration, ScheduledExecutorService executorService) {
242    mIsPlaying = true;
243
244    mStartStopButton.setImageResource(R.drawable.ic_pause);
245
246    if (mPositionUpdater != null) {
247      mPositionUpdater.stopUpdating();
248      mPositionUpdater = null;
249    }
250    mPositionUpdater = new PositionUpdater(duration, executorService);
251    mPositionUpdater.startUpdating();
252  }
253
254  @Override
255  public void onPlaybackStopped() {
256    mIsPlaying = false;
257
258    mStartStopButton.setImageResource(R.drawable.ic_play_arrow);
259
260    if (mPositionUpdater != null) {
261      mPositionUpdater.stopUpdating();
262      mPositionUpdater = null;
263    }
264  }
265
266  @Override
267  public void onPlaybackError() {
268    if (mPositionUpdater != null) {
269      mPositionUpdater.stopUpdating();
270    }
271
272    disableUiElements();
273    mStateText.setText(getString(R.string.voicemail_playback_error));
274  }
275
276  @Override
277  public void onSpeakerphoneOn(boolean on) {
278    if (on) {
279      mPlaybackSpeakerphone.setImageResource(R.drawable.quantum_ic_volume_up_white_24);
280      // Speaker is now on, tapping button will turn it off.
281      mPlaybackSpeakerphone.setContentDescription(
282          mContext.getString(R.string.voicemail_speaker_off));
283    } else {
284      mPlaybackSpeakerphone.setImageResource(R.drawable.quantum_ic_volume_down_white_24);
285      // Speaker is now off, tapping button will turn it on.
286      mPlaybackSpeakerphone.setContentDescription(
287          mContext.getString(R.string.voicemail_speaker_on));
288    }
289  }
290
291  @Override
292  public void setClipPosition(int positionMs, int durationMs) {
293    int seekBarPositionMs = Math.max(0, positionMs);
294    int seekBarMax = Math.max(seekBarPositionMs, durationMs);
295    if (mPlaybackSeek.getMax() != seekBarMax) {
296      mPlaybackSeek.setMax(seekBarMax);
297    }
298
299    mPlaybackSeek.setProgress(seekBarPositionMs);
300
301    mPositionText.setText(formatAsMinutesAndSeconds(seekBarPositionMs));
302    mTotalDurationText.setText(formatAsMinutesAndSeconds(durationMs));
303  }
304
305  @Override
306  public void setSuccess() {
307    mStateText.setText(null);
308  }
309
310  @Override
311  public void setIsFetchingContent() {
312    disableUiElements();
313    mStateText.setText(getString(R.string.voicemail_fetching_content));
314  }
315
316  @Override
317  public void setFetchContentTimeout() {
318    mStartStopButton.setEnabled(true);
319    mStateText.setText(getString(R.string.voicemail_fetching_timout));
320  }
321
322  @Override
323  public int getDesiredClipPosition() {
324    return mPlaybackSeek.getProgress();
325  }
326
327  @Override
328  public void disableUiElements() {
329    mStartStopButton.setEnabled(false);
330    resetSeekBar();
331  }
332
333  @Override
334  public void enableUiElements() {
335    mDeleteButton.setEnabled(true);
336    mStartStopButton.setEnabled(true);
337    mPlaybackSeek.setEnabled(true);
338    mPlaybackSeek.setThumb(mVoicemailSeekHandleEnabled);
339  }
340
341  @Override
342  public void resetSeekBar() {
343    mPlaybackSeek.setProgress(0);
344    mPlaybackSeek.setEnabled(false);
345    mPlaybackSeek.setThumb(mVoicemailSeekHandleDisabled);
346  }
347
348  @Override
349  public void onDeleteVoicemail() {
350    mPresenter.onVoicemailDeletedInDatabase();
351  }
352
353  private String getString(int resId) {
354    return mContext.getString(resId);
355  }
356
357  /**
358   * Formats a number of milliseconds as something that looks like {@code 00:05}.
359   *
360   * <p>We always use four digits, two for minutes two for seconds. In the very unlikely event that
361   * the voicemail duration exceeds 99 minutes, the display is capped at 99 minutes.
362   */
363  private String formatAsMinutesAndSeconds(int millis) {
364    int seconds = millis / 1000;
365    int minutes = seconds / 60;
366    seconds -= minutes * 60;
367    if (minutes > 99) {
368      minutes = 99;
369    }
370    return String.format("%02d:%02d", minutes, seconds);
371  }
372
373  @VisibleForTesting
374  public String getStateText() {
375    return mStateText.getText().toString();
376  }
377
378  /** Controls the animation of the playback slider. */
379  @ThreadSafe
380  private final class PositionUpdater implements Runnable {
381
382    /** Update rate for the slider, 30fps. */
383    private static final int SLIDER_UPDATE_PERIOD_MILLIS = 1000 / 30;
384
385    private final ScheduledExecutorService mExecutorService;
386    private final Object mLock = new Object();
387    private int mDurationMs;
388
389    @GuardedBy("mLock")
390    private ScheduledFuture<?> mScheduledFuture;
391
392    private Runnable mUpdateClipPositionRunnable =
393        new Runnable() {
394          @Override
395          public void run() {
396            int currentPositionMs = 0;
397            synchronized (mLock) {
398              if (mScheduledFuture == null || mPresenter == null) {
399                // This task has been canceled. Just stop now.
400                return;
401              }
402              currentPositionMs = mPresenter.getMediaPlayerPosition();
403            }
404            setClipPosition(currentPositionMs, mDurationMs);
405          }
406        };
407
408    public PositionUpdater(int durationMs, ScheduledExecutorService executorService) {
409      mDurationMs = durationMs;
410      mExecutorService = executorService;
411    }
412
413    @Override
414    public void run() {
415      post(mUpdateClipPositionRunnable);
416    }
417
418    public void startUpdating() {
419      synchronized (mLock) {
420        cancelPendingRunnables();
421        mScheduledFuture =
422            mExecutorService.scheduleAtFixedRate(
423                this, 0, SLIDER_UPDATE_PERIOD_MILLIS, TimeUnit.MILLISECONDS);
424      }
425    }
426
427    public void stopUpdating() {
428      synchronized (mLock) {
429        cancelPendingRunnables();
430      }
431    }
432
433    @GuardedBy("mLock")
434    private void cancelPendingRunnables() {
435      if (mScheduledFuture != null) {
436        mScheduledFuture.cancel(true);
437        mScheduledFuture = null;
438      }
439      removeCallbacks(mUpdateClipPositionRunnable);
440    }
441  }
442}
443