/* * 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.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.AudioManager; 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.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.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.DialerExecutorComponent; import com.android.dialer.configprovider.ConfigProviderComponent; 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.strictmode.StrictModeUtils; import com.android.dialer.telecom.TelecomUtil; 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}. * *
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. * *
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 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 instance; private static ScheduledExecutorService scheduledExecutorService; /** * 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 duration = new AtomicInteger(0); protected Context context; private long rowId; protected Uri voicemailUri; protected MediaPlayer mediaPlayer; // Used to run async tasks that need to interact with the UI. protected AsyncTaskExecutor asyncTaskExecutor; private Activity activity; private PlaybackView view; private int position; private boolean isPlaying; // 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 isPrepared; private boolean isSpeakerphoneOn; private boolean shouldResumePlaybackAfterSeeking; /** * 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 fetchResultHandler;
private PowerManager.WakeLock proximityWakeLock;
private VoicemailAudioManager voicemailAudioManager;
private OnVoicemailDeletedListener onVoicemailDeletedListener;
private View shareVoicemailButtonView;
private DialerExecutor 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 (instance == null) {
instance = new VoicemailPlaybackPresenter(activity);
}
instance.init(activity, savedInstanceState);
return instance;
}
private static synchronized ScheduledExecutorService getScheduledExecutorServiceInstance() {
if (scheduledExecutorService == null) {
scheduledExecutorService = Executors.newScheduledThreadPool(NUMBER_OF_THREADS_IN_POOL);
}
return scheduledExecutorService;
}
/** Update variables which are activity-dependent or state-dependent. */
@MainThread
protected void init(Activity activity, Bundle savedInstanceState) {
Assert.isMainThread();
this.activity = activity;
context = activity;
if (savedInstanceState != null) {
// Restores playback state when activity is recreated, such as after rotation.
voicemailUri = savedInstanceState.getParcelable(VOICEMAIL_URI_KEY);
isPrepared = savedInstanceState.getBoolean(IS_PREPARED_KEY);
position = savedInstanceState.getInt(CLIP_POSITION_KEY, 0);
isPlaying = savedInstanceState.getBoolean(IS_PLAYING_STATE_KEY, false);
isSpeakerphoneOn = savedInstanceState.getBoolean(IS_SPEAKERPHONE_ON_KEY, false);
AudioManager audioManager = activity.getSystemService(AudioManager.class);
audioManager.setSpeakerphoneOn(isSpeakerphoneOn);
}
if (mediaPlayer == null) {
isPrepared = false;
isPlaying = false;
}
if (this.activity != null) {
if (isPlaying()) {
this.activity.getWindow().addFlags(LayoutParams.FLAG_KEEP_SCREEN_ON);
} else {
this.activity.getWindow().clearFlags(LayoutParams.FLAG_KEEP_SCREEN_ON);
}
shareVoicemailExecutor =
DialerExecutorComponent.get(context)
.dialerExecutorFactory()
.createUiTaskBuilder(
this.activity.getFragmentManager(), "shareVoicemail", new ShareVoicemailWorker())
.onSuccess(
output -> {
if (output == null) {
LogUtil.e("VoicemailAsyncTaskUtil.shareVoicemail", "failed to get voicemail");
return;
}
context.startActivity(
Intent.createChooser(
getShareIntent(context, output.first, output.second),
context
.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 (view != null) {
outState.putParcelable(VOICEMAIL_URI_KEY, voicemailUri);
outState.putBoolean(IS_PREPARED_KEY, isPrepared);
outState.putInt(CLIP_POSITION_KEY, view.getDesiredClipPosition());
outState.putBoolean(IS_PLAYING_STATE_KEY, isPlaying);
outState.putBoolean(IS_SPEAKERPHONE_ON_KEY, isSpeakerphoneOn);
}
}
/** 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) {
this.rowId = rowId;
this.view = view;
this.view.setPresenter(this, voicemailUri);
this.view.onSpeakerphoneOn(isSpeakerphoneOn);
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 (mediaPlayer != null && isPrepared && voicemailUri.equals(this.voicemailUri)) {
// 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.
position = mediaPlayer.getCurrentPosition();
onPrepared(mediaPlayer);
showShareVoicemailButton(true);
} else {
if (!voicemailUri.equals(this.voicemailUri)) {
this.voicemailUri = voicemailUri;
position = 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 (this.view != null) {
this.view.resetSeekBar();
this.view.setClipPosition(0, duration.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.
isPlaying = startPlayingImmediately;
}
}
}
/** Reset the presenter for playback back to its original state. */
public void resetAll() {
pausePresenter(true);
view = null;
voicemailUri = 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 (mediaPlayer != null) {
mediaPlayer.release();
mediaPlayer = null;
}
disableProximitySensor(false /* waitForFarState */);
isPrepared = false;
isPlaying = false;
if (reset) {
// We want to reset the position whether or not the view is valid.
position = 0;
}
if (view != null) {
view.onPlaybackStopped();
if (reset) {
view.setClipPosition(0, duration.get());
} else {
position = view.getDesiredClipPosition();
}
}
}
/** Must be invoked when the parent activity is resumed. */
public void onResume() {
voicemailAudioManager.registerReceivers();
}
/** Must be invoked when the parent activity is paused. */
public void onPause() {
voicemailAudioManager.unregisterReceivers();
if (activity != null && isPrepared && activity.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.
activity = null;
context = null;
if (scheduledExecutorService != null) {
scheduledExecutorService.shutdown();
scheduledExecutorService = null;
}
if (fetchResultHandler != null) {
fetchResultHandler.destroy();
fetchResultHandler = null;
}
}
/** Checks to see if we have content available for this voicemail. */
protected void checkForContent(final OnContentCheckedListener callback) {
asyncTaskExecutor.submit(
Tasks.CHECK_FOR_CONTENT,
new AsyncTask 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 (context == null || voicemailUri == null) {
return false;
}
FetchResultHandler tempFetchResultHandler =
new FetchResultHandler(new Handler(), voicemailUri, code);
switch (code) {
default:
if (fetchResultHandler != null) {
fetchResultHandler.destroy();
}
view.setIsFetchingContent();
fetchResultHandler = tempFetchResultHandler;
break;
}
asyncTaskExecutor.submit(
Tasks.SEND_FETCH_REQUEST,
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 (view == null || context == null) {
return;
}
LogUtil.d("VoicemailPlaybackPresenter.prepareContent", null);
// Release the previous media player, otherwise there may be failures.
if (mediaPlayer != null) {
mediaPlayer.release();
mediaPlayer = null;
}
view.disableUiElements();
isPrepared = false;
if (context != null && TelecomUtil.isInManagedCall(context)) {
handleError(new IllegalStateException("Cannot play voicemail when call is in progress"));
return;
}
StrictModeUtils.bypass(this::prepareMediaPlayer);
}
private void prepareMediaPlayer() {
try {
mediaPlayer = new MediaPlayer();
mediaPlayer.setOnPreparedListener(this);
mediaPlayer.setOnErrorListener(this);
mediaPlayer.setOnCompletionListener(this);
mediaPlayer.reset();
mediaPlayer.setDataSource(context, voicemailUri);
mediaPlayer.setAudioStreamType(VoicemailAudioManager.PLAYBACK_STREAM);
mediaPlayer.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 (view == null || context == null) {
return;
}
LogUtil.d("VoicemailPlaybackPresenter.onPrepared", null);
isPrepared = true;
duration.set(mediaPlayer.getDuration());
LogUtil.d("VoicemailPlaybackPresenter.onPrepared", "mPosition=" + position);
view.setClipPosition(position, duration.get());
view.enableUiElements();
view.setSuccess();
if (!mp.isPlaying()) {
mediaPlayer.seekTo(position);
}
if (isPlaying) {
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 (isPrepared) {
mediaPlayer.release();
mediaPlayer = null;
isPrepared = false;
}
if (view != null) {
view.onPlaybackError();
}
position = 0;
isPlaying = 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.
position = 0;
if (view != null) {
mediaPlayer.seekTo(0);
view.setClipPosition(0, duration.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 (isPlaying == 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 (view == null) {
return;
}
if (!isPrepared) {
/*
* 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,
isPlaying = requestContent(PLAYBACK_REQUEST);
} else {
showShareVoicemailButton(true);
// Queue playing once the media play loaded the content.
isPlaying = true;
prepareContent();
}
});
return;
}
isPlaying = true;
activity.getWindow().addFlags(LayoutParams.FLAG_KEEP_SCREEN_ON);
if (mediaPlayer != null && !mediaPlayer.isPlaying()) {
// Clamp the start position between 0 and the duration.
position = Math.max(0, Math.min(position, duration.get()));
mediaPlayer.seekTo(position);
try {
// Grab audio focus.
// Can throw RejectedExecutionException.
voicemailAudioManager.requestAudioFocus();
mediaPlayer.start();
setSpeakerphoneOn(isSpeakerphoneOn);
voicemailAudioManager.setSpeakerphoneOn(isSpeakerphoneOn);
} catch (RejectedExecutionException e) {
handleError(e);
}
}
LogUtil.d("VoicemailPlaybackPresenter.resumePlayback", "resumed playback at %d.", position);
view.onPlaybackStarted(duration.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 (!isPrepared) {
return;
}
isPlaying = false;
if (mediaPlayer != null && mediaPlayer.isPlaying()) {
mediaPlayer.pause();
}
position = mediaPlayer == null ? 0 : mediaPlayer.getCurrentPosition();
LogUtil.d("VoicemailPlaybackPresenter.pausePlayback", "paused playback at %d.", position);
if (view != null) {
view.onPlaybackStopped();
}
if (!keepFocus) {
voicemailAudioManager.abandonAudioFocus();
}
if (activity != null) {
activity.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 (mediaPlayer != null) {
shouldResumePlaybackAfterSeeking = mediaPlayer.isPlaying();
}
pausePlayback(true);
}
public void resumePlaybackAfterSeeking(int desiredPosition) {
position = desiredPosition;
if (shouldResumePlaybackAfterSeeking) {
shouldResumePlaybackAfterSeeking = 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) {
this.position = position;
mediaPlayer.seekTo(this.position);
}
private void enableProximitySensor() {
if (proximityWakeLock == null
|| isSpeakerphoneOn
|| !isPrepared
|| mediaPlayer == null
|| !mediaPlayer.isPlaying()) {
return;
}
if (!proximityWakeLock.isHeld()) {
LogUtil.i(
"VoicemailPlaybackPresenter.enableProximitySensor", "acquiring proximity wake lock");
proximityWakeLock.acquire();
} else {
LogUtil.i(
"VoicemailPlaybackPresenter.enableProximitySensor",
"proximity wake lock already acquired");
}
}
private void disableProximitySensor(boolean waitForFarState) {
if (proximityWakeLock == null) {
return;
}
if (proximityWakeLock.isHeld()) {
LogUtil.i(
"VoicemailPlaybackPresenter.disableProximitySensor", "releasing proximity wake lock");
int flags = waitForFarState ? PowerManager.RELEASE_FLAG_WAIT_FOR_NO_PROXIMITY : 0;
proximityWakeLock.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() {
voicemailAudioManager.setSpeakerphoneOn(!isSpeakerphoneOn);
setSpeakerphoneOn(!isSpeakerphoneOn);
}
public void setOnVoicemailDeletedListener(OnVoicemailDeletedListener listener) {
onVoicemailDeletedListener = listener;
}
public int getMediaPlayerPosition() {
return isPrepared && mediaPlayer != null ? mediaPlayer.getCurrentPosition() : 0;
}
void onVoicemailDeleted(CallLogListItemViewHolder viewHolder) {
if (onVoicemailDeletedListener != null) {
onVoicemailDeletedListener.onVoicemailDeleted(viewHolder, voicemailUri);
}
}
void onVoicemailDeleteUndo(int adapterPosition) {
if (onVoicemailDeletedListener != null) {
onVoicemailDeletedListener.onVoicemailDeleteUndo(rowId, adapterPosition, voicemailUri);
}
}
void onVoicemailDeletedInDatabase() {
if (onVoicemailDeletedListener != null) {
onVoicemailDeletedListener.onVoicemailDeletedInDatabase(rowId, voicemailUri);
}
}
@VisibleForTesting
public boolean isPlaying() {
return isPlaying;
}
@VisibleForTesting
public boolean isSpeakerphoneOn() {
return isSpeakerphoneOn;
}
/**
* 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 (view == null) {
return;
}
view.onSpeakerphoneOn(on);
isSpeakerphoneOn = 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 (isPlaying) {
if (on || voicemailAudioManager.isWiredHeadsetPluggedIn()) {
disableProximitySensor(false /* waitForFarState */);
} else {
enableProximitySensor();
}
}
}
@VisibleForTesting
public void clearInstance() {
instance = null;
}
private void showShareVoicemailButton(boolean show) {
if (context == null) {
return;
}
if (isShareVoicemailAllowed(context) && shareVoicemailButtonView != null) {
if (show) {
Logger.get(context).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 ConfigProviderComponent.get(context)
.getConfigProvider()
.getBoolean(CONFIG_SHARE_VOICEMAIL_ALLOWED, true);
}
private static class ShareVoicemailWorker
implements DialerExecutor.Worker