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 
17 package com.android.dialer.app.voicemail;
18 
19 import android.content.Context;
20 import android.graphics.drawable.Drawable;
21 import android.net.Uri;
22 import android.os.Handler;
23 import android.support.annotation.VisibleForTesting;
24 import android.support.design.widget.Snackbar;
25 import android.util.AttributeSet;
26 import android.view.LayoutInflater;
27 import android.view.View;
28 import android.widget.ImageButton;
29 import android.widget.LinearLayout;
30 import android.widget.SeekBar;
31 import android.widget.SeekBar.OnSeekBarChangeListener;
32 import android.widget.TextView;
33 import com.android.dialer.app.R;
34 import com.android.dialer.app.calllog.CallLogAsyncTaskUtil;
35 import com.android.dialer.app.calllog.CallLogListItemViewHolder;
36 import com.android.dialer.logging.DialerImpression;
37 import com.android.dialer.logging.Logger;
38 import java.util.Objects;
39 import java.util.concurrent.ScheduledExecutorService;
40 import java.util.concurrent.ScheduledFuture;
41 import java.util.concurrent.TimeUnit;
42 import javax.annotation.concurrent.GuardedBy;
43 import javax.annotation.concurrent.NotThreadSafe;
44 import 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
54 public 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 
VoicemailPlaybackLayout(Context context)189   public VoicemailPlaybackLayout(Context context) {
190     this(context, null);
191   }
192 
VoicemailPlaybackLayout(Context context, AttributeSet attrs)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 
setViewHolder(CallLogListItemViewHolder mViewHolder)201   public void setViewHolder(CallLogListItemViewHolder mViewHolder) {
202     this.mViewHolder = mViewHolder;
203   }
204 
205   @Override
setPresenter(VoicemailPlaybackPresenter presenter, Uri voicemailUri)206   public void setPresenter(VoicemailPlaybackPresenter presenter, Uri voicemailUri) {
207     mPresenter = presenter;
208     mVoicemailUri = voicemailUri;
209   }
210 
211   @Override
onFinishInflate()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
onPlaybackStarted(int duration, ScheduledExecutorService executorService)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
onPlaybackStopped()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
onPlaybackError()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
onSpeakerphoneOn(boolean on)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
setClipPosition(int positionMs, int durationMs)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
setSuccess()306   public void setSuccess() {
307     mStateText.setText(null);
308   }
309 
310   @Override
setIsFetchingContent()311   public void setIsFetchingContent() {
312     disableUiElements();
313     mStateText.setText(getString(R.string.voicemail_fetching_content));
314   }
315 
316   @Override
setFetchContentTimeout()317   public void setFetchContentTimeout() {
318     mStartStopButton.setEnabled(true);
319     mStateText.setText(getString(R.string.voicemail_fetching_timout));
320   }
321 
322   @Override
getDesiredClipPosition()323   public int getDesiredClipPosition() {
324     return mPlaybackSeek.getProgress();
325   }
326 
327   @Override
disableUiElements()328   public void disableUiElements() {
329     mStartStopButton.setEnabled(false);
330     resetSeekBar();
331   }
332 
333   @Override
enableUiElements()334   public void enableUiElements() {
335     mDeleteButton.setEnabled(true);
336     mStartStopButton.setEnabled(true);
337     mPlaybackSeek.setEnabled(true);
338     mPlaybackSeek.setThumb(mVoicemailSeekHandleEnabled);
339   }
340 
341   @Override
resetSeekBar()342   public void resetSeekBar() {
343     mPlaybackSeek.setProgress(0);
344     mPlaybackSeek.setEnabled(false);
345     mPlaybackSeek.setThumb(mVoicemailSeekHandleDisabled);
346   }
347 
348   @Override
onDeleteVoicemail()349   public void onDeleteVoicemail() {
350     mPresenter.onVoicemailDeletedInDatabase();
351   }
352 
getString(int resId)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    */
formatAsMinutesAndSeconds(int millis)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
getStateText()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 
PositionUpdater(int durationMs, ScheduledExecutorService executorService)408     public PositionUpdater(int durationMs, ScheduledExecutorService executorService) {
409       mDurationMs = durationMs;
410       mExecutorService = executorService;
411     }
412 
413     @Override
run()414     public void run() {
415       post(mUpdateClipPositionRunnable);
416     }
417 
startUpdating()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 
stopUpdating()427     public void stopUpdating() {
428       synchronized (mLock) {
429         cancelPendingRunnables();
430       }
431     }
432 
433     @GuardedBy("mLock")
cancelPendingRunnables()434     private void cancelPendingRunnables() {
435       if (mScheduledFuture != null) {
436         mScheduledFuture.cancel(true);
437         mScheduledFuture = null;
438       }
439       removeCallbacks(mUpdateClipPositionRunnable);
440     }
441   }
442 }
443