summaryrefslogtreecommitdiff
path: root/java/com/android/dialer/app/voicemail/VoicemailPlaybackPresenter.java
diff options
context:
space:
mode:
Diffstat (limited to 'java/com/android/dialer/app/voicemail/VoicemailPlaybackPresenter.java')
-rw-r--r--java/com/android/dialer/app/voicemail/VoicemailPlaybackPresenter.java1102
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();
+ }
+ }
+ });
+ }
+ }
+}