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