Fetching content via intent, timeout on ui.
- Adding a new state, fetching the content.
- Set ui in fetching content state whilst we check has_content field.
- If content not available, move to make request for content state.
- If fetching content fails within a timeout, show this on the ui.
- If content is available, move to buffering state as before.
- Disable ui during buffering and fetching content states.
- Re-enable ui elements once successfully prepared.
- Add speakerphone to list of ui elements to be disabled on error.
Other:
- Makes inner fragment class static, to prevent possible NPE when
accessing the Activity via getActivity().
- Makes use of mApplicationContext where it makes sense, rather than
using the Activity directly.
Bug: 5059965
Bug: 5114261
Change-Id: Id2fee5e279fb02688198a1d6b602555f7a450450
diff --git a/res/values/strings.xml b/res/values/strings.xml
index b7a1868..c7a2bc7 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -1635,6 +1635,12 @@
<!-- Message to display before we have prepared the media player, i.e. before we know duration. [CHAR LIMIT=40] -->
<string name="voicemail_buffering">buffering...</string>
+ <!-- Message to display whilst we are waiting for the content to be fetched. [CHAR LIMIT=40] -->
+ <string name="voicemail_fetching_content">fetching voicemail...</string>
+
+ <!-- Message to display if we fail to get content within a suitable time period. [CHAR LIMIT=40] -->
+ <string name="voicemail_fetching_timout">failed to fetch voicemail</string>
+
<!-- The header in the call log used to identify missed calls and voicemail that have not yet been consumed [CHAR LIMIT=10] -->
<string name="call_log_new_header">New</string>
diff --git a/src/com/android/contacts/voicemail/VoicemailPlaybackFragment.java b/src/com/android/contacts/voicemail/VoicemailPlaybackFragment.java
index 0b30cd9..5e53b76 100644
--- a/src/com/android/contacts/voicemail/VoicemailPlaybackFragment.java
+++ b/src/com/android/contacts/voicemail/VoicemailPlaybackFragment.java
@@ -19,17 +19,24 @@
import static com.android.contacts.CallDetailActivity.EXTRA_VOICEMAIL_START_PLAYBACK;
import static com.android.contacts.CallDetailActivity.EXTRA_VOICEMAIL_URI;
+import com.android.common.io.MoreCloseables;
import com.android.contacts.R;
import com.android.contacts.util.BackgroundTaskService;
import com.android.ex.variablespeed.MediaPlayerProxy;
import com.android.ex.variablespeed.VariableSpeed;
import com.google.common.base.Preconditions;
+import android.app.Activity;
import android.app.Fragment;
+import android.content.ContentResolver;
import android.content.Context;
+import android.content.Intent;
+import android.database.ContentObserver;
+import android.database.Cursor;
import android.media.AudioManager;
import android.net.Uri;
import android.os.Bundle;
+import android.provider.VoicemailContract;
import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
@@ -60,30 +67,19 @@
public class VoicemailPlaybackFragment extends Fragment {
private static final String TAG = "VoicemailPlayback";
private static final int NUMBER_OF_THREADS_IN_POOL = 2;
+ private static final String[] HAS_CONTENT_PROJECTION = new String[] {
+ VoicemailContract.Voicemails.HAS_CONTENT,
+ };
private VoicemailPlaybackPresenter mPresenter;
private ScheduledExecutorService mScheduledExecutorService;
- private SeekBar mPlaybackSeek;
- private ImageButton mStartStopButton;
- private ImageButton mPlaybackSpeakerphone;
- private ImageButton mRateDecreaseButton;
- private ImageButton mRateIncreaseButton;
- private TextViewWithMessagesController mTextController;
+ private View mPlaybackLayout;
@Override
public View onCreateView(LayoutInflater inflater, ViewGroup container,
Bundle savedInstanceState) {
- View view = inflater.inflate(R.layout.playback_layout, null);
- mPlaybackSeek = (SeekBar) view.findViewById(R.id.playback_seek);
- mPlaybackSeek = (SeekBar) view.findViewById(R.id.playback_seek);
- mStartStopButton = (ImageButton) view.findViewById(R.id.playback_start_stop);
- mPlaybackSpeakerphone = (ImageButton) view.findViewById(R.id.playback_speakerphone);
- mRateDecreaseButton = (ImageButton) view.findViewById(R.id.rate_decrease_button);
- mRateIncreaseButton = (ImageButton) view.findViewById(R.id.rate_increase_button);
- mTextController = new TextViewWithMessagesController(
- (TextView) view.findViewById(R.id.playback_position_text),
- (TextView) view.findViewById(R.id.playback_speed_text));
- return view;
+ mPlaybackLayout = inflater.inflate(R.layout.playback_layout, null);
+ return mPlaybackLayout;
}
@Override
@@ -95,7 +91,7 @@
Uri voicemailUri = arguments.getParcelable(EXTRA_VOICEMAIL_URI);
Preconditions.checkNotNull(voicemailUri, "fragment must contain EXTRA_VOICEMAIL_URI");
boolean startPlayback = arguments.getBoolean(EXTRA_VOICEMAIL_START_PLAYBACK, false);
- mPresenter = new VoicemailPlaybackPresenter(new PlaybackViewImpl(),
+ mPresenter = new VoicemailPlaybackPresenter(createPlaybackViewImpl(),
createMediaPlayer(mScheduledExecutorService), voicemailUri,
mScheduledExecutorService, startPlayback, getBackgroundTaskService());
mPresenter.onCreate(savedInstanceState);
@@ -119,6 +115,11 @@
super.onDestroy();
}
+ private PlaybackViewImpl createPlaybackViewImpl() {
+ return new PlaybackViewImpl(new ActivityReference(), getActivity().getApplicationContext(),
+ mPlaybackLayout);
+ }
+
private MediaPlayerProxy createMediaPlayer(ExecutorService executorService) {
return VariableSpeed.createVariableSpeed(executorService);
}
@@ -133,7 +134,7 @@
* We always use four digits, two for minutes two for seconds. In the very unlikely event
* that the voicemail duration exceeds 99 minutes, the display is capped at 99 minutes.
*/
- private String formatAsMinutesAndSeconds(int millis) {
+ private static String formatAsMinutesAndSeconds(int millis) {
int seconds = millis / 1000;
int minutes = seconds / 60;
seconds -= minutes * 60;
@@ -143,25 +144,79 @@
return String.format("%02d:%02d", minutes, seconds);
}
- private AudioManager getAudioManager() {
- return (AudioManager) getActivity().getSystemService(Context.AUDIO_SERVICE);
+ /**
+ * An object that can provide us with an Activity.
+ * <p>
+ * Fragments suffer the drawback that the Activity they belong to may sometimes be null. This
+ * can happen if the Fragment is detached, for example. In that situation a call to
+ * {@link Fragment#getString(int)} will throw and {@link IllegalStateException}. Also, calling
+ * {@link Fragment#getActivity()} is dangerous - it may sometimes return null. And thus blindly
+ * calling a method on the result of getActivity() is dangerous too.
+ * <p>
+ * To work around this, I have made the {@link PlaybackViewImpl} class static, so that it does
+ * not have access to any Fragment methods directly. Instead it uses an application Context for
+ * things like accessing strings, accessing system services. It only uses the Activity when it
+ * absolutely needs it - and does so through this class. This makes it easy to see where we have
+ * to check for null properly.
+ */
+ private final class ActivityReference {
+ /** Gets this Fragment's Activity: <b>may be null</b>. */
+ public final Activity get() {
+ return getActivity();
+ }
}
/** Methods required by the PlaybackView for the VoicemailPlaybackPresenter. */
- private class PlaybackViewImpl implements VoicemailPlaybackPresenter.PlaybackView {
+ private static final class PlaybackViewImpl implements VoicemailPlaybackPresenter.PlaybackView {
+ private final ActivityReference mActivityReference;
+ private final Context mApplicationContext;
+ private final SeekBar mPlaybackSeek;
+ private final ImageButton mStartStopButton;
+ private final ImageButton mPlaybackSpeakerphone;
+ private final ImageButton mRateDecreaseButton;
+ private final ImageButton mRateIncreaseButton;
+ private final TextViewWithMessagesController mTextController;
+
+ public PlaybackViewImpl(ActivityReference activityReference, Context applicationContext,
+ View playbackLayout) {
+ Preconditions.checkNotNull(activityReference);
+ Preconditions.checkNotNull(applicationContext);
+ Preconditions.checkNotNull(playbackLayout);
+ mActivityReference = activityReference;
+ mApplicationContext = applicationContext;
+ mPlaybackSeek = (SeekBar) playbackLayout.findViewById(R.id.playback_seek);
+ mStartStopButton = (ImageButton) playbackLayout.findViewById(
+ R.id.playback_start_stop);
+ mPlaybackSpeakerphone = (ImageButton) playbackLayout.findViewById(
+ R.id.playback_speakerphone);
+ mRateDecreaseButton = (ImageButton) playbackLayout.findViewById(
+ R.id.rate_decrease_button);
+ mRateIncreaseButton = (ImageButton) playbackLayout.findViewById(
+ R.id.rate_increase_button);
+ mTextController = new TextViewWithMessagesController(
+ (TextView) playbackLayout.findViewById(R.id.playback_position_text),
+ (TextView) playbackLayout.findViewById(R.id.playback_speed_text));
+ }
+
@Override
public void finish() {
- getActivity().finish();
+ Activity activity = mActivityReference.get();
+ if (activity != null) {
+ activity.finish();
+ }
}
@Override
public void runOnUiThread(Runnable runnable) {
- getActivity().runOnUiThread(runnable);
+ Activity activity = mActivityReference.get();
+ if (activity != null) {
+ activity.runOnUiThread(runnable);
+ }
}
@Override
public Context getDataSourceContext() {
- return getActivity();
+ return mApplicationContext;
}
@Override
@@ -187,7 +242,7 @@
@Override
public void setRateDisplay(float rate, int stringResourceId) {
mTextController.setTemporaryText(
- getActivity().getString(stringResourceId), 1, TimeUnit.SECONDS);
+ mApplicationContext.getString(stringResourceId), 1, TimeUnit.SECONDS);
}
@Override
@@ -206,6 +261,16 @@
}
@Override
+ public void registerContentObserver(Uri uri, ContentObserver observer) {
+ mApplicationContext.getContentResolver().registerContentObserver(uri, false, observer);
+ }
+
+ @Override
+ public void unregisterContentObserver(ContentObserver observer) {
+ mApplicationContext.getContentResolver().unregisterContentObserver(observer);
+ }
+
+ @Override
public void setClipPosition(int clipPositionInMillis, int clipLengthInMillis) {
int seekBarPosition = Math.max(0, clipPositionInMillis);
int seekBarMax = Math.max(seekBarPosition, clipLengthInMillis);
@@ -217,9 +282,26 @@
formatAsMinutesAndSeconds(seekBarMax - seekBarPosition));
}
+ private String getString(int resId) {
+ return mApplicationContext.getString(resId);
+ }
+
@Override
public void setIsBuffering() {
- mTextController.setPermanentText(getString(R.string.voicemail_buffering));
+ disableUiElements();
+ mTextController.setPermanentText(getString(R.string.voicemail_buffering));
+ }
+
+ @Override
+ public void setIsFetchingContent() {
+ disableUiElements();
+ mTextController.setPermanentText(getString(R.string.voicemail_fetching_content));
+ }
+
+ @Override
+ public void setFetchContentTimeout() {
+ disableUiElements();
+ mTextController.setPermanentText(getString(R.string.voicemail_fetching_timout));
}
@Override
@@ -228,17 +310,58 @@
}
@Override
- public void playbackError(Exception e) {
+ public void disableUiElements() {
mRateIncreaseButton.setEnabled(false);
mRateDecreaseButton.setEnabled(false);
mStartStopButton.setEnabled(false);
+ mPlaybackSpeakerphone.setEnabled(false);
mPlaybackSeek.setProgress(0);
mPlaybackSeek.setEnabled(false);
+ }
+
+ @Override
+ public void playbackError(Exception e) {
+ disableUiElements();
mTextController.setPermanentText(getString(R.string.voicemail_playback_error));
Log.e(TAG, "Could not play voicemail", e);
}
@Override
+ public void enableUiElements() {
+ mRateIncreaseButton.setEnabled(true);
+ mRateDecreaseButton.setEnabled(true);
+ mStartStopButton.setEnabled(true);
+ mPlaybackSpeakerphone.setEnabled(true);
+ mPlaybackSeek.setEnabled(true);
+ }
+
+ @Override
+ public void sendFetchVoicemailRequest(Uri voicemailUri) {
+ Intent intent = new Intent(VoicemailContract.ACTION_FETCH_VOICEMAIL, voicemailUri);
+ mApplicationContext.sendBroadcast(intent);
+ }
+
+ @Override
+ public boolean queryHasContent(Uri voicemailUri) {
+ ContentResolver contentResolver = mApplicationContext.getContentResolver();
+ Cursor cursor = contentResolver.query(
+ voicemailUri, HAS_CONTENT_PROJECTION, null, null, null);
+ try {
+ if (cursor != null && cursor.moveToNext()) {
+ return cursor.getInt(cursor.getColumnIndexOrThrow(
+ VoicemailContract.Voicemails.HAS_CONTENT)) == 1;
+ }
+ } finally {
+ MoreCloseables.closeQuietly(cursor);
+ }
+ return false;
+ }
+
+ private AudioManager getAudioManager() {
+ return (AudioManager) mApplicationContext.getSystemService(Context.AUDIO_SERVICE);
+ }
+
+ @Override
public boolean isSpeakerPhoneOn() {
return getAudioManager().isSpeakerphoneOn();
}
diff --git a/src/com/android/contacts/voicemail/VoicemailPlaybackPresenter.java b/src/com/android/contacts/voicemail/VoicemailPlaybackPresenter.java
index 494888c..bd01991 100644
--- a/src/com/android/contacts/voicemail/VoicemailPlaybackPresenter.java
+++ b/src/com/android/contacts/voicemail/VoicemailPlaybackPresenter.java
@@ -23,12 +23,15 @@
import com.android.contacts.util.BackgroundTaskService;
import com.android.ex.variablespeed.MediaPlayerProxy;
import com.android.ex.variablespeed.SingleThreadedMediaPlayerProxy;
+import com.google.common.base.Preconditions;
import android.content.Context;
+import android.database.ContentObserver;
import android.media.MediaPlayer;
import android.net.Uri;
import android.os.AsyncTask;
import android.os.Bundle;
+import android.os.Handler;
import android.view.View;
import android.widget.SeekBar;
@@ -36,6 +39,7 @@
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import javax.annotation.concurrent.GuardedBy;
@@ -73,10 +77,20 @@
void setRateDisplay(float rate, int stringResourceId);
void setRateIncreaseButtonListener(View.OnClickListener listener);
void setRateDecreaseButtonListener(View.OnClickListener listener);
+ void setIsFetchingContent();
+ void disableUiElements();
+ void enableUiElements();
+ void sendFetchVoicemailRequest(Uri voicemailUri);
+ boolean queryHasContent(Uri voicemailUri);
+ void setFetchContentTimeout();
+ void registerContentObserver(Uri uri, ContentObserver observer);
+ void unregisterContentObserver(ContentObserver observer);
}
/** Update rate for the slider, 30fps. */
private static final int SLIDER_UPDATE_PERIOD_MILLIS = 1000 / 30;
+ /** Time our ui will wait for content to be fetched before reporting not available. */
+ private static final long FETCH_CONTENT_TIMEOUT_MS = 20000;
/**
* If present in the saved instance bundle, we should not resume playback on
* create.
@@ -102,6 +116,7 @@
R.string.voicemail_speed_faster,
R.string.voicemail_speed_fastest,
};
+
/**
* Pointer into the {@link VoicemailPlaybackPresenter#PRESET_RATES} array.
* <p>
@@ -131,6 +146,13 @@
/** Used to run background tasks that need to interact with the ui. */
private final BackgroundTaskService mBackgroundTaskService;
+ /**
+ * 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;
+
public VoicemailPlaybackPresenter(PlaybackView view, MediaPlayerProxy player,
Uri voicemailUri, ScheduledExecutorService executorService,
boolean startPlayingImmediately, BackgroundTaskService backgroundTaskService) {
@@ -143,6 +165,126 @@
}
public void onCreate(Bundle bundle) {
+ checkThatWeHaveContent();
+ }
+
+ /**
+ * Checks to see if we have content available for this voicemail.
+ * <p>
+ * This method will be called once, after the fragment has been created, before we know if the
+ * voicemail we've been asked to play has any content available.
+ * <p>
+ * This method will notify the user through the ui that we are fetching the content, then check
+ * to see if the content field in the db is set. If set, we proceed to
+ * {@link #postSuccessfullyFetchedContent()} method. If not set, we will make a request to fetch
+ * the content asynchronously via {@link #makeRequestForContent()}.
+ */
+ private void checkThatWeHaveContent() {
+ mView.setIsFetchingContent();
+ mBackgroundTaskService.submit(new BackgroundTask() {
+ private boolean mHasContent = false;
+
+ @Override
+ public void doInBackground() {
+ mHasContent = mView.queryHasContent(mVoicemailUri);
+ }
+
+ @Override
+ public void onPostExecute() {
+ if (mHasContent) {
+ postSuccessfullyFetchedContent();
+ } else {
+ makeRequestForContent();
+ }
+ }
+ }, AsyncTask.THREAD_POOL_EXECUTOR);
+ }
+
+ /**
+ * 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 #postSuccessfullyFetchedContent()}. 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.
+ */
+ private void makeRequestForContent() {
+ Handler handler = new Handler();
+ Preconditions.checkState(mFetchResultHandler == null, "mFetchResultHandler should be null");
+ mFetchResultHandler = new FetchResultHandler(handler);
+ mView.registerContentObserver(mVoicemailUri, mFetchResultHandler);
+ handler.postDelayed(mFetchResultHandler.getTimeoutRunnable(), FETCH_CONTENT_TIMEOUT_MS);
+ mView.sendFetchVoicemailRequest(mVoicemailUri);
+ }
+
+ @ThreadSafe
+ private class FetchResultHandler extends ContentObserver implements Runnable {
+ private AtomicBoolean mResultStillPending = new AtomicBoolean(true);
+ private final Handler mHandler;
+
+ public FetchResultHandler(Handler handler) {
+ super(handler);
+ mHandler = handler;
+ }
+
+ public Runnable getTimeoutRunnable() {
+ return this;
+ }
+
+ @Override
+ public void run() {
+ if (mResultStillPending.getAndSet(false)) {
+ mView.unregisterContentObserver(FetchResultHandler.this);
+ mView.setFetchContentTimeout();
+ }
+ }
+
+ public void destroy() {
+ if (mResultStillPending.getAndSet(false)) {
+ mView.unregisterContentObserver(FetchResultHandler.this);
+ mHandler.removeCallbacks(this);
+ }
+ }
+
+ @Override
+ public void onChange(boolean selfChange) {
+ mBackgroundTaskService.submit(new BackgroundTask() {
+ private boolean mHasContent = false;
+
+ @Override
+ public void doInBackground() {
+ mHasContent = mView.queryHasContent(mVoicemailUri);
+ }
+
+ @Override
+ public void onPostExecute() {
+ if (mHasContent) {
+ if (mResultStillPending.getAndSet(false)) {
+ mView.unregisterContentObserver(FetchResultHandler.this);
+ postSuccessfullyFetchedContent();
+ }
+ }
+ }
+ }, AsyncTask.THREAD_POOL_EXECUTOR);
+ }
+ }
+
+ /**
+ * 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 will try to prepare the data source through the media player.
+ * If preparing the media player works, we will call through to
+ * {@link #postSuccessfulPrepareActions()}. If preparing the media player fails (perhaps the
+ * file the content provider points to is actually missing, perhaps it is of an unknown file
+ * format that we can't play, who knows) then we will show an error on the ui.
+ */
+ private void postSuccessfullyFetchedContent() {
mView.setIsBuffering();
mBackgroundTaskService.submit(new BackgroundTask() {
private Exception mException;
@@ -169,7 +311,14 @@
}, AsyncTask.THREAD_POOL_EXECUTOR);
}
+ /**
+ * Enables the ui, and optionally starts playback immediately.
+ * <p>
+ * This will be called once we have successfully prepared the media player, and will optionally
+ * playback immediately.
+ */
private void postSuccessfulPrepareActions() {
+ mView.enableUiElements();
mView.setPositionSeekListener(new PlaybackPositionListener());
mView.setStartStopListener(new StartStopButtonListener());
mView.setSpeakerphoneListener(new SpeakerphoneListener());
@@ -194,7 +343,14 @@
}
}
+ /**
+ * This method should be called <b>only on the ui thread</b>.
+ */
public void onDestroy() {
+ if (mFetchResultHandler != null) {
+ mFetchResultHandler.destroy();
+ mFetchResultHandler = null;
+ }
mPositionUpdater.stopUpdating();
mPlayer.release();
}