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.voicemail; 18 19import android.content.ContentUris; 20import android.content.Context; 21import android.content.Intent; 22import android.database.Cursor; 23import android.graphics.drawable.Drawable; 24import android.net.Uri; 25import android.os.AsyncTask; 26import android.os.Handler; 27import android.util.AttributeSet; 28import android.support.design.widget.Snackbar; 29import android.view.LayoutInflater; 30import android.view.View; 31import android.widget.ImageButton; 32import android.widget.LinearLayout; 33import android.widget.SeekBar; 34import android.widget.SeekBar.OnSeekBarChangeListener; 35import android.widget.Space; 36import android.widget.TextView; 37import android.widget.Toast; 38 39import com.android.common.io.MoreCloseables; 40import com.android.dialer.PhoneCallDetails; 41import com.android.dialer.R; 42import com.android.dialer.calllog.CallLogAsyncTaskUtil; 43 44import com.android.dialer.database.VoicemailArchiveContract; 45import com.android.dialer.database.VoicemailArchiveContract.VoicemailArchive; 46import com.android.dialer.util.AsyncTaskExecutor; 47import com.android.dialer.util.AsyncTaskExecutors; 48import com.android.dialerbind.ObjectFactory; 49import com.google.common.annotations.VisibleForTesting; 50 51import java.util.ArrayList; 52import java.util.HashMap; 53import java.util.Objects; 54import java.util.concurrent.TimeUnit; 55import java.util.concurrent.ScheduledFuture; 56import java.util.concurrent.ScheduledExecutorService; 57 58import javax.annotation.Nullable; 59import javax.annotation.concurrent.GuardedBy; 60import javax.annotation.concurrent.NotThreadSafe; 61import javax.annotation.concurrent.ThreadSafe; 62 63/** 64 * Displays and plays a single voicemail. See {@link VoicemailPlaybackPresenter} for 65 * details on the voicemail playback implementation. 66 * 67 * This class is not thread-safe, it is thread-confined. All calls to all public 68 * methods on this class are expected to come from the main ui thread. 69 */ 70@NotThreadSafe 71public class VoicemailPlaybackLayout extends LinearLayout 72 implements VoicemailPlaybackPresenter.PlaybackView, 73 CallLogAsyncTaskUtil.CallLogAsyncTaskListener { 74 private static final String TAG = VoicemailPlaybackLayout.class.getSimpleName(); 75 private static final int VOICEMAIL_DELETE_DELAY_MS = 3000; 76 private static final int VOICEMAIL_ARCHIVE_DELAY_MS = 3000; 77 78 /** The enumeration of {@link AsyncTask} objects we use in this class. */ 79 public enum Tasks { 80 QUERY_ARCHIVED_STATUS 81 } 82 83 /** 84 * Controls the animation of the playback slider. 85 */ 86 @ThreadSafe 87 private final class PositionUpdater implements Runnable { 88 89 /** Update rate for the slider, 30fps. */ 90 private static final int SLIDER_UPDATE_PERIOD_MILLIS = 1000 / 30; 91 92 private int mDurationMs; 93 private final ScheduledExecutorService mExecutorService; 94 private final Object mLock = new Object(); 95 @GuardedBy("mLock") private ScheduledFuture<?> mScheduledFuture; 96 97 private Runnable mUpdateClipPositionRunnable = new Runnable() { 98 @Override 99 public void run() { 100 int currentPositionMs = 0; 101 synchronized (mLock) { 102 if (mScheduledFuture == null || mPresenter == null) { 103 // This task has been canceled. Just stop now. 104 return; 105 } 106 currentPositionMs = mPresenter.getMediaPlayerPosition(); 107 } 108 setClipPosition(currentPositionMs, mDurationMs); 109 } 110 }; 111 112 public PositionUpdater(int durationMs, ScheduledExecutorService executorService) { 113 mDurationMs = durationMs; 114 mExecutorService = executorService; 115 } 116 117 @Override 118 public void run() { 119 post(mUpdateClipPositionRunnable); 120 } 121 122 public void startUpdating() { 123 synchronized (mLock) { 124 cancelPendingRunnables(); 125 mScheduledFuture = mExecutorService.scheduleAtFixedRate( 126 this, 0, SLIDER_UPDATE_PERIOD_MILLIS, TimeUnit.MILLISECONDS); 127 } 128 } 129 130 public void stopUpdating() { 131 synchronized (mLock) { 132 cancelPendingRunnables(); 133 } 134 } 135 136 private void cancelPendingRunnables() { 137 if (mScheduledFuture != null) { 138 mScheduledFuture.cancel(true); 139 mScheduledFuture = null; 140 } 141 removeCallbacks(mUpdateClipPositionRunnable); 142 } 143 } 144 145 /** 146 * Handle state changes when the user manipulates the seek bar. 147 */ 148 private final OnSeekBarChangeListener mSeekBarChangeListener = new OnSeekBarChangeListener() { 149 @Override 150 public void onStartTrackingTouch(SeekBar seekBar) { 151 if (mPresenter != null) { 152 mPresenter.pausePlaybackForSeeking(); 153 } 154 } 155 156 @Override 157 public void onStopTrackingTouch(SeekBar seekBar) { 158 if (mPresenter != null) { 159 mPresenter.resumePlaybackAfterSeeking(seekBar.getProgress()); 160 } 161 } 162 163 @Override 164 public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { 165 setClipPosition(progress, seekBar.getMax()); 166 // Update the seek position if user manually changed it. This makes sure position gets 167 // updated when user use volume button to seek playback in talkback mode. 168 if (fromUser) { 169 mPresenter.seek(progress); 170 } 171 } 172 }; 173 174 /** 175 * Click listener to toggle speakerphone. 176 */ 177 private final View.OnClickListener mSpeakerphoneListener = new View.OnClickListener() { 178 @Override 179 public void onClick(View v) { 180 if (mPresenter != null) { 181 mPresenter.toggleSpeakerphone(); 182 } 183 } 184 }; 185 186 /** 187 * Click listener to play or pause voicemail playback. 188 */ 189 private final View.OnClickListener mStartStopButtonListener = new View.OnClickListener() { 190 @Override 191 public void onClick(View view) { 192 if (mPresenter == null) { 193 return; 194 } 195 196 if (mIsPlaying) { 197 mPresenter.pausePlayback(); 198 } else { 199 mPresenter.resumePlayback(); 200 } 201 } 202 }; 203 204 private final View.OnClickListener mDeleteButtonListener = new View.OnClickListener() { 205 @Override 206 public void onClick(View view ) { 207 if (mPresenter == null) { 208 return; 209 } 210 mPresenter.pausePlayback(); 211 mPresenter.onVoicemailDeleted(); 212 213 final Uri deleteUri = mVoicemailUri; 214 final Runnable deleteCallback = new Runnable() { 215 @Override 216 public void run() { 217 if (Objects.equals(deleteUri, mVoicemailUri)) { 218 CallLogAsyncTaskUtil.deleteVoicemail(mContext, deleteUri, 219 VoicemailPlaybackLayout.this); 220 } 221 } 222 }; 223 224 final Handler handler = new Handler(); 225 // Add a little buffer time in case the user clicked "undo" at the end of the delay 226 // window. 227 handler.postDelayed(deleteCallback, VOICEMAIL_DELETE_DELAY_MS + 50); 228 229 Snackbar.make(VoicemailPlaybackLayout.this, R.string.snackbar_voicemail_deleted, 230 Snackbar.LENGTH_LONG) 231 .setDuration(VOICEMAIL_DELETE_DELAY_MS) 232 .setAction(R.string.snackbar_voicemail_deleted_undo, 233 new View.OnClickListener() { 234 @Override 235 public void onClick(View view) { 236 mPresenter.onVoicemailDeleteUndo(); 237 handler.removeCallbacks(deleteCallback); 238 } 239 }) 240 .setActionTextColor( 241 mContext.getResources().getColor( 242 R.color.dialer_snackbar_action_text_color)) 243 .show(); 244 } 245 }; 246 247 private final View.OnClickListener mArchiveButtonListener = new View.OnClickListener() { 248 @Override 249 public void onClick(View v) { 250 if (mPresenter == null || isArchiving(mVoicemailUri)) { 251 return; 252 } 253 mIsArchiving.add(mVoicemailUri); 254 mPresenter.pausePlayback(); 255 updateArchiveUI(mVoicemailUri); 256 disableUiElements(); 257 mPresenter.archiveContent(mVoicemailUri, true); 258 } 259 }; 260 261 private final View.OnClickListener mShareButtonListener = new View.OnClickListener() { 262 @Override 263 public void onClick(View v) { 264 if (mPresenter == null || isArchiving(mVoicemailUri)) { 265 return; 266 } 267 disableUiElements(); 268 mPresenter.archiveContent(mVoicemailUri, false); 269 } 270 }; 271 272 private Context mContext; 273 private VoicemailPlaybackPresenter mPresenter; 274 private Uri mVoicemailUri; 275 private final AsyncTaskExecutor mAsyncTaskExecutor = 276 AsyncTaskExecutors.createAsyncTaskExecutor(); 277 private boolean mIsPlaying = false; 278 /** 279 * Keeps track of which voicemails are currently being archived in order to update the voicemail 280 * card UI every time a user opens a new card. 281 */ 282 private static final ArrayList<Uri> mIsArchiving = new ArrayList<>(); 283 284 private SeekBar mPlaybackSeek; 285 private ImageButton mStartStopButton; 286 private ImageButton mPlaybackSpeakerphone; 287 private ImageButton mDeleteButton; 288 private ImageButton mArchiveButton; 289 private ImageButton mShareButton; 290 291 private Space mArchiveSpace; 292 private Space mShareSpace; 293 294 private TextView mStateText; 295 private TextView mPositionText; 296 private TextView mTotalDurationText; 297 298 private PositionUpdater mPositionUpdater; 299 private Drawable mVoicemailSeekHandleEnabled; 300 private Drawable mVoicemailSeekHandleDisabled; 301 302 public VoicemailPlaybackLayout(Context context) { 303 this(context, null); 304 } 305 306 public VoicemailPlaybackLayout(Context context, AttributeSet attrs) { 307 super(context, attrs); 308 mContext = context; 309 LayoutInflater inflater = 310 (LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE); 311 inflater.inflate(R.layout.voicemail_playback_layout, this); 312 } 313 314 @Override 315 public void setPresenter(VoicemailPlaybackPresenter presenter, Uri voicemailUri) { 316 mPresenter = presenter; 317 mVoicemailUri = voicemailUri; 318 if (ObjectFactory.isVoicemailArchiveEnabled(mContext)) { 319 updateArchiveUI(mVoicemailUri); 320 updateArchiveButton(mVoicemailUri); 321 } 322 323 if (ObjectFactory.isVoicemailShareEnabled(mContext)) { 324 // Show share button and space before it 325 mShareSpace.setVisibility(View.VISIBLE); 326 mShareButton.setVisibility(View.VISIBLE); 327 } 328 } 329 330 @Override 331 protected void onFinishInflate() { 332 super.onFinishInflate(); 333 334 mPlaybackSeek = (SeekBar) findViewById(R.id.playback_seek); 335 mStartStopButton = (ImageButton) findViewById(R.id.playback_start_stop); 336 mPlaybackSpeakerphone = (ImageButton) findViewById(R.id.playback_speakerphone); 337 mDeleteButton = (ImageButton) findViewById(R.id.delete_voicemail); 338 mArchiveButton =(ImageButton) findViewById(R.id.archive_voicemail); 339 mShareButton = (ImageButton) findViewById(R.id.share_voicemail); 340 341 mArchiveSpace = (Space) findViewById(R.id.space_before_archive_voicemail); 342 mShareSpace = (Space) findViewById(R.id.space_before_share_voicemail); 343 344 mStateText = (TextView) findViewById(R.id.playback_state_text); 345 mPositionText = (TextView) findViewById(R.id.playback_position_text); 346 mTotalDurationText = (TextView) findViewById(R.id.total_duration_text); 347 348 mPlaybackSeek.setOnSeekBarChangeListener(mSeekBarChangeListener); 349 mStartStopButton.setOnClickListener(mStartStopButtonListener); 350 mPlaybackSpeakerphone.setOnClickListener(mSpeakerphoneListener); 351 mDeleteButton.setOnClickListener(mDeleteButtonListener); 352 mArchiveButton.setOnClickListener(mArchiveButtonListener); 353 mShareButton.setOnClickListener(mShareButtonListener); 354 355 mPositionText.setText(formatAsMinutesAndSeconds(0)); 356 mTotalDurationText.setText(formatAsMinutesAndSeconds(0)); 357 358 mVoicemailSeekHandleEnabled = getResources().getDrawable( 359 R.drawable.ic_voicemail_seek_handle, mContext.getTheme()); 360 mVoicemailSeekHandleDisabled = getResources().getDrawable( 361 R.drawable.ic_voicemail_seek_handle_disabled, mContext.getTheme()); 362 } 363 364 @Override 365 public void onPlaybackStarted(int duration, ScheduledExecutorService executorService) { 366 mIsPlaying = true; 367 368 mStartStopButton.setImageResource(R.drawable.ic_pause); 369 370 if (mPositionUpdater != null) { 371 mPositionUpdater.stopUpdating(); 372 mPositionUpdater = null; 373 } 374 mPositionUpdater = new PositionUpdater(duration, executorService); 375 mPositionUpdater.startUpdating(); 376 } 377 378 @Override 379 public void onPlaybackStopped() { 380 mIsPlaying = false; 381 382 mStartStopButton.setImageResource(R.drawable.ic_play_arrow); 383 384 if (mPositionUpdater != null) { 385 mPositionUpdater.stopUpdating(); 386 mPositionUpdater = null; 387 } 388 } 389 390 @Override 391 public void onPlaybackError() { 392 if (mPositionUpdater != null) { 393 mPositionUpdater.stopUpdating(); 394 } 395 396 disableUiElements(); 397 mStateText.setText(getString(R.string.voicemail_playback_error)); 398 } 399 400 @Override 401 public void onSpeakerphoneOn(boolean on) { 402 if (on) { 403 mPlaybackSpeakerphone.setImageResource(R.drawable.ic_volume_up_24dp); 404 // Speaker is now on, tapping button will turn it off. 405 mPlaybackSpeakerphone.setContentDescription( 406 mContext.getString(R.string.voicemail_speaker_off)); 407 } else { 408 mPlaybackSpeakerphone.setImageResource(R.drawable.ic_volume_down_24dp); 409 // Speaker is now off, tapping button will turn it on. 410 mPlaybackSpeakerphone.setContentDescription( 411 mContext.getString(R.string.voicemail_speaker_on)); 412 } 413 } 414 415 @Override 416 public void setClipPosition(int positionMs, int durationMs) { 417 int seekBarPositionMs = Math.max(0, positionMs); 418 int seekBarMax = Math.max(seekBarPositionMs, durationMs); 419 if (mPlaybackSeek.getMax() != seekBarMax) { 420 mPlaybackSeek.setMax(seekBarMax); 421 } 422 423 mPlaybackSeek.setProgress(seekBarPositionMs); 424 425 mPositionText.setText(formatAsMinutesAndSeconds(seekBarPositionMs)); 426 mTotalDurationText.setText(formatAsMinutesAndSeconds(durationMs)); 427 } 428 429 @Override 430 public void setSuccess() { 431 mStateText.setText(null); 432 } 433 434 @Override 435 public void setIsFetchingContent() { 436 disableUiElements(); 437 mStateText.setText(getString(R.string.voicemail_fetching_content)); 438 } 439 440 @Override 441 public void setFetchContentTimeout() { 442 mStartStopButton.setEnabled(true); 443 mStateText.setText(getString(R.string.voicemail_fetching_timout)); 444 } 445 446 @Override 447 public int getDesiredClipPosition() { 448 return mPlaybackSeek.getProgress(); 449 } 450 451 @Override 452 public void disableUiElements() { 453 mStartStopButton.setEnabled(false); 454 resetSeekBar(); 455 } 456 457 @Override 458 public void enableUiElements() { 459 mDeleteButton.setEnabled(true); 460 mStartStopButton.setEnabled(true); 461 mPlaybackSeek.setEnabled(true); 462 mPlaybackSeek.setThumb(mVoicemailSeekHandleEnabled); 463 } 464 465 @Override 466 public void resetSeekBar() { 467 mPlaybackSeek.setProgress(0); 468 mPlaybackSeek.setEnabled(false); 469 mPlaybackSeek.setThumb(mVoicemailSeekHandleDisabled); 470 } 471 472 @Override 473 public void onDeleteCall() {} 474 475 @Override 476 public void onDeleteVoicemail() { 477 mPresenter.onVoicemailDeletedInDatabase(); 478 } 479 480 @Override 481 public void onGetCallDetails(PhoneCallDetails[] details) {} 482 483 private String getString(int resId) { 484 return mContext.getString(resId); 485 } 486 487 /** 488 * Formats a number of milliseconds as something that looks like {@code 00:05}. 489 * <p> 490 * We always use four digits, two for minutes two for seconds. In the very unlikely event 491 * that the voicemail duration exceeds 99 minutes, the display is capped at 99 minutes. 492 */ 493 private String formatAsMinutesAndSeconds(int millis) { 494 int seconds = millis / 1000; 495 int minutes = seconds / 60; 496 seconds -= minutes * 60; 497 if (minutes > 99) { 498 minutes = 99; 499 } 500 return String.format("%02d:%02d", minutes, seconds); 501 } 502 503 /** 504 * Called when a voicemail archive succeeded. If the expanded voicemail was being 505 * archived, update the card UI. Either way, display a snackbar linking user to archive. 506 */ 507 @Override 508 public void onVoicemailArchiveSucceded(Uri voicemailUri) { 509 if (isArchiving(voicemailUri)) { 510 mIsArchiving.remove(voicemailUri); 511 if (Objects.equals(voicemailUri, mVoicemailUri)) { 512 onVoicemailArchiveResult(); 513 hideArchiveButton(); 514 } 515 } 516 517 Snackbar.make(this, R.string.snackbar_voicemail_archived, 518 Snackbar.LENGTH_LONG) 519 .setDuration(VOICEMAIL_ARCHIVE_DELAY_MS) 520 .setAction(R.string.snackbar_voicemail_archived_goto, 521 new View.OnClickListener() { 522 @Override 523 public void onClick(View view) { 524 Intent intent = new Intent(mContext, 525 VoicemailArchiveActivity.class); 526 mContext.startActivity(intent); 527 } 528 }) 529 .setActionTextColor( 530 mContext.getResources().getColor(R.color.dialer_snackbar_action_text_color)) 531 .show(); 532 } 533 534 /** 535 * If a voicemail archive failed, and the expanded card was being archived, update the card UI. 536 * Either way, display a toast saying the voicemail archive failed. 537 */ 538 @Override 539 public void onVoicemailArchiveFailed(Uri voicemailUri) { 540 if (isArchiving(voicemailUri)) { 541 mIsArchiving.remove(voicemailUri); 542 if (Objects.equals(voicemailUri, mVoicemailUri)) { 543 onVoicemailArchiveResult(); 544 } 545 } 546 String toastStr = mContext.getString(R.string.voicemail_archive_failed); 547 Toast.makeText(mContext, toastStr, Toast.LENGTH_SHORT).show(); 548 } 549 550 public void hideArchiveButton() { 551 mArchiveSpace.setVisibility(View.GONE); 552 mArchiveButton.setVisibility(View.GONE); 553 mArchiveButton.setClickable(false); 554 mArchiveButton.setEnabled(false); 555 } 556 557 /** 558 * Whenever a voicemail archive succeeds or fails, clear the text displayed in the voicemail 559 * card. 560 */ 561 private void onVoicemailArchiveResult() { 562 enableUiElements(); 563 mStateText.setText(null); 564 mArchiveButton.setColorFilter(null); 565 } 566 567 /** 568 * Whether or not the voicemail with the given uri is being archived. 569 */ 570 private boolean isArchiving(@Nullable Uri uri) { 571 return uri != null && mIsArchiving.contains(uri); 572 } 573 574 /** 575 * Show the proper text and hide the archive button if the voicemail is still being archived. 576 */ 577 private void updateArchiveUI(@Nullable Uri voicemailUri) { 578 if (!Objects.equals(voicemailUri, mVoicemailUri)) { 579 return; 580 } 581 if (isArchiving(voicemailUri)) { 582 // If expanded card was in the middle of archiving, disable buttons and display message 583 disableUiElements(); 584 mDeleteButton.setEnabled(false); 585 mArchiveButton.setColorFilter(getResources().getColor(R.color.setting_disabled_color)); 586 mStateText.setText(getString(R.string.voicemail_archiving_content)); 587 } else { 588 onVoicemailArchiveResult(); 589 } 590 } 591 592 /** 593 * Hides the archive button if the voicemail has already been archived, shows otherwise. 594 * @param voicemailUri the URI of the voicemail for which the archive button needs to be updated 595 */ 596 private void updateArchiveButton(@Nullable final Uri voicemailUri) { 597 if (voicemailUri == null || 598 !Objects.equals(voicemailUri, mVoicemailUri) || isArchiving(voicemailUri) || 599 Objects.equals(voicemailUri.getAuthority(),VoicemailArchiveContract.AUTHORITY)) { 600 return; 601 } 602 mAsyncTaskExecutor.submit(Tasks.QUERY_ARCHIVED_STATUS, 603 new AsyncTask<Void, Void, Boolean>() { 604 @Override 605 public Boolean doInBackground(Void... params) { 606 Cursor cursor = mContext.getContentResolver().query(VoicemailArchive.CONTENT_URI, 607 null, VoicemailArchive.SERVER_ID + "=" + ContentUris.parseId(mVoicemailUri) 608 + " AND " + VoicemailArchive.ARCHIVED + "= 1", null, null); 609 boolean archived = cursor != null && cursor.getCount() > 0; 610 cursor.close(); 611 return archived; 612 } 613 614 @Override 615 public void onPostExecute(Boolean archived) { 616 if (!Objects.equals(voicemailUri, mVoicemailUri)) { 617 return; 618 } 619 620 if (archived) { 621 hideArchiveButton(); 622 } else { 623 mArchiveSpace.setVisibility(View.VISIBLE); 624 mArchiveButton.setVisibility(View.VISIBLE); 625 mArchiveButton.setClickable(true); 626 mArchiveButton.setEnabled(true); 627 } 628 629 } 630 }); 631 } 632 633 @VisibleForTesting 634 public String getStateText() { 635 return mStateText.getText().toString(); 636 } 637} 638