/* * Copyright (C) 2011 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.dialer.voicemail; import com.google.common.annotations.VisibleForTesting; import android.app.Activity; import android.content.Context; import android.content.ContentResolver; import android.content.Intent; import android.database.ContentObserver; import android.database.Cursor; import android.media.MediaPlayer; import android.net.Uri; import android.os.AsyncTask; import android.os.Bundle; import android.os.Handler; import android.os.PowerManager; import android.provider.VoicemailContract; import android.support.v4.content.FileProvider; import android.util.Log; import android.view.WindowManager.LayoutParams; import com.android.dialer.R; import com.android.dialer.calllog.CallLogAsyncTaskUtil; import com.android.dialer.util.AsyncTaskExecutor; import com.android.dialer.util.AsyncTaskExecutors; import com.android.common.io.MoreCloseables; import java.io.File; import java.io.IOException; import java.util.ArrayList; import java.util.List; import java.util.concurrent.Executors; import java.util.concurrent.RejectedExecutionException; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import javax.annotation.concurrent.NotThreadSafe; import javax.annotation.concurrent.ThreadSafe; /** * Contains the controlling logic for a voicemail playback in the call log. It is closely coupled * to assumptions about the behaviors and lifecycle of the call log, in particular in the * {@link CallLogFragment} and {@link CallLogAdapter}. *
* This controls a single {@link com.android.dialer.voicemail.VoicemailPlaybackLayout}. A single * instance can be reused for different such layouts, using {@link #setPlaybackView}. This * is to facilitate reuse across different voicemail call log entries. *
* This class is not thread safe. The thread policy for this class is thread-confinement, all calls * into this class from outside must be done from the main UI thread. */ @NotThreadSafe @VisibleForTesting public class VoicemailPlaybackPresenter implements MediaPlayer.OnPreparedListener, MediaPlayer.OnCompletionListener, MediaPlayer.OnErrorListener { private static final String TAG = "VmPlaybackPresenter"; /** Contract describing the behaviour we need from the ui we are controlling. */ public interface PlaybackView { int getDesiredClipPosition(); void disableUiElements(); void enableUiElements(); void onPlaybackError(); void onPlaybackStarted(int duration, ScheduledExecutorService executorService); void onPlaybackStopped(); void onSpeakerphoneOn(boolean on); void setClipPosition(int clipPositionInMillis, int clipLengthInMillis); void setFetchContentTimeout(); void setIsFetchingContent(); void onVoicemailArchiveSucceded(Uri voicemailUri); void onVoicemailArchiveFailed(Uri voicemailUri); void setPresenter(VoicemailPlaybackPresenter presenter, Uri voicemailUri); void resetSeekBar(); } public interface OnVoicemailDeletedListener { void onVoicemailDeleted(Uri uri); void onVoicemailDeleteUndo(); void onVoicemailDeletedInDatabase(); } /** The enumeration of {@link AsyncTask} objects we use in this class. */ public enum Tasks { CHECK_FOR_CONTENT, CHECK_CONTENT_AFTER_CHANGE, ARCHIVE_VOICEMAIL } protected interface OnContentCheckedListener { void onContentChecked(boolean hasContent); } private static final String[] HAS_CONTENT_PROJECTION = new String[] { VoicemailContract.Voicemails.HAS_CONTENT, VoicemailContract.Voicemails.DURATION }; private static final int NUMBER_OF_THREADS_IN_POOL = 2; // Time to wait for content to be fetched before timing out. private static final long FETCH_CONTENT_TIMEOUT_MS = 20000; private static final String VOICEMAIL_URI_KEY = VoicemailPlaybackPresenter.class.getName() + ".VOICEMAIL_URI"; private static final String IS_PREPARED_KEY = VoicemailPlaybackPresenter.class.getName() + ".IS_PREPARED"; // If present in the saved instance bundle, we should not resume playback on create. private static final String IS_PLAYING_STATE_KEY = VoicemailPlaybackPresenter.class.getName() + ".IS_PLAYING_STATE_KEY"; // If present in the saved instance bundle, indicates where to set the playback slider. private static final String CLIP_POSITION_KEY = VoicemailPlaybackPresenter.class.getName() + ".CLIP_POSITION_KEY"; private static final String IS_SPEAKERPHONE_ON_KEY = VoicemailPlaybackPresenter.class.getName() + ".IS_SPEAKER_PHONE_ON"; public static final int PLAYBACK_REQUEST = 0; public static final int ARCHIVE_REQUEST = 1; public static final int SHARE_REQUEST = 2; /** * The most recently cached duration. We cache this since we don't want to keep requesting it * from the player, as this can easily lead to throwing {@link IllegalStateException} (any time * the player is released, it's illegal to ask for the duration). */ private final AtomicInteger mDuration = new AtomicInteger(0); private static VoicemailPlaybackPresenter sInstance; private Activity mActivity; protected Context mContext; private PlaybackView mView; protected Uri mVoicemailUri; protected MediaPlayer mMediaPlayer; private int mPosition; private boolean mIsPlaying; // MediaPlayer crashes on some method calls if not prepared but does not have a method which // exposes its prepared state. Store this locally, so we can check and prevent crashes. private boolean mIsPrepared; private boolean mIsSpeakerphoneOn; private boolean mShouldResumePlaybackAfterSeeking; private int mInitialOrientation; // Used to run async tasks that need to interact with the UI. protected AsyncTaskExecutor mAsyncTaskExecutor; private static ScheduledExecutorService mScheduledExecutorService; /** * Used to handle the result of a successful or time-out fetch result. *
* This variable is thread-contained, accessed only on the ui thread.
*/
private FetchResultHandler mFetchResultHandler;
private final List
* This method must be called on the ui thread.
*
* This method will be called when we realise that we don't have content for this voicemail. It
* will trigger a broadcast to request that the content be downloaded. It will add a listener to
* the content resolver so that it will be notified when the has_content field changes. It will
* also set a timer. If the has_content field changes to true within the allowed time, we will
* proceed to {@link #prepareContent()}. If the has_content field does not
* become true within the allowed time, we will update the ui to reflect the fact that content
* was not available.
*
* @return whether issued request to fetch content
*/
protected boolean requestContent(int code) {
if (mContext == null || mVoicemailUri == null) {
return false;
}
FetchResultHandler tempFetchResultHandler =
new FetchResultHandler(new Handler(), mVoicemailUri, code);
switch (code) {
case ARCHIVE_REQUEST:
mArchiveResultHandlers.add(tempFetchResultHandler);
break;
default:
if (mFetchResultHandler != null) {
mFetchResultHandler.destroy();
}
mView.setIsFetchingContent();
mFetchResultHandler = tempFetchResultHandler;
break;
}
// Send voicemail fetch request.
Intent intent = new Intent(VoicemailContract.ACTION_FETCH_VOICEMAIL, mVoicemailUri);
mContext.sendBroadcast(intent);
return true;
}
@ThreadSafe
private class FetchResultHandler extends ContentObserver implements Runnable {
private AtomicBoolean mIsWaitingForResult = new AtomicBoolean(true);
private final Handler mFetchResultHandler;
private final Uri mVoicemailUri;
private final int mRequestCode;
public FetchResultHandler(Handler handler, Uri uri, int code) {
super(handler);
mFetchResultHandler = handler;
mRequestCode = code;
mVoicemailUri = uri;
if (mContext != null) {
mContext.getContentResolver().registerContentObserver(
mVoicemailUri, false, this);
mFetchResultHandler.postDelayed(this, FETCH_CONTENT_TIMEOUT_MS);
}
}
/**
* Stop waiting for content and notify UI if {@link FETCH_CONTENT_TIMEOUT_MS} has elapsed.
*/
@Override
public void run() {
if (mIsWaitingForResult.getAndSet(false) && mContext != null) {
mContext.getContentResolver().unregisterContentObserver(this);
if (mView != null) {
mView.setFetchContentTimeout();
}
}
}
public void destroy() {
if (mIsWaitingForResult.getAndSet(false) && mContext != null) {
mContext.getContentResolver().unregisterContentObserver(this);
mFetchResultHandler.removeCallbacks(this);
}
}
@Override
public void onChange(boolean selfChange) {
mAsyncTaskExecutor.submit(Tasks.CHECK_CONTENT_AFTER_CHANGE,
new AsyncTask
* This method will be called once we know that our voicemail has content (according to the
* content provider). this method asynchronously tries to prepare the data source through the
* media player. If preparation is successful, the media player will {@link #onPrepared()},
* and it will call {@link #onError()} otherwise.
*/
protected void prepareContent() {
if (mView == null) {
return;
}
Log.d(TAG, "prepareContent");
// Release the previous media player, otherwise there may be failures.
if (mMediaPlayer != null) {
mMediaPlayer.release();
mMediaPlayer = null;
}
mView.disableUiElements();
mIsPrepared = false;
try {
mMediaPlayer = new MediaPlayer();
mMediaPlayer.setOnPreparedListener(this);
mMediaPlayer.setOnErrorListener(this);
mMediaPlayer.setOnCompletionListener(this);
mMediaPlayer.reset();
mMediaPlayer.setDataSource(mContext, mVoicemailUri);
mMediaPlayer.setAudioStreamType(VoicemailAudioManager.PLAYBACK_STREAM);
mMediaPlayer.prepareAsync();
} catch (IOException e) {
handleError(e);
}
}
/**
* Once the media player is prepared, enables the UI and adopts the appropriate playback state.
*/
@Override
public void onPrepared(MediaPlayer mp) {
if (mView == null) {
return;
}
Log.d(TAG, "onPrepared");
mIsPrepared = true;
// Update the duration in the database if it was not previously retrieved
CallLogAsyncTaskUtil.updateVoicemailDuration(mContext, mVoicemailUri,
TimeUnit.MILLISECONDS.toSeconds(mMediaPlayer.getDuration()));
mDuration.set(mMediaPlayer.getDuration());
Log.d(TAG, "onPrepared: mPosition=" + mPosition);
mView.setClipPosition(mPosition, mDuration.get());
mView.enableUiElements();
mMediaPlayer.seekTo(mPosition);
if (mIsPlaying) {
resumePlayback();
} else {
pausePlayback();
}
}
/**
* Invoked if preparing the media player fails, for example, if file is missing or the voicemail
* is an unknown file format that can't be played.
*/
@Override
public boolean onError(MediaPlayer mp, int what, int extra) {
handleError(new IllegalStateException("MediaPlayer error listener invoked: " + extra));
return true;
}
protected void handleError(Exception e) {
Log.d(TAG, "handleError: Could not play voicemail " + e);
if (mIsPrepared) {
mMediaPlayer.release();
mMediaPlayer = null;
mIsPrepared = false;
}
if (mView != null) {
mView.onPlaybackError();
}
mPosition = 0;
mIsPlaying = false;
}
/**
* After done playing the voicemail clip, reset the clip position to the start.
*/
@Override
public void onCompletion(MediaPlayer mediaPlayer) {
pausePlayback();
// Reset the seekbar position to the beginning.
mPosition = 0;
if (mView != null) {
mView.setClipPosition(0, mDuration.get());
}
}
/**
* Only play voicemail when audio focus is granted. When it is lost (usually by another
* application requesting focus), pause playback.
*
* @param gainedFocus {@code true} if the audio focus was gained, {@code} false otherwise.
*/
public void onAudioFocusChange(boolean gainedFocus) {
if (mIsPlaying == gainedFocus) {
// Nothing new here, just exit.
return;
}
if (!mIsPlaying) {
resumePlayback();
} else {
pausePlayback();
}
}
/**
* Resumes voicemail playback at the clip position stored by the presenter. Null-op if already
* playing.
*/
public void resumePlayback() {
if (mView == null) {
return;
}
if (!mIsPrepared) {
/*
* Check content before requesting content to avoid duplicated requests. It is possible
* that the UI doesn't know content has arrived if the fetch took too long causing a
* timeout, but succeeded.
*/
checkForContent(new OnContentCheckedListener() {
@Override
public void onContentChecked(boolean hasContent) {
if (!hasContent) {
// No local content, download from server. Queue playing if the request was
// issued,
mIsPlaying = requestContent(PLAYBACK_REQUEST);
} else {
// Queue playing once the media play loaded the content.
mIsPlaying = true;
prepareContent();
}
}
});
return;
}
mIsPlaying = true;
if (mMediaPlayer != null && !mMediaPlayer.isPlaying()) {
// Clamp the start position between 0 and the duration.
mPosition = Math.max(0, Math.min(mPosition, mDuration.get()));
mMediaPlayer.seekTo(mPosition);
try {
// Grab audio focus.
// Can throw RejectedExecutionException.
mVoicemailAudioManager.requestAudioFocus();
mMediaPlayer.start();
setSpeakerphoneOn(mIsSpeakerphoneOn);
} catch (RejectedExecutionException e) {
handleError(e);
}
}
Log.d(TAG, "Resumed playback at " + mPosition + ".");
mView.onPlaybackStarted(mDuration.get(), getScheduledExecutorServiceInstance());
}
/**
* Pauses voicemail playback at the current position. Null-op if already paused.
*/
public void pausePlayback() {
if (!mIsPrepared) {
return;
}
mIsPlaying = false;
if (mMediaPlayer != null && mMediaPlayer.isPlaying()) {
mMediaPlayer.pause();
}
mPosition = mMediaPlayer == null ? 0 : mMediaPlayer.getCurrentPosition();
Log.d(TAG, "Paused playback at " + mPosition + ".");
if (mView != null) {
mView.onPlaybackStopped();
}
mVoicemailAudioManager.abandonAudioFocus();
if (mActivity != null) {
mActivity.getWindow().clearFlags(LayoutParams.FLAG_KEEP_SCREEN_ON);
}
disableProximitySensor(true /* waitForFarState */);
}
/**
* Pauses playback when the user starts seeking the position, and notes whether the voicemail is
* playing to know whether to resume playback once the user selects a new position.
*/
public void pausePlaybackForSeeking() {
if (mMediaPlayer != null) {
mShouldResumePlaybackAfterSeeking = mMediaPlayer.isPlaying();
}
pausePlayback();
}
public void resumePlaybackAfterSeeking(int desiredPosition) {
mPosition = desiredPosition;
if (mShouldResumePlaybackAfterSeeking) {
mShouldResumePlaybackAfterSeeking = false;
resumePlayback();
}
}
/**
* Seek to position. This is called when user manually seek the playback. It could be either
* by touch or volume button while in talkback mode.
* @param position
*/
public void seek(int position) {
mPosition = position;
}
private void enableProximitySensor() {
if (mProximityWakeLock == null || mIsSpeakerphoneOn || !mIsPrepared
|| mMediaPlayer == null || !mMediaPlayer.isPlaying()) {
return;
}
if (!mProximityWakeLock.isHeld()) {
Log.i(TAG, "Acquiring proximity wake lock");
mProximityWakeLock.acquire();
} else {
Log.i(TAG, "Proximity wake lock already acquired");
}
}
private void disableProximitySensor(boolean waitForFarState) {
if (mProximityWakeLock == null) {
return;
}
if (mProximityWakeLock.isHeld()) {
Log.i(TAG, "Releasing proximity wake lock");
int flags = waitForFarState ? PowerManager.RELEASE_FLAG_WAIT_FOR_NO_PROXIMITY : 0;
mProximityWakeLock.release(flags);
} else {
Log.i(TAG, "Proximity wake lock already released");
}
}
/**
* This is for use by UI interactions only. It simplifies UI logic.
*/
public void toggleSpeakerphone() {
mVoicemailAudioManager.setSpeakerphoneOn(!mIsSpeakerphoneOn);
setSpeakerphoneOn(!mIsSpeakerphoneOn);
}
/**
* This method only handles app-level changes to the speakerphone. Audio layer changes should
* be handled separately. This is so that the VoicemailAudioManager can trigger changes to
* the presenter without the presenter triggering the audio manager and duplicating actions.
*/
public void setSpeakerphoneOn(boolean on) {
if (mView == null) {
return;
}
mView.onSpeakerphoneOn(on);
mIsSpeakerphoneOn = on;
// This should run even if speakerphone is not being toggled because we may be switching
// from earpiece to headphone and vise versa. Also upon initial setup the default audio
// source is the earpiece, so we want to trigger the proximity sensor.
if (mIsPlaying) {
if (on || mVoicemailAudioManager.isWiredHeadsetPluggedIn()) {
disableProximitySensor(false /* waitForFarState */);
if (mIsPrepared && mMediaPlayer != null && mMediaPlayer.isPlaying()) {
mActivity.getWindow().addFlags(LayoutParams.FLAG_KEEP_SCREEN_ON);
}
} else {
enableProximitySensor();
if (mActivity != null) {
mActivity.getWindow().clearFlags(LayoutParams.FLAG_KEEP_SCREEN_ON);
}
}
}
}
public void setOnVoicemailDeletedListener(OnVoicemailDeletedListener listener) {
mOnVoicemailDeletedListener = listener;
}
public int getMediaPlayerPosition() {
return mIsPrepared && mMediaPlayer != null ? mMediaPlayer.getCurrentPosition() : 0;
}
public void notifyUiOfArchiveResult(Uri voicemailUri, boolean archived) {
if (mView == null) {
return;
}
if (archived) {
mView.onVoicemailArchiveSucceded(voicemailUri);
} else {
mView.onVoicemailArchiveFailed(voicemailUri);
}
}
/* package */ void onVoicemailDeleted() {
// Trampoline the event notification to the interested listener.
if (mOnVoicemailDeletedListener != null) {
mOnVoicemailDeletedListener.onVoicemailDeleted(mVoicemailUri);
}
}
/* package */ void onVoicemailDeleteUndo() {
// Trampoline the event notification to the interested listener.
if (mOnVoicemailDeletedListener != null) {
mOnVoicemailDeletedListener.onVoicemailDeleteUndo();
}
}
/* package */ void onVoicemailDeletedInDatabase() {
// Trampoline the event notification to the interested listener.
if (mOnVoicemailDeletedListener != null) {
mOnVoicemailDeletedListener.onVoicemailDeletedInDatabase();
}
}
private static synchronized ScheduledExecutorService getScheduledExecutorServiceInstance() {
if (mScheduledExecutorService == null) {
mScheduledExecutorService = Executors.newScheduledThreadPool(NUMBER_OF_THREADS_IN_POOL);
}
return mScheduledExecutorService;
}
/**
* If voicemail has already been downloaded, go straight to archiving. Otherwise, request
* the voicemail content first.
*/
public void archiveContent(final Uri voicemailUri, final boolean archivedByUser) {
checkForContent(new OnContentCheckedListener() {
@Override
public void onContentChecked(boolean hasContent) {
if (!hasContent) {
requestContent(archivedByUser ? ARCHIVE_REQUEST : SHARE_REQUEST);
} else {
startArchiveVoicemailTask(voicemailUri, archivedByUser);
}
}
});
}
/**
* Asynchronous task used to archive a voicemail given its uri.
*/
protected void startArchiveVoicemailTask(final Uri voicemailUri, final boolean archivedByUser) {
mVoicemailAsyncTaskUtil.archiveVoicemailContent(
new VoicemailAsyncTaskUtil.OnArchiveVoicemailListener() {
@Override
public void onArchiveVoicemail(final Uri archivedVoicemailUri) {
if (archivedVoicemailUri == null) {
notifyUiOfArchiveResult(voicemailUri, false);
return;
}
if (archivedByUser) {
setArchivedVoicemailStatusAndUpdateUI(voicemailUri,
archivedVoicemailUri, true);
} else {
sendShareIntent(archivedVoicemailUri);
}
}
}, voicemailUri);
}
/**
* Sends the intent for sharing the voicemail file.
*/
protected void sendShareIntent(final Uri voicemailUri) {
mVoicemailAsyncTaskUtil.getVoicemailFilePath(
new VoicemailAsyncTaskUtil.OnGetArchivedVoicemailFilePathListener() {
@Override
public void onGetArchivedVoicemailFilePath(String filePath) {
mView.enableUiElements();
if (filePath == null) {
mView.setFetchContentTimeout();
return;
}
Uri voicemailFileUri = FileProvider.getUriForFile(
mContext,
mContext.getString(R.string.contacts_file_provider_authority),
new File(filePath));
mContext.startActivity(Intent.createChooser(
getShareIntent(voicemailFileUri),
mContext.getResources().getText(
R.string.call_log_share_voicemail)));
}
}, voicemailUri);
}
/** Sets archived_by_user field to the given boolean and updates the URI. */
private void setArchivedVoicemailStatusAndUpdateUI(
final Uri voicemailUri,
final Uri archivedVoicemailUri,
boolean status) {
mVoicemailAsyncTaskUtil.setVoicemailArchiveStatus(
new VoicemailAsyncTaskUtil.OnSetVoicemailArchiveStatusListener() {
@Override
public void onSetVoicemailArchiveStatus(boolean success) {
notifyUiOfArchiveResult(voicemailUri, success);
}
}, archivedVoicemailUri, status);
}
private Intent getShareIntent(Uri voicemailFileUri) {
Intent shareIntent = new Intent();
shareIntent.setAction(Intent.ACTION_SEND);
shareIntent.putExtra(Intent.EXTRA_STREAM, voicemailFileUri);
shareIntent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
shareIntent.setType(mContext.getContentResolver()
.getType(voicemailFileUri));
return shareIntent;
}
@VisibleForTesting
public boolean isPlaying() {
return mIsPlaying;
}
@VisibleForTesting
public boolean isSpeakerphoneOn() {
return mIsSpeakerphoneOn;
}
@VisibleForTesting
public void clearInstance() {
sInstance = null;
}
}