diff options
Diffstat (limited to 'java/com/android/dialer/app/voicemail/VoicemailPlaybackPresenter.java')
-rw-r--r-- | java/com/android/dialer/app/voicemail/VoicemailPlaybackPresenter.java | 1102 |
1 files changed, 1102 insertions, 0 deletions
diff --git a/java/com/android/dialer/app/voicemail/VoicemailPlaybackPresenter.java b/java/com/android/dialer/app/voicemail/VoicemailPlaybackPresenter.java new file mode 100644 index 000000000..ea48c8321 --- /dev/null +++ b/java/com/android/dialer/app/voicemail/VoicemailPlaybackPresenter.java @@ -0,0 +1,1102 @@ +/* + * 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.app.voicemail; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.ContentResolver; +import android.content.ContentUris; +import android.content.Context; +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.Build.VERSION_CODES; +import android.os.Bundle; +import android.os.Handler; +import android.os.PowerManager; +import android.provider.CallLog; +import android.provider.VoicemailContract; +import android.provider.VoicemailContract.Voicemails; +import android.support.annotation.MainThread; +import android.support.annotation.Nullable; +import android.support.annotation.VisibleForTesting; +import android.support.v4.content.FileProvider; +import android.text.TextUtils; +import android.util.Pair; +import android.view.View; +import android.view.WindowManager.LayoutParams; +import android.webkit.MimeTypeMap; +import com.android.common.io.MoreCloseables; +import com.android.dialer.app.R; +import com.android.dialer.app.calllog.CallLogListItemViewHolder; +import com.android.dialer.common.Assert; +import com.android.dialer.common.ConfigProviderBindings; +import com.android.dialer.common.LogUtil; +import com.android.dialer.common.concurrent.AsyncTaskExecutor; +import com.android.dialer.common.concurrent.AsyncTaskExecutors; +import com.android.dialer.common.concurrent.DialerExecutor; +import com.android.dialer.common.concurrent.DialerExecutors; +import com.android.dialer.constants.Constants; +import com.android.dialer.logging.DialerImpression; +import com.android.dialer.logging.Logger; +import com.android.dialer.phonenumbercache.CallLogQuery; +import com.android.dialer.util.PermissionsUtil; +import com.google.common.io.ByteStreams; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.Locale; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.ScheduledExecutorService; +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}. + * + * <p>This controls a single {@link com.android.dialer.app.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. + * + * <p>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 +@TargetApi(VERSION_CODES.M) +public class VoicemailPlaybackPresenter + implements MediaPlayer.OnPreparedListener, + MediaPlayer.OnCompletionListener, + MediaPlayer.OnErrorListener { + + public static final int PLAYBACK_REQUEST = 0; + 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"; + private static final String VOICEMAIL_SHARE_FILE_NAME_DATE_FORMAT = "MM-dd-yy_hhmmaa"; + private static final String CONFIG_SHARE_VOICEMAIL_ALLOWED = "share_voicemail_allowed"; + + private static VoicemailPlaybackPresenter sInstance; + private static ScheduledExecutorService mScheduledExecutorService; + /** + * 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); + + protected Context mContext; + private long mRowId; + protected Uri mVoicemailUri; + protected MediaPlayer mMediaPlayer; + // Used to run async tasks that need to interact with the UI. + protected AsyncTaskExecutor mAsyncTaskExecutor; + private Activity mActivity; + private PlaybackView mView; + 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; + /** + * Used to handle the result of a successful or time-out fetch result. + * + * <p>This variable is thread-contained, accessed only on the ui thread. + */ + private FetchResultHandler mFetchResultHandler; + + private PowerManager.WakeLock mProximityWakeLock; + private VoicemailAudioManager mVoicemailAudioManager; + private OnVoicemailDeletedListener mOnVoicemailDeletedListener; + private View shareVoicemailButtonView; + + private DialerExecutor<Pair<Context, Uri>> shareVoicemailExecutor; + + /** Initialize variables which are activity-independent and state-independent. */ + protected VoicemailPlaybackPresenter(Activity activity) { + Context context = activity.getApplicationContext(); + mAsyncTaskExecutor = AsyncTaskExecutors.createAsyncTaskExecutor(); + mVoicemailAudioManager = new VoicemailAudioManager(context, this); + PowerManager powerManager = (PowerManager) context.getSystemService(Context.POWER_SERVICE); + if (powerManager.isWakeLockLevelSupported(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK)) { + mProximityWakeLock = + powerManager.newWakeLock( + PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, "VoicemailPlaybackPresenter"); + } + } + + /** + * Obtain singleton instance of this class. Use a single instance to provide a consistent listener + * to the AudioManager when requesting and abandoning audio focus. + * + * <p>Otherwise, after rotation the previous listener will still be active but a new listener will + * be provided to calls to the AudioManager, which is bad. For example, abandoning audio focus + * with the new listeners results in an AUDIO_FOCUS_GAIN callback to the previous listener, which + * is the opposite of the intended behavior. + */ + @MainThread + public static VoicemailPlaybackPresenter getInstance( + Activity activity, Bundle savedInstanceState) { + if (sInstance == null) { + sInstance = new VoicemailPlaybackPresenter(activity); + } + + sInstance.init(activity, savedInstanceState); + return sInstance; + } + + private static synchronized ScheduledExecutorService getScheduledExecutorServiceInstance() { + if (mScheduledExecutorService == null) { + mScheduledExecutorService = Executors.newScheduledThreadPool(NUMBER_OF_THREADS_IN_POOL); + } + return mScheduledExecutorService; + } + + /** Update variables which are activity-dependent or state-dependent. */ + @MainThread + protected void init(Activity activity, Bundle savedInstanceState) { + Assert.isMainThread(); + mActivity = activity; + mContext = activity; + + if (savedInstanceState != null) { + // Restores playback state when activity is recreated, such as after rotation. + mVoicemailUri = savedInstanceState.getParcelable(VOICEMAIL_URI_KEY); + mIsPrepared = savedInstanceState.getBoolean(IS_PREPARED_KEY); + mPosition = savedInstanceState.getInt(CLIP_POSITION_KEY, 0); + mIsPlaying = savedInstanceState.getBoolean(IS_PLAYING_STATE_KEY, false); + mIsSpeakerphoneOn = savedInstanceState.getBoolean(IS_SPEAKERPHONE_ON_KEY, false); + } + + if (mMediaPlayer == null) { + mIsPrepared = false; + mIsPlaying = false; + } + + if (mActivity != null) { + if (isPlaying()) { + mActivity.getWindow().addFlags(LayoutParams.FLAG_KEEP_SCREEN_ON); + } else { + mActivity.getWindow().clearFlags(LayoutParams.FLAG_KEEP_SCREEN_ON); + } + shareVoicemailExecutor = + DialerExecutors.createUiTaskBuilder( + mActivity.getFragmentManager(), "test", new ShareVoicemailWorker()) + .onSuccess( + output -> { + if (output == null) { + LogUtil.e("VoicemailAsyncTaskUtil.shareVoicemail", "failed to get voicemail"); + return; + } + mContext.startActivity( + Intent.createChooser( + getShareIntent(mContext, output.first, output.second), + mContext + .getResources() + .getText(R.string.call_log_action_share_voicemail))); + }) + .build(); + } + } + + /** Must be invoked when the parent Activity is saving it state. */ + public void onSaveInstanceState(Bundle outState) { + if (mView != null) { + outState.putParcelable(VOICEMAIL_URI_KEY, mVoicemailUri); + outState.putBoolean(IS_PREPARED_KEY, mIsPrepared); + outState.putInt(CLIP_POSITION_KEY, mView.getDesiredClipPosition()); + outState.putBoolean(IS_PLAYING_STATE_KEY, mIsPlaying); + outState.putBoolean(IS_SPEAKERPHONE_ON_KEY, mIsSpeakerphoneOn); + } + } + + /** Specify the view which this presenter controls and the voicemail to prepare to play. */ + public void setPlaybackView( + PlaybackView view, + long rowId, + Uri voicemailUri, + final boolean startPlayingImmediately, + View shareVoicemailButtonView) { + mRowId = rowId; + mView = view; + mView.setPresenter(this, voicemailUri); + mView.onSpeakerphoneOn(mIsSpeakerphoneOn); + this.shareVoicemailButtonView = shareVoicemailButtonView; + showShareVoicemailButton(false); + + // Handles cases where the same entry is binded again when scrolling in list, or where + // the MediaPlayer was retained after an orientation change. + if (mMediaPlayer != null && mIsPrepared && voicemailUri.equals(mVoicemailUri)) { + // If the voicemail card was rebinded, we need to set the position to the appropriate + // point. Since we retain the media player, we can just set it to the position of the + // media player. + mPosition = mMediaPlayer.getCurrentPosition(); + onPrepared(mMediaPlayer); + showShareVoicemailButton(true); + } else { + if (!voicemailUri.equals(mVoicemailUri)) { + mVoicemailUri = voicemailUri; + mPosition = 0; + } + /* + * Check to see if the content field in the DB is set. If set, we proceed to + * prepareContent() method. We get the duration of the voicemail from the query and set + * it if the content is not available. + */ + checkForContent( + hasContent -> { + if (hasContent) { + showShareVoicemailButton(true); + prepareContent(); + } else { + if (startPlayingImmediately) { + requestContent(PLAYBACK_REQUEST); + } + if (mView != null) { + mView.resetSeekBar(); + mView.setClipPosition(0, mDuration.get()); + } + } + }); + + if (startPlayingImmediately) { + // Since setPlaybackView can get called during the view binding process, we don't + // want to reset mIsPlaying to false if the user is currently playing the + // voicemail and the view is rebound. + mIsPlaying = startPlayingImmediately; + } + } + } + + /** Reset the presenter for playback back to its original state. */ + public void resetAll() { + pausePresenter(true); + + mView = null; + mVoicemailUri = null; + } + + /** + * When navigating away from voicemail playback, we need to release the media player, pause the UI + * and save the position. + * + * @param reset {@code true} if we want to reset the position of the playback, {@code false} if we + * want to retain the current position (in case we return to the voicemail). + */ + public void pausePresenter(boolean reset) { + pausePlayback(); + if (mMediaPlayer != null) { + mMediaPlayer.release(); + mMediaPlayer = null; + } + + disableProximitySensor(false /* waitForFarState */); + + mIsPrepared = false; + mIsPlaying = false; + + if (reset) { + // We want to reset the position whether or not the view is valid. + mPosition = 0; + } + + if (mView != null) { + mView.onPlaybackStopped(); + if (reset) { + mView.setClipPosition(0, mDuration.get()); + } else { + mPosition = mView.getDesiredClipPosition(); + } + } + } + + /** Must be invoked when the parent activity is resumed. */ + public void onResume() { + mVoicemailAudioManager.registerReceivers(); + } + + /** Must be invoked when the parent activity is paused. */ + public void onPause() { + mVoicemailAudioManager.unregisterReceivers(); + + if (mActivity != null && mIsPrepared && mActivity.isChangingConfigurations()) { + // If an configuration change triggers the pause, retain the MediaPlayer. + LogUtil.d("VoicemailPlaybackPresenter.onPause", "configuration changed."); + return; + } + + // Release the media player, otherwise there may be failures. + pausePresenter(false); + } + + /** Must be invoked when the parent activity is destroyed. */ + public void onDestroy() { + // Clear references to avoid leaks from the singleton instance. + mActivity = null; + mContext = null; + + if (mScheduledExecutorService != null) { + mScheduledExecutorService.shutdown(); + mScheduledExecutorService = null; + } + + if (mFetchResultHandler != null) { + mFetchResultHandler.destroy(); + mFetchResultHandler = null; + } + } + + /** Checks to see if we have content available for this voicemail. */ + protected void checkForContent(final OnContentCheckedListener callback) { + mAsyncTaskExecutor.submit( + Tasks.CHECK_FOR_CONTENT, + new AsyncTask<Void, Void, Boolean>() { + @Override + public Boolean doInBackground(Void... params) { + return queryHasContent(mVoicemailUri); + } + + @Override + public void onPostExecute(Boolean hasContent) { + callback.onContentChecked(hasContent); + } + }); + } + + private boolean queryHasContent(Uri voicemailUri) { + if (voicemailUri == null || mContext == null) { + return false; + } + + ContentResolver contentResolver = mContext.getContentResolver(); + Cursor cursor = contentResolver.query(voicemailUri, null, null, null, null); + try { + if (cursor != null && cursor.moveToNext()) { + int duration = cursor.getInt(cursor.getColumnIndex(VoicemailContract.Voicemails.DURATION)); + // Convert database duration (seconds) into mDuration (milliseconds) + mDuration.set(duration > 0 ? duration * 1000 : 0); + return cursor.getInt(cursor.getColumnIndex(VoicemailContract.Voicemails.HAS_CONTENT)) == 1; + } + } finally { + MoreCloseables.closeQuietly(cursor); + } + return false; + } + + /** + * Makes a broadcast request to ask that a voicemail source fetch this content. + * + * <p>This method <b>must be called on the ui thread</b>. + * + * <p>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) { + default: + if (mFetchResultHandler != null) { + mFetchResultHandler.destroy(); + } + mView.setIsFetchingContent(); + mFetchResultHandler = tempFetchResultHandler; + break; + } + + mAsyncTaskExecutor.submit( + Tasks.SEND_FETCH_REQUEST, + new AsyncTask<Void, Void, Void>() { + + @Override + protected Void doInBackground(Void... voids) { + try (Cursor cursor = + mContext + .getContentResolver() + .query( + mVoicemailUri, + new String[] {Voicemails.SOURCE_PACKAGE}, + null, + null, + null)) { + String sourcePackage; + if (!hasContent(cursor)) { + LogUtil.e( + "VoicemailPlaybackPresenter.requestContent", + "mVoicemailUri does not return a SOURCE_PACKAGE"); + sourcePackage = null; + } else { + sourcePackage = cursor.getString(0); + } + // Send voicemail fetch request. + Intent intent = new Intent(VoicemailContract.ACTION_FETCH_VOICEMAIL, mVoicemailUri); + intent.setPackage(sourcePackage); + LogUtil.i( + "VoicemailPlaybackPresenter.requestContent", + "Sending ACTION_FETCH_VOICEMAIL to " + sourcePackage); + mContext.sendBroadcast(intent); + } + return null; + } + }); + return true; + } + + /** + * Prepares the voicemail content for playback. + * + * <p>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; + } + LogUtil.d("VoicemailPlaybackPresenter.prepareContent", null); + + // 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 || mContext == null) { + return; + } + LogUtil.d("VoicemailPlaybackPresenter.onPrepared", null); + mIsPrepared = true; + + mDuration.set(mMediaPlayer.getDuration()); + + LogUtil.d("VoicemailPlaybackPresenter.onPrepared", "mPosition=" + mPosition); + mView.setClipPosition(mPosition, mDuration.get()); + mView.enableUiElements(); + mView.setSuccess(); + 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) { + LogUtil.e("VoicemailPlaybackPresenter.handlerError", "could not play voicemail", e); + + if (mIsPrepared) { + mMediaPlayer.release(); + mMediaPlayer = null; + mIsPrepared = false; + } + + if (mView != null) { + mView.onPlaybackError(); + } + + mPosition = 0; + mIsPlaying = false; + showShareVoicemailButton(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) { + mediaPlayer.seekTo(0); + 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. Audio focus gain/lost only triggers the focus is + * requested. Audio focus is requested when the user pressed play and abandoned when the user + * pressed pause or the audio has finished. Losing focus should not abandon focus as the voicemail + * should resume once the focus is returned. + * + * @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 (gainedFocus) { + resumePlayback(); + } else { + pausePlayback(true); + } + } + + /** + * 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( + hasContent -> { + if (!hasContent) { + // No local content, download from server. Queue playing if the request was + // issued, + mIsPlaying = requestContent(PLAYBACK_REQUEST); + } else { + showShareVoicemailButton(true); + // Queue playing once the media play loaded the content. + mIsPlaying = true; + prepareContent(); + } + }); + return; + } + + mIsPlaying = true; + + mActivity.getWindow().addFlags(LayoutParams.FLAG_KEEP_SCREEN_ON); + + 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); + mVoicemailAudioManager.setSpeakerphoneOn(mIsSpeakerphoneOn); + } catch (RejectedExecutionException e) { + handleError(e); + } + } + + LogUtil.d("VoicemailPlaybackPresenter.resumePlayback", "resumed playback at %d.", mPosition); + mView.onPlaybackStarted(mDuration.get(), getScheduledExecutorServiceInstance()); + } + + /** Pauses voicemail playback at the current position. Null-op if already paused. */ + public void pausePlayback() { + pausePlayback(false); + } + + private void pausePlayback(boolean keepFocus) { + if (!mIsPrepared) { + return; + } + + mIsPlaying = false; + + if (mMediaPlayer != null && mMediaPlayer.isPlaying()) { + mMediaPlayer.pause(); + } + + mPosition = mMediaPlayer == null ? 0 : mMediaPlayer.getCurrentPosition(); + + LogUtil.d("VoicemailPlaybackPresenter.pausePlayback", "paused playback at %d.", mPosition); + + if (mView != null) { + mView.onPlaybackStopped(); + } + + if (!keepFocus) { + 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(true); + } + + 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. + */ + public void seek(int position) { + mPosition = position; + mMediaPlayer.seekTo(mPosition); + } + + private void enableProximitySensor() { + if (mProximityWakeLock == null + || mIsSpeakerphoneOn + || !mIsPrepared + || mMediaPlayer == null + || !mMediaPlayer.isPlaying()) { + return; + } + + if (!mProximityWakeLock.isHeld()) { + LogUtil.i( + "VoicemailPlaybackPresenter.enableProximitySensor", "acquiring proximity wake lock"); + mProximityWakeLock.acquire(); + } else { + LogUtil.i( + "VoicemailPlaybackPresenter.enableProximitySensor", + "proximity wake lock already acquired"); + } + } + + private void disableProximitySensor(boolean waitForFarState) { + if (mProximityWakeLock == null) { + return; + } + if (mProximityWakeLock.isHeld()) { + LogUtil.i( + "VoicemailPlaybackPresenter.disableProximitySensor", "releasing proximity wake lock"); + int flags = waitForFarState ? PowerManager.RELEASE_FLAG_WAIT_FOR_NO_PROXIMITY : 0; + mProximityWakeLock.release(flags); + } else { + LogUtil.i( + "VoicemailPlaybackPresenter.disableProximitySensor", + "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); + } + + public void setOnVoicemailDeletedListener(OnVoicemailDeletedListener listener) { + mOnVoicemailDeletedListener = listener; + } + + public int getMediaPlayerPosition() { + return mIsPrepared && mMediaPlayer != null ? mMediaPlayer.getCurrentPosition() : 0; + } + + void onVoicemailDeleted(CallLogListItemViewHolder viewHolder) { + if (mOnVoicemailDeletedListener != null) { + mOnVoicemailDeletedListener.onVoicemailDeleted(viewHolder, mVoicemailUri); + } + } + + void onVoicemailDeleteUndo(int adapterPosition) { + if (mOnVoicemailDeletedListener != null) { + mOnVoicemailDeletedListener.onVoicemailDeleteUndo(mRowId, adapterPosition, mVoicemailUri); + } + } + + void onVoicemailDeletedInDatabase() { + if (mOnVoicemailDeletedListener != null) { + mOnVoicemailDeletedListener.onVoicemailDeletedInDatabase(mRowId, mVoicemailUri); + } + } + + @VisibleForTesting + public boolean isPlaying() { + return mIsPlaying; + } + + @VisibleForTesting + public boolean isSpeakerphoneOn() { + return 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 */); + } else { + enableProximitySensor(); + } + } + } + + @VisibleForTesting + public void clearInstance() { + sInstance = null; + } + + private void showShareVoicemailButton(boolean show) { + if (mContext == null) { + return; + } + if (isShareVoicemailAllowed(mContext) && shareVoicemailButtonView != null) { + if (show) { + Logger.get(mContext).logImpression(DialerImpression.Type.VVM_SHARE_VISIBLE); + } + LogUtil.d("VoicemailPlaybackPresenter.showShareVoicemailButton", "show: %b", show); + shareVoicemailButtonView.setVisibility(show ? View.VISIBLE : View.GONE); + } + } + + private static boolean isShareVoicemailAllowed(Context context) { + return ConfigProviderBindings.get(context).getBoolean(CONFIG_SHARE_VOICEMAIL_ALLOWED, true); + } + + private static class ShareVoicemailWorker + implements DialerExecutor.Worker<Pair<Context, Uri>, Pair<Uri, String>> { + + @Nullable + @Override + public Pair<Uri, String> doInBackground(Pair<Context, Uri> input) { + Context context = input.first; + Uri voicemailUri = input.second; + ContentResolver contentResolver = context.getContentResolver(); + try (Cursor callLogInfo = getCallLogInfoCursor(contentResolver, voicemailUri); + Cursor contentInfo = getContentInfoCursor(contentResolver, voicemailUri)) { + + if (hasContent(callLogInfo) && hasContent(contentInfo)) { + String cachedName = callLogInfo.getString(CallLogQuery.CACHED_NAME); + String number = contentInfo.getString(contentInfo.getColumnIndex(Voicemails.NUMBER)); + long date = contentInfo.getLong(contentInfo.getColumnIndex(Voicemails.DATE)); + String mimeType = contentInfo.getString(contentInfo.getColumnIndex(Voicemails.MIME_TYPE)); + String transcription = + contentInfo.getString(contentInfo.getColumnIndex(Voicemails.TRANSCRIPTION)); + + // Copy voicemail content to a new file. + // Please see reference in third_party/java_src/android_app/dialer/java/com/android/ + // dialer/app/res/xml/file_paths.xml for correct cache directory name. + File parentDir = new File(context.getCacheDir(), "my_cache"); + if (!parentDir.exists()) { + parentDir.mkdirs(); + } + File temporaryVoicemailFile = + new File(parentDir, getFileName(cachedName, number, mimeType, date)); + + try (InputStream inputStream = contentResolver.openInputStream(voicemailUri); + OutputStream outputStream = + contentResolver.openOutputStream(Uri.fromFile(temporaryVoicemailFile))) { + if (inputStream != null && outputStream != null) { + ByteStreams.copy(inputStream, outputStream); + return new Pair<>( + FileProvider.getUriForFile( + context, Constants.get().getFileProviderAuthority(), temporaryVoicemailFile), + transcription); + } + } catch (IOException e) { + LogUtil.e( + "VoicemailAsyncTaskUtil.shareVoicemail", + "failed to copy voicemail content to new file: ", + e); + } + return null; + } + } + return null; + } + } + + /** + * Share voicemail to be opened by user selected apps. This method will collect information, copy + * voicemail to a temporary file in background and launch a chooser intent to share it. + */ + public void shareVoicemail() { + shareVoicemailExecutor.executeParallel(new Pair<>(mContext, mVoicemailUri)); + } + + private static String getFileName(String cachedName, String number, String mimeType, long date) { + String callerName = TextUtils.isEmpty(cachedName) ? number : cachedName; + SimpleDateFormat simpleDateFormat = + new SimpleDateFormat(VOICEMAIL_SHARE_FILE_NAME_DATE_FORMAT, Locale.getDefault()); + + String fileExtension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType); + + return callerName + + "_" + + simpleDateFormat.format(new Date(date)) + + (TextUtils.isEmpty(fileExtension) ? "" : "." + fileExtension); + } + + private static Intent getShareIntent( + Context context, Uri voicemailFileUri, String transcription) { + Intent shareIntent = new Intent(); + if (TextUtils.isEmpty(transcription)) { + shareIntent.setAction(Intent.ACTION_SEND); + shareIntent.putExtra(Intent.EXTRA_STREAM, voicemailFileUri); + shareIntent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + shareIntent.setType(context.getContentResolver().getType(voicemailFileUri)); + } else { + shareIntent.setAction(Intent.ACTION_SEND); + shareIntent.putExtra(Intent.EXTRA_STREAM, voicemailFileUri); + shareIntent.putExtra(Intent.EXTRA_TEXT, transcription); + shareIntent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + shareIntent.setType("*/*"); + } + + return shareIntent; + } + + private static boolean hasContent(@Nullable Cursor cursor) { + return cursor != null && cursor.moveToFirst(); + } + + @Nullable + private static Cursor getCallLogInfoCursor(ContentResolver contentResolver, Uri voicemailUri) { + return contentResolver.query( + ContentUris.withAppendedId( + CallLog.Calls.CONTENT_URI_WITH_VOICEMAIL, ContentUris.parseId(voicemailUri)), + CallLogQuery.getProjection(), + null, + null, + null); + } + + @Nullable + private static Cursor getContentInfoCursor(ContentResolver contentResolver, Uri voicemailUri) { + return contentResolver.query( + voicemailUri, + new String[] { + Voicemails._ID, + Voicemails.NUMBER, + Voicemails.DATE, + Voicemails.MIME_TYPE, + Voicemails.TRANSCRIPTION, + }, + null, + null, + null); + } + + /** The enumeration of {@link AsyncTask} objects we use in this class. */ + public enum Tasks { + CHECK_FOR_CONTENT, + CHECK_CONTENT_AFTER_CHANGE, + SHARE_VOICEMAIL, + SEND_FETCH_REQUEST + } + + /** 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 setSuccess(); + + void setFetchContentTimeout(); + + void setIsFetchingContent(); + + void setPresenter(VoicemailPlaybackPresenter presenter, Uri voicemailUri); + + void resetSeekBar(); + } + + public interface OnVoicemailDeletedListener { + + void onVoicemailDeleted(CallLogListItemViewHolder viewHolder, Uri uri); + + void onVoicemailDeleteUndo(long rowId, int adaptorPosition, Uri uri); + + void onVoicemailDeletedInDatabase(long rowId, Uri uri); + } + + protected interface OnContentCheckedListener { + + void onContentChecked(boolean hasContent); + } + + @ThreadSafe + private class FetchResultHandler extends ContentObserver implements Runnable { + + private final Handler mFetchResultHandler; + private final Uri mVoicemailUri; + private AtomicBoolean mIsWaitingForResult = new AtomicBoolean(true); + + public FetchResultHandler(Handler handler, Uri uri, int code) { + super(handler); + mFetchResultHandler = handler; + mVoicemailUri = uri; + if (mContext != null) { + if (PermissionsUtil.hasReadVoicemailPermissions(mContext)) { + 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<Void, Void, Boolean>() { + + @Override + public Boolean doInBackground(Void... params) { + return queryHasContent(mVoicemailUri); + } + + @Override + public void onPostExecute(Boolean hasContent) { + if (hasContent && mContext != null && mIsWaitingForResult.getAndSet(false)) { + mContext.getContentResolver().unregisterContentObserver(FetchResultHandler.this); + showShareVoicemailButton(true); + prepareContent(); + } + } + }); + } + } +} |