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 context;
62   private CallLogListItemViewHolder viewHolder;
63   private VoicemailPlaybackPresenter presenter;
64   /** Click listener to toggle speakerphone. */
65   private final View.OnClickListener speakerphoneListener =
66       new View.OnClickListener() {
67         @Override
68         public void onClick(View v) {
69           if (presenter != null) {
70             presenter.toggleSpeakerphone();
71           }
72         }
73       };
74 
75   private Uri voicemailUri;
76   private final View.OnClickListener deleteButtonListener =
77       new View.OnClickListener() {
78         @Override
79         public void onClick(View view) {
80           Logger.get(context).logImpression(DialerImpression.Type.VOICEMAIL_DELETE_ENTRY);
81           if (presenter == 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(twyen): refactor this so the view holder will always be valid.
89           final int adapterPosition = viewHolder.getAdapterPosition();
90 
91           presenter.pausePlayback();
92           presenter.onVoicemailDeleted(viewHolder);
93 
94           final Uri deleteUri = voicemailUri;
95           final Runnable deleteCallback =
96               new Runnable() {
97                 @Override
98                 public void run() {
99                   if (Objects.equals(deleteUri, voicemailUri)) {
100                     CallLogAsyncTaskUtil.deleteVoicemail(
101                         context, 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_undo,
118                   new View.OnClickListener() {
119                     @Override
120                     public void onClick(View view) {
121                       presenter.onVoicemailDeleteUndo(adapterPosition);
122                       handler.removeCallbacks(deleteCallback);
123                     }
124                   })
125               .setActionTextColor(
126                   context.getResources().getColor(R.color.dialer_snackbar_action_text_color))
127               .show();
128         }
129       };
130   private boolean isPlaying = false;
131   /** Click listener to play or pause voicemail playback. */
132   private final View.OnClickListener startStopButtonListener =
133       new View.OnClickListener() {
134         @Override
135         public void onClick(View view) {
136           if (presenter == null) {
137             return;
138           }
139 
140           if (isPlaying) {
141             presenter.pausePlayback();
142           } else {
143             Logger.get(context)
144                 .logImpression(DialerImpression.Type.VOICEMAIL_PLAY_AUDIO_AFTER_EXPANDING_ENTRY);
145             presenter.resumePlayback();
146           }
147         }
148       };
149 
150   private SeekBar playbackSeek;
151   private ImageButton startStopButton;
152   private ImageButton playbackSpeakerphone;
153   private ImageButton deleteButton;
154   private TextView stateText;
155   private TextView positionText;
156   private TextView totalDurationText;
157   /** Handle state changes when the user manipulates the seek bar. */
158   private final OnSeekBarChangeListener seekBarChangeListener =
159       new OnSeekBarChangeListener() {
160         @Override
161         public void onStartTrackingTouch(SeekBar seekBar) {
162           if (presenter != null) {
163             presenter.pausePlaybackForSeeking();
164           }
165         }
166 
167         @Override
168         public void onStopTrackingTouch(SeekBar seekBar) {
169           if (presenter != null) {
170             presenter.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             presenter.seek(progress);
181           }
182         }
183       };
184 
185   private PositionUpdater positionUpdater;
186   private Drawable voicemailSeekHandleEnabled;
187   private Drawable voicemailSeekHandleDisabled;
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     this.context = 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.viewHolder = mViewHolder;
203   }
204 
205   @Override
setPresenter(VoicemailPlaybackPresenter presenter, Uri voicemailUri)206   public void setPresenter(VoicemailPlaybackPresenter presenter, Uri voicemailUri) {
207     this.presenter = presenter;
208     this.voicemailUri = voicemailUri;
209   }
210 
211   @Override
onFinishInflate()212   protected void onFinishInflate() {
213     super.onFinishInflate();
214 
215     playbackSeek = (SeekBar) findViewById(R.id.playback_seek);
216     startStopButton = (ImageButton) findViewById(R.id.playback_start_stop);
217     playbackSpeakerphone = (ImageButton) findViewById(R.id.playback_speakerphone);
218     deleteButton = (ImageButton) findViewById(R.id.delete_voicemail);
219 
220     stateText = (TextView) findViewById(R.id.playback_state_text);
221     stateText.setAccessibilityLiveRegion(ACCESSIBILITY_LIVE_REGION_POLITE);
222     positionText = (TextView) findViewById(R.id.playback_position_text);
223     totalDurationText = (TextView) findViewById(R.id.total_duration_text);
224 
225     playbackSeek.setOnSeekBarChangeListener(seekBarChangeListener);
226     startStopButton.setOnClickListener(startStopButtonListener);
227     playbackSpeakerphone.setOnClickListener(speakerphoneListener);
228     deleteButton.setOnClickListener(deleteButtonListener);
229 
230     positionText.setText(formatAsMinutesAndSeconds(0));
231     totalDurationText.setText(formatAsMinutesAndSeconds(0));
232 
233     voicemailSeekHandleEnabled =
234         getResources().getDrawable(R.drawable.ic_voicemail_seek_handle, context.getTheme());
235     voicemailSeekHandleDisabled =
236         getResources()
237             .getDrawable(R.drawable.old_ic_voicemail_seek_handle_disabled, context.getTheme());
238   }
239 
240   @Override
onPlaybackStarted(int duration, ScheduledExecutorService executorService)241   public void onPlaybackStarted(int duration, ScheduledExecutorService executorService) {
242     isPlaying = true;
243 
244     startStopButton.setImageResource(R.drawable.ic_pause);
245 
246     if (positionUpdater != null) {
247       positionUpdater.stopUpdating();
248       positionUpdater = null;
249     }
250     positionUpdater = new PositionUpdater(duration, executorService);
251     positionUpdater.startUpdating();
252   }
253 
254   @Override
onPlaybackStopped()255   public void onPlaybackStopped() {
256     isPlaying = false;
257 
258     startStopButton.setImageResource(R.drawable.ic_play_arrow);
259 
260     if (positionUpdater != null) {
261       positionUpdater.stopUpdating();
262       positionUpdater = null;
263     }
264   }
265 
266   @Override
onPlaybackError()267   public void onPlaybackError() {
268     if (positionUpdater != null) {
269       positionUpdater.stopUpdating();
270     }
271 
272     disableUiElements();
273     stateText.setText(getString(R.string.voicemail_playback_error));
274   }
275 
276   @Override
onSpeakerphoneOn(boolean on)277   public void onSpeakerphoneOn(boolean on) {
278     if (on) {
279       playbackSpeakerphone.setImageResource(R.drawable.quantum_ic_volume_up_vd_theme_24);
280       // Speaker is now on, tapping button will turn it off.
281       playbackSpeakerphone.setContentDescription(context.getString(R.string.voicemail_speaker_off));
282     } else {
283       playbackSpeakerphone.setImageResource(R.drawable.quantum_ic_volume_down_white_24);
284       // Speaker is now off, tapping button will turn it on.
285       playbackSpeakerphone.setContentDescription(context.getString(R.string.voicemail_speaker_on));
286     }
287   }
288 
289   @Override
setClipPosition(int positionMs, int durationMs)290   public void setClipPosition(int positionMs, int durationMs) {
291     int seekBarPositionMs = Math.max(0, positionMs);
292     int seekBarMax = Math.max(seekBarPositionMs, durationMs);
293     if (playbackSeek.getMax() != seekBarMax) {
294       playbackSeek.setMax(seekBarMax);
295     }
296 
297     playbackSeek.setProgress(seekBarPositionMs);
298 
299     positionText.setText(formatAsMinutesAndSeconds(seekBarPositionMs));
300     totalDurationText.setText(formatAsMinutesAndSeconds(durationMs));
301   }
302 
303   @Override
setSuccess()304   public void setSuccess() {
305     stateText.setText(null);
306   }
307 
308   @Override
setIsFetchingContent()309   public void setIsFetchingContent() {
310     disableUiElements();
311     stateText.setText(getString(R.string.voicemail_fetching_content));
312   }
313 
314   @Override
setFetchContentTimeout()315   public void setFetchContentTimeout() {
316     startStopButton.setEnabled(true);
317     stateText.setText(getString(R.string.voicemail_fetching_timout));
318   }
319 
320   @Override
getDesiredClipPosition()321   public int getDesiredClipPosition() {
322     return playbackSeek.getProgress();
323   }
324 
325   @Override
disableUiElements()326   public void disableUiElements() {
327     startStopButton.setEnabled(false);
328     resetSeekBar();
329   }
330 
331   @Override
enableUiElements()332   public void enableUiElements() {
333     deleteButton.setEnabled(true);
334     startStopButton.setEnabled(true);
335     playbackSeek.setEnabled(true);
336     playbackSeek.setThumb(voicemailSeekHandleEnabled);
337   }
338 
339   @Override
resetSeekBar()340   public void resetSeekBar() {
341     playbackSeek.setProgress(0);
342     playbackSeek.setEnabled(false);
343     playbackSeek.setThumb(voicemailSeekHandleDisabled);
344   }
345 
346   @Override
onDeleteVoicemail()347   public void onDeleteVoicemail() {
348     presenter.onVoicemailDeletedInDatabase();
349   }
350 
getString(int resId)351   private String getString(int resId) {
352     return context.getString(resId);
353   }
354 
355   /**
356    * Formats a number of milliseconds as something that looks like {@code 00:05}.
357    *
358    * <p>We always use four digits, two for minutes two for seconds. In the very unlikely event that
359    * the voicemail duration exceeds 99 minutes, the display is capped at 99 minutes.
360    */
formatAsMinutesAndSeconds(int millis)361   private String formatAsMinutesAndSeconds(int millis) {
362     int seconds = millis / 1000;
363     int minutes = seconds / 60;
364     seconds -= minutes * 60;
365     if (minutes > 99) {
366       minutes = 99;
367     }
368     return String.format("%02d:%02d", minutes, seconds);
369   }
370 
371   @VisibleForTesting
getStateText()372   public String getStateText() {
373     return stateText.getText().toString();
374   }
375 
376   /** Controls the animation of the playback slider. */
377   @ThreadSafe
378   private final class PositionUpdater implements Runnable {
379 
380     /** Update rate for the slider, 30fps. */
381     private static final int SLIDER_UPDATE_PERIOD_MILLIS = 1000 / 30;
382 
383     private final ScheduledExecutorService executorService;
384     private final Object lock = new Object();
385     private int durationMs;
386 
387     @GuardedBy("lock")
388     private ScheduledFuture<?> scheduledFuture;
389 
390     private Runnable updateClipPositionRunnable =
391         new Runnable() {
392           @Override
393           public void run() {
394             int currentPositionMs = 0;
395             synchronized (lock) {
396               if (scheduledFuture == null || presenter == null) {
397                 // This task has been canceled. Just stop now.
398                 return;
399               }
400               currentPositionMs = presenter.getMediaPlayerPosition();
401             }
402             setClipPosition(currentPositionMs, durationMs);
403           }
404         };
405 
PositionUpdater(int durationMs, ScheduledExecutorService executorService)406     public PositionUpdater(int durationMs, ScheduledExecutorService executorService) {
407       this.durationMs = durationMs;
408       this.executorService = executorService;
409     }
410 
411     @Override
run()412     public void run() {
413       post(updateClipPositionRunnable);
414     }
415 
startUpdating()416     public void startUpdating() {
417       synchronized (lock) {
418         cancelPendingRunnables();
419         scheduledFuture =
420             executorService.scheduleAtFixedRate(
421                 this, 0, SLIDER_UPDATE_PERIOD_MILLIS, TimeUnit.MILLISECONDS);
422       }
423     }
424 
stopUpdating()425     public void stopUpdating() {
426       synchronized (lock) {
427         cancelPendingRunnables();
428       }
429     }
430 
431     @GuardedBy("lock")
cancelPendingRunnables()432     private void cancelPendingRunnables() {
433       if (scheduledFuture != null) {
434         scheduledFuture.cancel(true);
435         scheduledFuture = null;
436       }
437       removeCallbacks(updateClipPositionRunnable);
438     }
439   }
440 }
441