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.voicemail; 18 19 import android.content.Context; 20 import android.database.ContentObserver; 21 import android.media.AudioManager; 22 import android.media.MediaPlayer; 23 import android.net.Uri; 24 import android.os.AsyncTask; 25 import android.os.Bundle; 26 import android.os.Handler; 27 import android.os.PowerManager; 28 import android.view.View; 29 import android.widget.SeekBar; 30 31 import com.android.dialer.R; 32 import com.android.dialer.util.AsyncTaskExecutor; 33 import com.android.ex.variablespeed.MediaPlayerProxy; 34 import com.android.ex.variablespeed.SingleThreadedMediaPlayerProxy; 35 import com.google.common.annotations.VisibleForTesting; 36 import com.google.common.base.Preconditions; 37 38 import java.util.concurrent.RejectedExecutionException; 39 import java.util.concurrent.ScheduledExecutorService; 40 import java.util.concurrent.ScheduledFuture; 41 import java.util.concurrent.TimeUnit; 42 import java.util.concurrent.atomic.AtomicBoolean; 43 import java.util.concurrent.atomic.AtomicInteger; 44 45 import javax.annotation.concurrent.GuardedBy; 46 import javax.annotation.concurrent.NotThreadSafe; 47 import javax.annotation.concurrent.ThreadSafe; 48 49 /** 50 * Contains the controlling logic for a voicemail playback ui. 51 * <p> 52 * Specifically right now this class is used to control the 53 * {@link com.android.dialer.voicemail.VoicemailPlaybackFragment}. 54 * <p> 55 * This class is not thread safe. The thread policy for this class is 56 * thread-confinement, all calls into this class from outside must be done from 57 * the main ui thread. 58 */ 59 @NotThreadSafe 60 @VisibleForTesting 61 public class VoicemailPlaybackPresenter { 62 /** The stream used to playback voicemail. */ 63 private static final int PLAYBACK_STREAM = AudioManager.STREAM_VOICE_CALL; 64 65 /** Contract describing the behaviour we need from the ui we are controlling. */ 66 public interface PlaybackView { 67 Context getDataSourceContext(); 68 void runOnUiThread(Runnable runnable); 69 void setStartStopListener(View.OnClickListener listener); 70 void setPositionSeekListener(SeekBar.OnSeekBarChangeListener listener); 71 void setSpeakerphoneListener(View.OnClickListener listener); 72 void setIsBuffering(); 73 void setClipPosition(int clipPositionInMillis, int clipLengthInMillis); 74 int getDesiredClipPosition(); 75 void playbackStarted(); 76 void playbackStopped(); 77 void playbackError(Exception e); 78 boolean isSpeakerPhoneOn(); 79 void setSpeakerPhoneOn(boolean on); 80 void finish(); 81 void setRateDisplay(float rate, int stringResourceId); 82 void setRateIncreaseButtonListener(View.OnClickListener listener); 83 void setRateDecreaseButtonListener(View.OnClickListener listener); 84 void setIsFetchingContent(); 85 void disableUiElements(); 86 void enableUiElements(); 87 void sendFetchVoicemailRequest(Uri voicemailUri); 88 boolean queryHasContent(Uri voicemailUri); 89 void setFetchContentTimeout(); 90 void registerContentObserver(Uri uri, ContentObserver observer); 91 void unregisterContentObserver(ContentObserver observer); 92 void enableProximitySensor(); 93 void disableProximitySensor(); 94 void setVolumeControlStream(int streamType); 95 } 96 97 /** The enumeration of {@link AsyncTask} objects we use in this class. */ 98 public enum Tasks { 99 CHECK_FOR_CONTENT, 100 CHECK_CONTENT_AFTER_CHANGE, 101 PREPARE_MEDIA_PLAYER, 102 RESET_PREPARE_START_MEDIA_PLAYER, 103 } 104 105 /** Update rate for the slider, 30fps. */ 106 private static final int SLIDER_UPDATE_PERIOD_MILLIS = 1000 / 30; 107 /** Time our ui will wait for content to be fetched before reporting not available. */ 108 private static final long FETCH_CONTENT_TIMEOUT_MS = 20000; 109 /** 110 * If present in the saved instance bundle, we should not resume playback on 111 * create. 112 */ 113 private static final String PAUSED_STATE_KEY = VoicemailPlaybackPresenter.class.getName() 114 + ".PAUSED_STATE_KEY"; 115 /** 116 * If present in the saved instance bundle, indicates where to set the 117 * playback slider. 118 */ 119 private static final String CLIP_POSITION_KEY = VoicemailPlaybackPresenter.class.getName() 120 + ".CLIP_POSITION_KEY"; 121 122 /** The preset variable-speed rates. Each is greater than the previous by 25%. */ 123 private static final float[] PRESET_RATES = new float[] { 124 0.64f, 0.8f, 1.0f, 1.25f, 1.5625f 125 }; 126 /** The string resource ids corresponding to the names given to the above preset rates. */ 127 private static final int[] PRESET_NAMES = new int[] { 128 R.string.voicemail_speed_slowest, 129 R.string.voicemail_speed_slower, 130 R.string.voicemail_speed_normal, 131 R.string.voicemail_speed_faster, 132 R.string.voicemail_speed_fastest, 133 }; 134 135 /** 136 * Pointer into the {@link VoicemailPlaybackPresenter#PRESET_RATES} array. 137 * <p> 138 * This doesn't need to be synchronized, it's used only by the {@link RateChangeListener} 139 * which in turn is only executed on the ui thread. This can't be encapsulated inside the 140 * rate change listener since multiple rate change listeners must share the same value. 141 */ 142 private int mRateIndex = 2; 143 144 /** 145 * The most recently calculated duration. 146 * <p> 147 * We cache this in a field since we don't want to keep requesting it from the player, as 148 * this can easily lead to throwing {@link IllegalStateException} (any time the player is 149 * released, it's illegal to ask for the duration). 150 */ 151 private final AtomicInteger mDuration = new AtomicInteger(0); 152 153 private final PlaybackView mView; 154 private final MediaPlayerProxy mPlayer; 155 private final PositionUpdater mPositionUpdater; 156 157 /** Voicemail uri to play. */ 158 private final Uri mVoicemailUri; 159 /** Start playing in onCreate iff this is true. */ 160 private final boolean mStartPlayingImmediately; 161 /** Used to run async tasks that need to interact with the ui. */ 162 private final AsyncTaskExecutor mAsyncTaskExecutor; 163 164 /** 165 * Used to handle the result of a successful or time-out fetch result. 166 * <p> 167 * This variable is thread-contained, accessed only on the ui thread. 168 */ 169 private FetchResultHandler mFetchResultHandler; 170 private PowerManager.WakeLock mWakeLock; 171 private AsyncTask<Void, ?, ?> mPrepareTask; 172 173 public VoicemailPlaybackPresenter(PlaybackView view, MediaPlayerProxy player, 174 Uri voicemailUri, ScheduledExecutorService executorService, 175 boolean startPlayingImmediately, AsyncTaskExecutor asyncTaskExecutor, 176 PowerManager.WakeLock wakeLock) { 177 mView = view; 178 mPlayer = player; 179 mVoicemailUri = voicemailUri; 180 mStartPlayingImmediately = startPlayingImmediately; 181 mAsyncTaskExecutor = asyncTaskExecutor; 182 mPositionUpdater = new PositionUpdater(executorService, SLIDER_UPDATE_PERIOD_MILLIS); 183 mWakeLock = wakeLock; 184 } 185 186 public void onCreate(Bundle bundle) { 187 mView.setVolumeControlStream(PLAYBACK_STREAM); 188 checkThatWeHaveContent(); 189 } 190 191 /** 192 * Checks to see if we have content available for this voicemail. 193 * <p> 194 * This method will be called once, after the fragment has been created, before we know if the 195 * voicemail we've been asked to play has any content available. 196 * <p> 197 * This method will notify the user through the ui that we are fetching the content, then check 198 * to see if the content field in the db is set. If set, we proceed to 199 * {@link #postSuccessfullyFetchedContent()} method. If not set, we will make a request to fetch 200 * the content asynchronously via {@link #makeRequestForContent()}. 201 */ 202 private void checkThatWeHaveContent() { 203 mView.setIsFetchingContent(); 204 mAsyncTaskExecutor.submit(Tasks.CHECK_FOR_CONTENT, new AsyncTask<Void, Void, Boolean>() { 205 @Override 206 public Boolean doInBackground(Void... params) { 207 return mView.queryHasContent(mVoicemailUri); 208 } 209 210 @Override 211 public void onPostExecute(Boolean hasContent) { 212 if (hasContent) { 213 postSuccessfullyFetchedContent(); 214 } else { 215 makeRequestForContent(); 216 } 217 } 218 }); 219 } 220 221 /** 222 * Makes a broadcast request to ask that a voicemail source fetch this content. 223 * <p> 224 * This method <b>must be called on the ui thread</b>. 225 * <p> 226 * This method will be called when we realise that we don't have content for this voicemail. It 227 * will trigger a broadcast to request that the content be downloaded. It will add a listener to 228 * the content resolver so that it will be notified when the has_content field changes. It will 229 * also set a timer. If the has_content field changes to true within the allowed time, we will 230 * proceed to {@link #postSuccessfullyFetchedContent()}. If the has_content field does not 231 * become true within the allowed time, we will update the ui to reflect the fact that content 232 * was not available. 233 */ 234 private void makeRequestForContent() { 235 Handler handler = new Handler(); 236 Preconditions.checkState(mFetchResultHandler == null, "mFetchResultHandler should be null"); 237 mFetchResultHandler = new FetchResultHandler(handler); 238 mView.registerContentObserver(mVoicemailUri, mFetchResultHandler); 239 handler.postDelayed(mFetchResultHandler.getTimeoutRunnable(), FETCH_CONTENT_TIMEOUT_MS); 240 mView.sendFetchVoicemailRequest(mVoicemailUri); 241 } 242 243 @ThreadSafe 244 private class FetchResultHandler extends ContentObserver implements Runnable { 245 private AtomicBoolean mResultStillPending = new AtomicBoolean(true); 246 private final Handler mHandler; 247 248 public FetchResultHandler(Handler handler) { 249 super(handler); 250 mHandler = handler; 251 } 252 253 public Runnable getTimeoutRunnable() { 254 return this; 255 } 256 257 @Override 258 public void run() { 259 if (mResultStillPending.getAndSet(false)) { 260 mView.unregisterContentObserver(FetchResultHandler.this); 261 mView.setFetchContentTimeout(); 262 } 263 } 264 265 public void destroy() { 266 if (mResultStillPending.getAndSet(false)) { 267 mView.unregisterContentObserver(FetchResultHandler.this); 268 mHandler.removeCallbacks(this); 269 } 270 } 271 272 @Override 273 public void onChange(boolean selfChange) { 274 mAsyncTaskExecutor.submit(Tasks.CHECK_CONTENT_AFTER_CHANGE, 275 new AsyncTask<Void, Void, Boolean>() { 276 @Override 277 public Boolean doInBackground(Void... params) { 278 return mView.queryHasContent(mVoicemailUri); 279 } 280 281 @Override 282 public void onPostExecute(Boolean hasContent) { 283 if (hasContent) { 284 if (mResultStillPending.getAndSet(false)) { 285 mView.unregisterContentObserver(FetchResultHandler.this); 286 postSuccessfullyFetchedContent(); 287 } 288 } 289 } 290 }); 291 } 292 } 293 294 /** 295 * Prepares the voicemail content for playback. 296 * <p> 297 * This method will be called once we know that our voicemail has content (according to the 298 * content provider). This method will try to prepare the data source through the media player. 299 * If preparing the media player works, we will call through to 300 * {@link #postSuccessfulPrepareActions()}. If preparing the media player fails (perhaps the 301 * file the content provider points to is actually missing, perhaps it is of an unknown file 302 * format that we can't play, who knows) then we will show an error on the ui. 303 */ 304 private void postSuccessfullyFetchedContent() { 305 mView.setIsBuffering(); 306 mAsyncTaskExecutor.submit(Tasks.PREPARE_MEDIA_PLAYER, 307 new AsyncTask<Void, Void, Exception>() { 308 @Override 309 public Exception doInBackground(Void... params) { 310 try { 311 mPlayer.reset(); 312 mPlayer.setDataSource(mView.getDataSourceContext(), mVoicemailUri); 313 mPlayer.setAudioStreamType(PLAYBACK_STREAM); 314 mPlayer.prepare(); 315 mDuration.set(mPlayer.getDuration()); 316 return null; 317 } catch (Exception e) { 318 return e; 319 } 320 } 321 322 @Override 323 public void onPostExecute(Exception exception) { 324 if (exception == null) { 325 postSuccessfulPrepareActions(); 326 } else { 327 mView.playbackError(exception); 328 } 329 } 330 }); 331 } 332 333 /** 334 * Enables the ui, and optionally starts playback immediately. 335 * <p> 336 * This will be called once we have successfully prepared the media player, and will optionally 337 * playback immediately. 338 */ 339 private void postSuccessfulPrepareActions() { 340 mView.enableUiElements(); 341 mView.setPositionSeekListener(new PlaybackPositionListener()); 342 mView.setStartStopListener(new StartStopButtonListener()); 343 mView.setSpeakerphoneListener(new SpeakerphoneListener()); 344 mPlayer.setOnErrorListener(new MediaPlayerErrorListener()); 345 mPlayer.setOnCompletionListener(new MediaPlayerCompletionListener()); 346 mView.setSpeakerPhoneOn(mView.isSpeakerPhoneOn()); 347 mView.setRateDecreaseButtonListener(createRateDecreaseListener()); 348 mView.setRateIncreaseButtonListener(createRateIncreaseListener()); 349 mView.setClipPosition(0, mDuration.get()); 350 mView.playbackStopped(); 351 // Always disable on stop. 352 mView.disableProximitySensor(); 353 if (mStartPlayingImmediately) { 354 resetPrepareStartPlaying(0); 355 } 356 // TODO: Now I'm ignoring the bundle, when previously I was checking for contains against 357 // the PAUSED_STATE_KEY, and CLIP_POSITION_KEY. 358 } 359 360 public void onSaveInstanceState(Bundle outState) { 361 outState.putInt(CLIP_POSITION_KEY, mView.getDesiredClipPosition()); 362 if (!mPlayer.isPlaying()) { 363 outState.putBoolean(PAUSED_STATE_KEY, true); 364 } 365 } 366 367 public void onDestroy() { 368 if (mPrepareTask != null) { 369 mPrepareTask.cancel(false); 370 mPrepareTask = null; 371 } 372 mPlayer.release(); 373 if (mFetchResultHandler != null) { 374 mFetchResultHandler.destroy(); 375 mFetchResultHandler = null; 376 } 377 mPositionUpdater.stopUpdating(); 378 if (mWakeLock.isHeld()) { 379 mWakeLock.release(); 380 } 381 } 382 383 private class MediaPlayerErrorListener implements MediaPlayer.OnErrorListener { 384 @Override 385 public boolean onError(MediaPlayer mp, int what, int extra) { 386 mView.runOnUiThread(new Runnable() { 387 @Override 388 public void run() { 389 handleError(new IllegalStateException("MediaPlayer error listener invoked")); 390 } 391 }); 392 return true; 393 } 394 } 395 396 private class MediaPlayerCompletionListener implements MediaPlayer.OnCompletionListener { 397 @Override 398 public void onCompletion(final MediaPlayer mp) { 399 mView.runOnUiThread(new Runnable() { 400 @Override 401 public void run() { 402 handleCompletion(mp); 403 } 404 }); 405 } 406 } 407 408 public View.OnClickListener createRateDecreaseListener() { 409 return new RateChangeListener(false); 410 } 411 412 public View.OnClickListener createRateIncreaseListener() { 413 return new RateChangeListener(true); 414 } 415 416 /** 417 * Listens to clicks on the rate increase and decrease buttons. 418 * <p> 419 * This class is not thread-safe, but all interactions with it will happen on the ui thread. 420 */ 421 private class RateChangeListener implements View.OnClickListener { 422 private final boolean mIncrease; 423 424 public RateChangeListener(boolean increase) { 425 mIncrease = increase; 426 } 427 428 @Override 429 public void onClick(View v) { 430 // Adjust the current rate, then clamp it to the allowed values. 431 mRateIndex = constrain(mRateIndex + (mIncrease ? 1 : -1), 0, PRESET_RATES.length - 1); 432 // Whether or not we have actually changed the index, call changeRate(). 433 // This will ensure that we show the "fastest" or "slowest" text on the ui to indicate 434 // to the user that it doesn't get any faster or slower. 435 changeRate(PRESET_RATES[mRateIndex], PRESET_NAMES[mRateIndex]); 436 } 437 } 438 439 private class AsyncPrepareTask extends AsyncTask<Void, Void, Exception> { 440 private int mClipPositionInMillis; 441 442 AsyncPrepareTask(int clipPositionInMillis) { 443 mClipPositionInMillis = clipPositionInMillis; 444 } 445 446 @Override 447 public Exception doInBackground(Void... params) { 448 try { 449 mPlayer.reset(); 450 mPlayer.setDataSource(mView.getDataSourceContext(), mVoicemailUri); 451 mPlayer.setAudioStreamType(PLAYBACK_STREAM); 452 mPlayer.prepare(); 453 return null; 454 } catch (Exception e) { 455 return e; 456 } 457 } 458 459 @Override 460 public void onPostExecute(Exception exception) { 461 mPrepareTask = null; 462 if (exception == null) { 463 final int duration = mPlayer.getDuration(); 464 mDuration.set(duration); 465 int startPosition = 466 constrain(mClipPositionInMillis, 0, duration); 467 mPlayer.seekTo(startPosition); 468 mView.setClipPosition(startPosition, duration); 469 try { 470 // Can throw RejectedExecutionException 471 mPlayer.start(); 472 mView.playbackStarted(); 473 if (!mWakeLock.isHeld()) { 474 mWakeLock.acquire(); 475 } 476 // Only enable if we are not currently using the speaker phone. 477 if (!mView.isSpeakerPhoneOn()) { 478 mView.enableProximitySensor(); 479 } 480 // Can throw RejectedExecutionException 481 mPositionUpdater.startUpdating(startPosition, duration); 482 } catch (RejectedExecutionException e) { 483 handleError(e); 484 } 485 } else { 486 handleError(exception); 487 } 488 } 489 } 490 491 private void resetPrepareStartPlaying(final int clipPositionInMillis) { 492 if (mPrepareTask != null) { 493 mPrepareTask.cancel(false); 494 mPrepareTask = null; 495 } 496 mPrepareTask = mAsyncTaskExecutor.submit(Tasks.RESET_PREPARE_START_MEDIA_PLAYER, 497 new AsyncPrepareTask(clipPositionInMillis)); 498 } 499 500 private void handleError(Exception e) { 501 mView.playbackError(e); 502 mPositionUpdater.stopUpdating(); 503 mPlayer.release(); 504 } 505 506 public void handleCompletion(MediaPlayer mediaPlayer) { 507 stopPlaybackAtPosition(0, mDuration.get()); 508 } 509 510 private void stopPlaybackAtPosition(int clipPosition, int duration) { 511 mPositionUpdater.stopUpdating(); 512 mView.playbackStopped(); 513 if (mWakeLock.isHeld()) { 514 mWakeLock.release(); 515 } 516 // Always disable on stop. 517 mView.disableProximitySensor(); 518 mView.setClipPosition(clipPosition, duration); 519 if (mPlayer.isPlaying()) { 520 mPlayer.pause(); 521 } 522 } 523 524 private class PlaybackPositionListener implements SeekBar.OnSeekBarChangeListener { 525 private boolean mShouldResumePlaybackAfterSeeking; 526 527 @Override 528 public void onStartTrackingTouch(SeekBar arg0) { 529 if (mPlayer.isPlaying()) { 530 mShouldResumePlaybackAfterSeeking = true; 531 stopPlaybackAtPosition(mPlayer.getCurrentPosition(), mDuration.get()); 532 } else { 533 mShouldResumePlaybackAfterSeeking = false; 534 } 535 } 536 537 @Override 538 public void onStopTrackingTouch(SeekBar arg0) { 539 if (mPlayer.isPlaying()) { 540 stopPlaybackAtPosition(mPlayer.getCurrentPosition(), mDuration.get()); 541 } 542 if (mShouldResumePlaybackAfterSeeking) { 543 resetPrepareStartPlaying(mView.getDesiredClipPosition()); 544 } 545 } 546 547 @Override 548 public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { 549 mView.setClipPosition(seekBar.getProgress(), seekBar.getMax()); 550 } 551 } 552 553 private void changeRate(float rate, int stringResourceId) { 554 ((SingleThreadedMediaPlayerProxy) mPlayer).setVariableSpeed(rate); 555 mView.setRateDisplay(rate, stringResourceId); 556 } 557 558 private class SpeakerphoneListener implements View.OnClickListener { 559 @Override 560 public void onClick(View v) { 561 boolean previousState = mView.isSpeakerPhoneOn(); 562 mView.setSpeakerPhoneOn(!previousState); 563 if (mPlayer.isPlaying() && previousState) { 564 // If we are currently playing and we are disabling the speaker phone, enable the 565 // sensor. 566 mView.enableProximitySensor(); 567 } else { 568 // If we are not currently playing, disable the sensor. 569 mView.disableProximitySensor(); 570 } 571 } 572 } 573 574 private class StartStopButtonListener implements View.OnClickListener { 575 @Override 576 public void onClick(View arg0) { 577 if (mPlayer.isPlaying()) { 578 stopPlaybackAtPosition(mPlayer.getCurrentPosition(), mDuration.get()); 579 } else { 580 resetPrepareStartPlaying(mView.getDesiredClipPosition()); 581 } 582 } 583 } 584 585 /** 586 * Controls the animation of the playback slider. 587 */ 588 @ThreadSafe 589 private final class PositionUpdater implements Runnable { 590 private final ScheduledExecutorService mExecutorService; 591 private final int mPeriodMillis; 592 private final Object mLock = new Object(); 593 @GuardedBy("mLock") private ScheduledFuture<?> mScheduledFuture; 594 private final Runnable mSetClipPostitionRunnable = new Runnable() { 595 @Override 596 public void run() { 597 int currentPosition = 0; 598 synchronized (mLock) { 599 if (mScheduledFuture == null) { 600 // This task has been canceled. Just stop now. 601 return; 602 } 603 currentPosition = mPlayer.getCurrentPosition(); 604 } 605 mView.setClipPosition(currentPosition, mDuration.get()); 606 } 607 }; 608 609 public PositionUpdater(ScheduledExecutorService executorService, int periodMillis) { 610 mExecutorService = executorService; 611 mPeriodMillis = periodMillis; 612 } 613 614 @Override 615 public void run() { 616 mView.runOnUiThread(mSetClipPostitionRunnable); 617 } 618 619 public void startUpdating(int beginPosition, int endPosition) { 620 synchronized (mLock) { 621 if (mScheduledFuture != null) { 622 mScheduledFuture.cancel(false); 623 mScheduledFuture = null; 624 } 625 mScheduledFuture = mExecutorService.scheduleAtFixedRate(this, 0, mPeriodMillis, 626 TimeUnit.MILLISECONDS); 627 } 628 } 629 630 public void stopUpdating() { 631 synchronized (mLock) { 632 if (mScheduledFuture != null) { 633 mScheduledFuture.cancel(false); 634 mScheduledFuture = null; 635 } 636 } 637 } 638 } 639 640 public void onPause() { 641 if (mPlayer.isPlaying()) { 642 stopPlaybackAtPosition(mPlayer.getCurrentPosition(), mDuration.get()); 643 } 644 if (mPrepareTask != null) { 645 mPrepareTask.cancel(false); 646 mPrepareTask = null; 647 } 648 if (mWakeLock.isHeld()) { 649 mWakeLock.release(); 650 } 651 } 652 653 private static int constrain(int amount, int low, int high) { 654 return amount < low ? low : (amount > high ? high : amount); 655 } 656 } 657