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