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