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();
     }