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 static com.android.dialer.CallDetailActivity.EXTRA_VOICEMAIL_START_PLAYBACK; 20 import static com.android.dialer.CallDetailActivity.EXTRA_VOICEMAIL_URI; 21 22 import android.app.Activity; 23 import android.app.Fragment; 24 import android.content.ContentResolver; 25 import android.content.Context; 26 import android.content.Intent; 27 import android.database.ContentObserver; 28 import android.database.Cursor; 29 import android.media.AudioManager; 30 import android.net.Uri; 31 import android.os.Bundle; 32 import android.os.PowerManager; 33 import android.provider.VoicemailContract; 34 import android.util.Log; 35 import android.view.LayoutInflater; 36 import android.view.View; 37 import android.view.ViewGroup; 38 import android.widget.ImageButton; 39 import android.widget.SeekBar; 40 import android.widget.TextView; 41 42 import com.android.common.io.MoreCloseables; 43 import com.android.contacts.commonbind.analytics.AnalyticsUtil; 44 import com.android.dialer.ProximitySensorAware; 45 import com.android.dialer.R; 46 import com.android.dialer.util.AsyncTaskExecutors; 47 import com.android.ex.variablespeed.MediaPlayerProxy; 48 import com.android.ex.variablespeed.VariableSpeed; 49 50 import com.google.common.base.Preconditions; 51 52 import java.util.concurrent.Executors; 53 import java.util.concurrent.ScheduledExecutorService; 54 import java.util.concurrent.TimeUnit; 55 56 import javax.annotation.concurrent.GuardedBy; 57 import javax.annotation.concurrent.NotThreadSafe; 58 59 /** 60 * Displays and plays back a single voicemail. 61 * <p> 62 * When the Activity containing this Fragment is created, voicemail playback 63 * will begin immediately. The Activity is expected to be started via an intent 64 * containing a suitable voicemail uri to playback. 65 * <p> 66 * This class is not thread-safe, it is thread-confined. All calls to all public 67 * methods on this class are expected to come from the main ui thread. 68 */ 69 @NotThreadSafe 70 public class VoicemailPlaybackFragment extends Fragment { 71 private static final String TAG = "VoicemailPlayback"; 72 private static final int NUMBER_OF_THREADS_IN_POOL = 2; 73 private static final String[] HAS_CONTENT_PROJECTION = new String[] { 74 VoicemailContract.Voicemails.HAS_CONTENT, 75 }; 76 77 private VoicemailPlaybackPresenter mPresenter; 78 private static int mMediaPlayerRefCount = 0; 79 private static MediaPlayerProxy mMediaPlayerInstance; 80 private static ScheduledExecutorService mScheduledExecutorService; 81 private View mPlaybackLayout; 82 83 @Override 84 public View onCreateView(LayoutInflater inflater, ViewGroup container, 85 Bundle savedInstanceState) { 86 mPlaybackLayout = inflater.inflate(R.layout.playback_layout, null); 87 return mPlaybackLayout; 88 } 89 90 @Override 91 public void onActivityCreated(Bundle savedInstanceState) { 92 super.onActivityCreated(savedInstanceState); 93 Bundle arguments = getArguments(); 94 Preconditions.checkNotNull(arguments, "fragment must be started with arguments"); 95 Uri voicemailUri = arguments.getParcelable(EXTRA_VOICEMAIL_URI); 96 Preconditions.checkNotNull(voicemailUri, "fragment must contain EXTRA_VOICEMAIL_URI"); 97 boolean startPlayback = arguments.getBoolean(EXTRA_VOICEMAIL_START_PLAYBACK, false); 98 PowerManager powerManager = 99 (PowerManager) getActivity().getSystemService(Context.POWER_SERVICE); 100 PowerManager.WakeLock wakeLock = 101 powerManager.newWakeLock( 102 PowerManager.SCREEN_DIM_WAKE_LOCK, getClass().getSimpleName()); 103 mPresenter = new VoicemailPlaybackPresenter(createPlaybackViewImpl(), 104 getMediaPlayerInstance(), voicemailUri, 105 getScheduledExecutorServiceInstance(), startPlayback, 106 AsyncTaskExecutors.createAsyncTaskExecutor(), wakeLock); 107 mPresenter.onCreate(savedInstanceState); 108 } 109 110 @Override 111 public void onSaveInstanceState(Bundle outState) { 112 mPresenter.onSaveInstanceState(outState); 113 super.onSaveInstanceState(outState); 114 } 115 116 @Override 117 public void onStart() { 118 super.onStart(); 119 AnalyticsUtil.sendScreenView(this); 120 } 121 122 @Override 123 public void onDestroy() { 124 shutdownMediaPlayer(); 125 mPresenter.onDestroy(); 126 super.onDestroy(); 127 } 128 129 @Override 130 public void onPause() { 131 mPresenter.onPause(); 132 super.onPause(); 133 } 134 135 private PlaybackViewImpl createPlaybackViewImpl() { 136 return new PlaybackViewImpl(new ActivityReference(), getActivity().getApplicationContext(), 137 mPlaybackLayout); 138 } 139 140 private static synchronized MediaPlayerProxy getMediaPlayerInstance() { 141 ++mMediaPlayerRefCount; 142 if (mMediaPlayerInstance == null) { 143 mMediaPlayerInstance = VariableSpeed.createVariableSpeed( 144 getScheduledExecutorServiceInstance()); 145 } 146 return mMediaPlayerInstance; 147 } 148 149 private static synchronized ScheduledExecutorService getScheduledExecutorServiceInstance() { 150 if (mScheduledExecutorService == null) { 151 mScheduledExecutorService = Executors.newScheduledThreadPool( 152 NUMBER_OF_THREADS_IN_POOL); 153 } 154 return mScheduledExecutorService; 155 } 156 157 private static synchronized void shutdownMediaPlayer() { 158 --mMediaPlayerRefCount; 159 if (mMediaPlayerRefCount > 0) { 160 return; 161 } 162 if (mScheduledExecutorService != null) { 163 mScheduledExecutorService.shutdown(); 164 mScheduledExecutorService = null; 165 } 166 if (mMediaPlayerInstance != null) { 167 mMediaPlayerInstance.release(); 168 mMediaPlayerInstance = null; 169 } 170 } 171 172 /** 173 * Formats a number of milliseconds as something that looks like {@code 00:05}. 174 * <p> 175 * We always use four digits, two for minutes two for seconds. In the very unlikely event 176 * that the voicemail duration exceeds 99 minutes, the display is capped at 99 minutes. 177 */ 178 private static String formatAsMinutesAndSeconds(int millis) { 179 int seconds = millis / 1000; 180 int minutes = seconds / 60; 181 seconds -= minutes * 60; 182 if (minutes > 99) { 183 minutes = 99; 184 } 185 return String.format("%02d:%02d", minutes, seconds); 186 } 187 188 /** 189 * An object that can provide us with an Activity. 190 * <p> 191 * Fragments suffer the drawback that the Activity they belong to may sometimes be null. This 192 * can happen if the Fragment is detached, for example. In that situation a call to 193 * {@link Fragment#getString(int)} will throw and {@link IllegalStateException}. Also, calling 194 * {@link Fragment#getActivity()} is dangerous - it may sometimes return null. And thus blindly 195 * calling a method on the result of getActivity() is dangerous too. 196 * <p> 197 * To work around this, I have made the {@link PlaybackViewImpl} class static, so that it does 198 * not have access to any Fragment methods directly. Instead it uses an application Context for 199 * things like accessing strings, accessing system services. It only uses the Activity when it 200 * absolutely needs it - and does so through this class. This makes it easy to see where we have 201 * to check for null properly. 202 */ 203 private final class ActivityReference { 204 /** Gets this Fragment's Activity: <b>may be null</b>. */ 205 public final Activity get() { 206 return getActivity(); 207 } 208 } 209 210 /** Methods required by the PlaybackView for the VoicemailPlaybackPresenter. */ 211 private static final class PlaybackViewImpl implements VoicemailPlaybackPresenter.PlaybackView { 212 private final ActivityReference mActivityReference; 213 private final Context mApplicationContext; 214 private final SeekBar mPlaybackSeek; 215 private final ImageButton mStartStopButton; 216 private final ImageButton mPlaybackSpeakerphone; 217 private final ImageButton mRateDecreaseButton; 218 private final ImageButton mRateIncreaseButton; 219 private final TextViewWithMessagesController mTextController; 220 221 public PlaybackViewImpl(ActivityReference activityReference, Context applicationContext, 222 View playbackLayout) { 223 Preconditions.checkNotNull(activityReference); 224 Preconditions.checkNotNull(applicationContext); 225 Preconditions.checkNotNull(playbackLayout); 226 mActivityReference = activityReference; 227 mApplicationContext = applicationContext; 228 mPlaybackSeek = (SeekBar) playbackLayout.findViewById(R.id.playback_seek); 229 mStartStopButton = (ImageButton) playbackLayout.findViewById( 230 R.id.playback_start_stop); 231 mPlaybackSpeakerphone = (ImageButton) playbackLayout.findViewById( 232 R.id.playback_speakerphone); 233 mRateDecreaseButton = (ImageButton) playbackLayout.findViewById( 234 R.id.rate_decrease_button); 235 mRateIncreaseButton = (ImageButton) playbackLayout.findViewById( 236 R.id.rate_increase_button); 237 mTextController = new TextViewWithMessagesController( 238 (TextView) playbackLayout.findViewById(R.id.playback_position_text), 239 (TextView) playbackLayout.findViewById(R.id.playback_speed_text)); 240 } 241 242 @Override 243 public void finish() { 244 Activity activity = mActivityReference.get(); 245 if (activity != null) { 246 activity.finish(); 247 } 248 } 249 250 @Override 251 public void runOnUiThread(Runnable runnable) { 252 Activity activity = mActivityReference.get(); 253 if (activity != null) { 254 activity.runOnUiThread(runnable); 255 } 256 } 257 258 @Override 259 public Context getDataSourceContext() { 260 return mApplicationContext; 261 } 262 263 @Override 264 public void setRateDecreaseButtonListener(View.OnClickListener listener) { 265 mRateDecreaseButton.setOnClickListener(listener); 266 } 267 268 @Override 269 public void setRateIncreaseButtonListener(View.OnClickListener listener) { 270 mRateIncreaseButton.setOnClickListener(listener); 271 } 272 273 @Override 274 public void setStartStopListener(View.OnClickListener listener) { 275 mStartStopButton.setOnClickListener(listener); 276 } 277 278 @Override 279 public void setSpeakerphoneListener(View.OnClickListener listener) { 280 mPlaybackSpeakerphone.setOnClickListener(listener); 281 } 282 283 @Override 284 public void setRateDisplay(float rate, int stringResourceId) { 285 mTextController.setTemporaryText( 286 mApplicationContext.getString(stringResourceId), 1, TimeUnit.SECONDS); 287 } 288 289 @Override 290 public void setPositionSeekListener(SeekBar.OnSeekBarChangeListener listener) { 291 mPlaybackSeek.setOnSeekBarChangeListener(listener); 292 } 293 294 @Override 295 public void playbackStarted() { 296 mStartStopButton.setImageResource(R.drawable.ic_hold_pause); 297 } 298 299 @Override 300 public void playbackStopped() { 301 mStartStopButton.setImageResource(R.drawable.ic_play); 302 } 303 304 @Override 305 public void enableProximitySensor() { 306 // Only change the state if the activity is still around. 307 Activity activity = mActivityReference.get(); 308 if (activity != null && activity instanceof ProximitySensorAware) { 309 ((ProximitySensorAware) activity).enableProximitySensor(); 310 } 311 } 312 313 @Override 314 public void disableProximitySensor() { 315 // Only change the state if the activity is still around. 316 Activity activity = mActivityReference.get(); 317 if (activity != null && activity instanceof ProximitySensorAware) { 318 ((ProximitySensorAware) activity).disableProximitySensor(true); 319 } 320 } 321 322 @Override 323 public void registerContentObserver(Uri uri, ContentObserver observer) { 324 mApplicationContext.getContentResolver().registerContentObserver(uri, false, observer); 325 } 326 327 @Override 328 public void unregisterContentObserver(ContentObserver observer) { 329 mApplicationContext.getContentResolver().unregisterContentObserver(observer); 330 } 331 332 @Override 333 public void setClipPosition(int clipPositionInMillis, int clipLengthInMillis) { 334 int seekBarPosition = Math.max(0, clipPositionInMillis); 335 int seekBarMax = Math.max(seekBarPosition, clipLengthInMillis); 336 if (mPlaybackSeek.getMax() != seekBarMax) { 337 mPlaybackSeek.setMax(seekBarMax); 338 } 339 mPlaybackSeek.setProgress(seekBarPosition); 340 mTextController.setPermanentText( 341 formatAsMinutesAndSeconds(seekBarMax - seekBarPosition)); 342 } 343 344 private String getString(int resId) { 345 return mApplicationContext.getString(resId); 346 } 347 348 @Override 349 public void setIsBuffering() { 350 disableUiElements(); 351 mTextController.setPermanentText(getString(R.string.voicemail_buffering)); 352 } 353 354 @Override 355 public void setIsFetchingContent() { 356 disableUiElements(); 357 mTextController.setPermanentText(getString(R.string.voicemail_fetching_content)); 358 } 359 360 @Override 361 public void setFetchContentTimeout() { 362 disableUiElements(); 363 mTextController.setPermanentText(getString(R.string.voicemail_fetching_timout)); 364 } 365 366 @Override 367 public int getDesiredClipPosition() { 368 return mPlaybackSeek.getProgress(); 369 } 370 371 @Override 372 public void disableUiElements() { 373 mRateIncreaseButton.setEnabled(false); 374 mRateDecreaseButton.setEnabled(false); 375 mStartStopButton.setEnabled(false); 376 mPlaybackSpeakerphone.setEnabled(false); 377 mPlaybackSeek.setProgress(0); 378 mPlaybackSeek.setEnabled(false); 379 } 380 381 @Override 382 public void playbackError(Exception e) { 383 disableUiElements(); 384 mTextController.setPermanentText(getString(R.string.voicemail_playback_error)); 385 Log.e(TAG, "Could not play voicemail", e); 386 } 387 388 @Override 389 public void enableUiElements() { 390 mRateIncreaseButton.setEnabled(true); 391 mRateDecreaseButton.setEnabled(true); 392 mStartStopButton.setEnabled(true); 393 mPlaybackSpeakerphone.setEnabled(true); 394 mPlaybackSeek.setEnabled(true); 395 } 396 397 @Override 398 public void sendFetchVoicemailRequest(Uri voicemailUri) { 399 Intent intent = new Intent(VoicemailContract.ACTION_FETCH_VOICEMAIL, voicemailUri); 400 mApplicationContext.sendBroadcast(intent); 401 } 402 403 @Override 404 public boolean queryHasContent(Uri voicemailUri) { 405 ContentResolver contentResolver = mApplicationContext.getContentResolver(); 406 Cursor cursor = contentResolver.query( 407 voicemailUri, HAS_CONTENT_PROJECTION, null, null, null); 408 try { 409 if (cursor != null && cursor.moveToNext()) { 410 return cursor.getInt(cursor.getColumnIndexOrThrow( 411 VoicemailContract.Voicemails.HAS_CONTENT)) == 1; 412 } 413 } finally { 414 MoreCloseables.closeQuietly(cursor); 415 } 416 return false; 417 } 418 419 private AudioManager getAudioManager() { 420 return (AudioManager) mApplicationContext.getSystemService(Context.AUDIO_SERVICE); 421 } 422 423 @Override 424 public boolean isSpeakerPhoneOn() { 425 return getAudioManager().isSpeakerphoneOn(); 426 } 427 428 @Override 429 public void setSpeakerPhoneOn(boolean on) { 430 getAudioManager().setSpeakerphoneOn(on); 431 if (on) { 432 mPlaybackSpeakerphone.setImageResource(R.drawable.ic_speakerphone_on); 433 // Speaker is now on, tapping button will turn it off. 434 mPlaybackSpeakerphone.setContentDescription( 435 mApplicationContext.getString(R.string.voicemail_speaker_off)); 436 } else { 437 mPlaybackSpeakerphone.setImageResource(R.drawable.ic_speakerphone_off); 438 // Speaker is now off, tapping button will turn it on. 439 mPlaybackSpeakerphone.setContentDescription( 440 mApplicationContext.getString(R.string.voicemail_speaker_on)); 441 } 442 } 443 444 @Override 445 public void setVolumeControlStream(int streamType) { 446 Activity activity = mActivityReference.get(); 447 if (activity != null) { 448 activity.setVolumeControlStream(streamType); 449 } 450 } 451 } 452 453 /** 454 * Controls a TextView with dynamically changing text. 455 * <p> 456 * There are two methods here of interest, 457 * {@link TextViewWithMessagesController#setPermanentText(String)} and 458 * {@link TextViewWithMessagesController#setTemporaryText(String, long, TimeUnit)}. The 459 * former is used to set the text on the text view immediately, and is used in our case for 460 * the countdown of duration remaining during voicemail playback. The second is used to 461 * temporarily replace this countdown with a message, in our case faster voicemail speed or 462 * slower voicemail speed, before returning to the countdown display. 463 * <p> 464 * All the methods on this class must be called from the ui thread. 465 */ 466 private static final class TextViewWithMessagesController { 467 private static final float VISIBLE = 1; 468 private static final float INVISIBLE = 0; 469 private static final long SHORT_ANIMATION_MS = 200; 470 private static final long LONG_ANIMATION_MS = 400; 471 private final Object mLock = new Object(); 472 private final TextView mPermanentTextView; 473 private final TextView mTemporaryTextView; 474 @GuardedBy("mLock") private Runnable mRunnable; 475 476 public TextViewWithMessagesController(TextView permanentTextView, 477 TextView temporaryTextView) { 478 mPermanentTextView = permanentTextView; 479 mTemporaryTextView = temporaryTextView; 480 } 481 482 public void setPermanentText(String text) { 483 mPermanentTextView.setText(text); 484 } 485 486 public void setTemporaryText(String text, long duration, TimeUnit units) { 487 synchronized (mLock) { 488 mTemporaryTextView.setText(text); 489 mTemporaryTextView.animate().alpha(VISIBLE).setDuration(SHORT_ANIMATION_MS); 490 mPermanentTextView.animate().alpha(INVISIBLE).setDuration(SHORT_ANIMATION_MS); 491 mRunnable = new Runnable() { 492 @Override 493 public void run() { 494 synchronized (mLock) { 495 // We check for (mRunnable == this) becuase if not true, then another 496 // setTemporaryText call has taken place in the meantime, and this 497 // one is now defunct and needs to take no action. 498 if (mRunnable == this) { 499 mRunnable = null; 500 mTemporaryTextView.animate() 501 .alpha(INVISIBLE).setDuration(LONG_ANIMATION_MS); 502 mPermanentTextView.animate() 503 .alpha(VISIBLE).setDuration(LONG_ANIMATION_MS); 504 } 505 } 506 } 507 }; 508 mTemporaryTextView.postDelayed(mRunnable, units.toMillis(duration)); 509 } 510 } 511 } 512 } 513