Blank the screen while playing voicemail using proximity sensor.

This commit blanks the screen when the proximity sensor tells us that
there is an object close to the screen.

This is to avoid accidental touching of UI element with your face while
listening to voicemail.

As a consequence, do not enable this if listening to voicemail using the
speaker phone, because in that case we do not need to blank the screen
at all as the user is unlikely to have the phone near their ear.

This is done using a blank view that is placed on top of all other views
and by hiding the action bar. This leave the notification area available
to be accidentally touched, but we cannot hide the notification area
without starting a new activity.

Moreover, we do not want to start a new activity as that would cause our
activity to be stopped, which we do not want to do while the user is
listening to voicemail, as we plan to stop playback when the activity is
paused.

Bug: 5188914
Change-Id: I17bfa7b9d466db7519a97e7ca96f152bde64b78d
diff --git a/res/layout/call_detail.xml b/res/layout/call_detail.xml
index df1ec62..c69f89f 100644
--- a/res/layout/call_detail.xml
+++ b/res/layout/call_detail.xml
@@ -196,4 +196,18 @@
             </LinearLayout>
         </FrameLayout>
     </RelativeLayout>
+
+    <!--
+         Used to hide the UI when playing a voicemail and the proximity sensor
+         is detecting something near the screen.
+      -->
+    <View
+        android:id="@+id/blank"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:layout_alignParentLeft="true"
+        android:layout_alignParentTop="true"
+        android:background="#000000"
+        android:visibility="gone"
+    />
 </RelativeLayout>
diff --git a/src/com/android/contacts/CallDetailActivity.java b/src/com/android/contacts/CallDetailActivity.java
index 6ab4b68..0fadbfb 100644
--- a/src/com/android/contacts/CallDetailActivity.java
+++ b/src/com/android/contacts/CallDetailActivity.java
@@ -69,9 +69,14 @@
  * This activity can be either started with the URI of a single call log entry, or with the
  * {@link #EXTRA_CALL_LOG_IDS} extra to specify a group of call log entries.
  */
-public class CallDetailActivity extends Activity {
+public class CallDetailActivity extends Activity implements ProximitySensorAware {
     private static final String TAG = "CallDetail";
 
+    /** The time to wait before enabling the blank the screen due to the proximity sensor. */
+    private static final long PROXIMITY_BLANK_DELAY_MILLIS = 100;
+    /** The time to wait before disabling the blank the screen due to the proximity sensor. */
+    private static final long PROXIMITY_UNBLANK_DELAY_MILLIS = 500;
+
     /** The enumeration of {@link AsyncTask} objects used in this class. */
     public enum Tasks {
         MARK_VOICEMAIL_READ,
@@ -115,6 +120,60 @@
     /** Whether we should show "edit number before call" in the options menu. */
     private boolean mHasEditNumberBeforeCall;
 
+    private ProximitySensorManager mProximitySensorManager;
+    private final ProximitySensorListener mProximitySensorListener = new ProximitySensorListener();
+
+    /** Listener to changes in the proximity sensor state. */
+    private class ProximitySensorListener implements ProximitySensorManager.Listener {
+        /** Used to show a blank view and hide the action bar. */
+        private final Runnable mBlankRunnable = new Runnable() {
+            @Override
+            public void run() {
+                View blankView = findViewById(R.id.blank);
+                blankView.setVisibility(View.VISIBLE);
+                getActionBar().hide();
+            }
+        };
+        /** Used to remove the blank view and show the action bar. */
+        private final Runnable mUnblankRunnable = new Runnable() {
+            @Override
+            public void run() {
+                View blankView = findViewById(R.id.blank);
+                blankView.setVisibility(View.GONE);
+                getActionBar().show();
+            }
+        };
+
+        @Override
+        public synchronized void onNear() {
+            clearPendingRequests();
+            postDelayed(mBlankRunnable, PROXIMITY_BLANK_DELAY_MILLIS);
+        }
+
+        @Override
+        public synchronized void onFar() {
+            clearPendingRequests();
+            postDelayed(mUnblankRunnable, PROXIMITY_UNBLANK_DELAY_MILLIS);
+        }
+
+        /** Removed any delayed requests that may be pending. */
+        public synchronized void clearPendingRequests() {
+            View blankView = findViewById(R.id.blank);
+            blankView.removeCallbacks(mBlankRunnable);
+            blankView.removeCallbacks(mUnblankRunnable);
+        }
+
+        /** Post a {@link Runnable} with a delay on the main thread. */
+        private synchronized void postDelayed(Runnable runnable, long delayMillis) {
+            // Post these instead of executing immediately so that:
+            // - They are guaranteed to be executed on the main thread.
+            // - If the sensor values changes rapidly for some time, the UI will not be
+            //   updated immediately.
+            View blankView = findViewById(R.id.blank);
+            blankView.postDelayed(runnable, delayMillis);
+        }
+    }
+
     static final String[] CALL_LOG_PROJECTION = new String[] {
         CallLog.Calls.DATE,
         CallLog.Calls.DURATION,
@@ -189,6 +248,7 @@
         mContactBackgroundView = (ImageView) findViewById(R.id.contact_background);
         mDefaultCountryIso = ContactsUtils.getCurrentCountryIso(this);
         mContactPhotoManager = ContactPhotoManager.getInstance(this);
+        mProximitySensorManager = new ProximitySensorManager(this, mProximitySensorListener);
         configureActionBar();
         optionallyHandleVoicemail();
     }
@@ -770,4 +830,22 @@
         startActivity(intent);
         finish();
     }
+
+    @Override
+    protected void onPause() {
+        // Immediately stop the proximity sensor.
+        disableProximitySensor(false);
+        mProximitySensorListener.clearPendingRequests();
+        super.onPause();
+    }
+
+    @Override
+    public void enableProximitySensor() {
+        mProximitySensorManager.enable();
+    }
+
+    @Override
+    public void disableProximitySensor(boolean waitForFarState) {
+        mProximitySensorManager.disable(waitForFarState);
+    }
 }
diff --git a/src/com/android/contacts/ProximitySensorAware.java b/src/com/android/contacts/ProximitySensorAware.java
new file mode 100644
index 0000000..0fb233d
--- /dev/null
+++ b/src/com/android/contacts/ProximitySensorAware.java
@@ -0,0 +1,33 @@
+/*
+ * 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.contacts;
+
+/**
+ * An object that is aware of the state of the proximity sensor.
+ */
+public interface ProximitySensorAware {
+    /** Start tracking the state of the proximity sensor. */
+    public void enableProximitySensor();
+
+    /**
+     * Stop tracking the state of the proximity sensor.
+     *
+     * @param waitForFarState if true and the sensor is currently in the near state, it will wait
+     *         until it is again in the far state before stopping to track its state.
+     */
+    public void disableProximitySensor(boolean waitForFarState);
+}
diff --git a/src/com/android/contacts/ProximitySensorManager.java b/src/com/android/contacts/ProximitySensorManager.java
new file mode 100644
index 0000000..69601bf
--- /dev/null
+++ b/src/com/android/contacts/ProximitySensorManager.java
@@ -0,0 +1,237 @@
+/*
+ * 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.contacts;
+
+import android.content.Context;
+import android.hardware.Sensor;
+import android.hardware.SensorEvent;
+import android.hardware.SensorEventListener;
+import android.hardware.SensorManager;
+
+import javax.annotation.concurrent.GuardedBy;
+
+/**
+ * Manages the proximity sensor and notifies a listener when enabled.
+ */
+public class ProximitySensorManager {
+    /**
+     * Listener of the state of the proximity sensor.
+     * <p>
+     * This interface abstracts two possible states for the proximity sensor, near and far.
+     * <p>
+     * The actual meaning of these states depends on the actual sensor.
+     */
+    public interface Listener {
+        /** Called when the proximity sensor transitions from the far to the near state. */
+        public void onNear();
+        /** Called when the proximity sensor transitions from the near to the far state. */
+        public void onFar();
+    }
+
+    public static enum State {
+        NEAR, FAR
+    }
+
+    private final ProximitySensorEventListener mProximitySensorListener;
+
+    /**
+     * The current state of the manager, i.e., whether it is currently tracking the state of the
+     * sensor.
+     */
+    private boolean mManagerEnabled;
+
+    /**
+     * The listener to the state of the sensor.
+     * <p>
+     * Contains most of the logic concerning tracking of the sensor.
+     * <p>
+     * After creating an instance of this object, one should call {@link #register()} and
+     * {@link #unregister()} to enable and disable the notifications.
+     * <p>
+     * Instead of calling unregister, one can call {@link #unregisterWhenFar()} to unregister the
+     * listener the next time the sensor reaches the {@link State#FAR} state if currently in the
+     * {@link State#NEAR} state.
+     */
+    private static class ProximitySensorEventListener implements SensorEventListener {
+        private static final float FAR_THRESHOLD = 5.0f;
+
+        private final SensorManager mSensorManager;
+        private final Sensor mProximitySensor;
+        private final float mMaxValue;
+        private final Listener mListener;
+
+        /**
+         * The last state of the sensor.
+         * <p>
+         * Before registering and after unregistering we are always in the {@link State#FAR} state.
+         */
+        @GuardedBy("this") private State mLastState;
+        /**
+         * If this flag is set to true, we are waiting to reach the {@link State#FAR} state and
+         * should notify the listener and unregister when that happens.
+         */
+        @GuardedBy("this") private boolean mWaitingForFarState;
+
+        public ProximitySensorEventListener(SensorManager sensorManager, Sensor proximitySensor,
+                Listener listener) {
+            mSensorManager = sensorManager;
+            mProximitySensor = proximitySensor;
+            mMaxValue = proximitySensor.getMaximumRange();
+            mListener = listener;
+            // Initialize at far state.
+            mLastState = State.FAR;
+            mWaitingForFarState = false;
+        }
+
+        @Override
+        public void onSensorChanged(SensorEvent event) {
+            // Make sure we have a valid value.
+            if (event.values == null) return;
+            if (event.values.length == 0) return;
+            float value = event.values[0];
+            // Convert the sensor into a NEAR/FAR state.
+            State state = getStateFromValue(value);
+            synchronized (this) {
+                // No change in state, do nothing.
+                if (state == mLastState) return;
+                // Keep track of the current state.
+                mLastState = state;
+                // If we are waiting to reach the far state and we are now in it, unregister.
+                if (mWaitingForFarState && mLastState == State.FAR) {
+                    unregisterWithoutNotification();
+                }
+            }
+            // Notify the listener of the state change.
+            switch (state) {
+                case NEAR:
+                    mListener.onNear();
+                    break;
+
+                case FAR:
+                    mListener.onFar();
+                    break;
+            }
+        }
+
+        @Override
+        public void onAccuracyChanged(Sensor sensor, int accuracy) {
+            // Nothing to do here.
+        }
+
+        /** Returns the state of the sensor given its current value. */
+        private State getStateFromValue(float value) {
+            // Determine if the current value corresponds to the NEAR or FAR state.
+            // Take case of the case where the proximity sensor is binary: if the current value is
+            // equal to the maximum, we are always in the FAR state.
+            return (value > FAR_THRESHOLD || value == mMaxValue) ? State.FAR : State.NEAR;
+        }
+
+        /**
+         * Unregister the next time the sensor reaches the {@link State#FAR} state.
+         */
+        public synchronized void unregisterWhenFar() {
+            if (mLastState == State.FAR) {
+                // We are already in the far state, just unregister now.
+                unregisterWithoutNotification();
+            } else {
+                mWaitingForFarState = true;
+            }
+        }
+
+        /** Register the listener and call the listener as necessary. */
+        public synchronized void register() {
+            // It is okay to register multiple times.
+            mSensorManager.registerListener(this, mProximitySensor, SensorManager.SENSOR_DELAY_UI);
+            // We should no longer be waiting for the far state if we are registering again.
+            mWaitingForFarState = false;
+        }
+
+        public void unregister() {
+            State lastState;
+            synchronized (this) {
+                unregisterWithoutNotification();
+                lastState = mLastState;
+                // Always go back to the FAR state. That way, when we register again we will get a
+                // transition when the sensor gets into the NEAR state.
+                mLastState = State.FAR;
+            }
+            // Notify the listener if we changed the state to FAR while unregistering.
+            if (lastState != State.FAR) {
+                mListener.onFar();
+            }
+        }
+
+        @GuardedBy("this")
+        private void unregisterWithoutNotification() {
+            mSensorManager.unregisterListener(this);
+            mWaitingForFarState = false;
+        }
+    }
+
+    public ProximitySensorManager(Context context, Listener listener) {
+        SensorManager sensorManager =
+                (SensorManager) context.getSystemService(Context.SENSOR_SERVICE);
+        Sensor proximitySensor = sensorManager.getDefaultSensor(Sensor.TYPE_PROXIMITY);
+        if (proximitySensor == null) {
+            // If there is no sensor, we should not do anything.
+            mProximitySensorListener = null;
+        } else {
+            mProximitySensorListener =
+                    new ProximitySensorEventListener(sensorManager, proximitySensor, listener);
+        }
+    }
+
+    /**
+     * Enables the proximity manager.
+     * <p>
+     * The listener will start getting notifications of events.
+     * <p>
+     * This method is idempotent.
+     */
+    public void enable() {
+        if (mProximitySensorListener != null && !mManagerEnabled) {
+            mProximitySensorListener.register();
+            mManagerEnabled = true;
+        }
+    }
+
+    /**
+     * Disables the proximity manager.
+     * <p>
+     * The listener will stop receiving notifications of events, possibly after receiving a last
+     * {@link Listener#onFar()} callback.
+     * <p>
+     * If {@code waitForFarState} is true, if the sensor is not currently in the {@link State#FAR}
+     * state, the listener will receive a {@link Listener#onFar()} callback the next time the sensor
+     * actually reaches the {@link State#FAR} state.
+     * <p>
+     * If {@code waitForFarState} is false, the listener will receive a {@link Listener#onFar()}
+     * callback immediately if the sensor is currently not in the {@link State#FAR} state.
+     * <p>
+     * This method is idempotent.
+     */
+    public void disable(boolean waitForFarState) {
+        if (mProximitySensorListener != null && mManagerEnabled) {
+            if (waitForFarState) {
+                mProximitySensorListener.unregisterWhenFar();
+            } else {
+                mProximitySensorListener.unregister();
+            }
+            mManagerEnabled = false;
+        }
+    }
+}
diff --git a/src/com/android/contacts/voicemail/VoicemailPlaybackFragment.java b/src/com/android/contacts/voicemail/VoicemailPlaybackFragment.java
index 7d29406..80f7862 100644
--- a/src/com/android/contacts/voicemail/VoicemailPlaybackFragment.java
+++ b/src/com/android/contacts/voicemail/VoicemailPlaybackFragment.java
@@ -20,6 +20,7 @@
 import static com.android.contacts.CallDetailActivity.EXTRA_VOICEMAIL_URI;
 
 import com.android.common.io.MoreCloseables;
+import com.android.contacts.ProximitySensorAware;
 import com.android.contacts.R;
 import com.android.contacts.util.AsyncTaskExecutors;
 import com.android.ex.variablespeed.MediaPlayerProxy;
@@ -36,6 +37,7 @@
 import android.media.AudioManager;
 import android.net.Uri;
 import android.os.Bundle;
+import android.os.PowerManager;
 import android.provider.VoicemailContract;
 import android.util.Log;
 import android.view.LayoutInflater;
@@ -91,10 +93,15 @@
         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);
+        PowerManager powerManager =
+                (PowerManager) getActivity().getSystemService(Context.POWER_SERVICE);
+        PowerManager.WakeLock wakeLock =
+                powerManager.newWakeLock(
+                        PowerManager.SCREEN_DIM_WAKE_LOCK, getClass().getSimpleName());
         mPresenter = new VoicemailPlaybackPresenter(createPlaybackViewImpl(),
                 createMediaPlayer(mScheduledExecutorService), voicemailUri,
                 mScheduledExecutorService, startPlayback,
-                AsyncTaskExecutors.createAsyncTaskExecutor());
+                AsyncTaskExecutors.createAsyncTaskExecutor(), wakeLock);
         mPresenter.onCreate(savedInstanceState);
     }
 
@@ -263,6 +270,24 @@
         }
 
         @Override
+        public void enableProximitySensor() {
+            // Only change the state if the activity is still around.
+            Activity activity = mActivityReference.get();
+            if (activity != null && activity instanceof ProximitySensorAware) {
+                ((ProximitySensorAware) activity).enableProximitySensor();
+            }
+        }
+
+        @Override
+        public void disableProximitySensor() {
+            // Only change the state if the activity is still around.
+            Activity activity = mActivityReference.get();
+            if (activity != null && activity instanceof ProximitySensorAware) {
+                ((ProximitySensorAware) activity).disableProximitySensor(true);
+            }
+        }
+
+        @Override
         public void registerContentObserver(Uri uri, ContentObserver observer) {
             mApplicationContext.getContentResolver().registerContentObserver(uri, false, observer);
         }
diff --git a/src/com/android/contacts/voicemail/VoicemailPlaybackPresenter.java b/src/com/android/contacts/voicemail/VoicemailPlaybackPresenter.java
index d54cddc..6eb541d 100644
--- a/src/com/android/contacts/voicemail/VoicemailPlaybackPresenter.java
+++ b/src/com/android/contacts/voicemail/VoicemailPlaybackPresenter.java
@@ -32,6 +32,7 @@
 import android.os.AsyncTask;
 import android.os.Bundle;
 import android.os.Handler;
+import android.os.PowerManager;
 import android.view.View;
 import android.widget.SeekBar;
 
@@ -86,6 +87,8 @@
         void setFetchContentTimeout();
         void registerContentObserver(Uri uri, ContentObserver observer);
         void unregisterContentObserver(ContentObserver observer);
+        void enableProximitySensor();
+        void disableProximitySensor();
     }
 
     /** The enumeration of {@link AsyncTask} objects we use in this class. */
@@ -160,16 +163,19 @@
      * This variable is thread-contained, accessed only on the ui thread.
      */
     private FetchResultHandler mFetchResultHandler;
+    private PowerManager.WakeLock mWakeLock;
 
     public VoicemailPlaybackPresenter(PlaybackView view, MediaPlayerProxy player,
             Uri voicemailUri, ScheduledExecutorService executorService,
-            boolean startPlayingImmediately, AsyncTaskExecutor asyncTaskExecutor) {
+            boolean startPlayingImmediately, AsyncTaskExecutor asyncTaskExecutor,
+            PowerManager.WakeLock wakeLock) {
         mView = view;
         mPlayer = player;
         mVoicemailUri = voicemailUri;
         mStartPlayingImmediately = startPlayingImmediately;
         mAsyncTaskExecutor = asyncTaskExecutor;
         mPositionUpdater = new PositionUpdater(executorService, SLIDER_UPDATE_PERIOD_MILLIS);
+        mWakeLock = wakeLock;
     }
 
     public void onCreate(Bundle bundle) {
@@ -334,6 +340,8 @@
         mView.setRateIncreaseButtonListener(createRateIncreaseListener());
         mView.setClipPosition(0, mPlayer.getDuration());
         mView.playbackStopped();
+        // Always disable on stop.
+        mView.disableProximitySensor();
         if (mStartPlayingImmediately) {
             resetPrepareStartPlaying(0);
         }
@@ -348,16 +356,16 @@
         }
     }
 
-    /**
-     * This method should be called <b>only on the ui thread</b>.
-     */
     public void onDestroy() {
+        mPlayer.release();
         if (mFetchResultHandler != null) {
             mFetchResultHandler.destroy();
             mFetchResultHandler = null;
         }
         mPositionUpdater.stopUpdating();
-        mPlayer.release();
+        if (mWakeLock.isHeld()) {
+            mWakeLock.release();
+        }
     }
 
     private class MediaPlayerErrorListener implements MediaPlayer.OnErrorListener {
@@ -427,6 +435,13 @@
             mPlayer.seekTo(startPosition);
             mPlayer.start();
             mView.playbackStarted();
+            if (!mWakeLock.isHeld()) {
+                mWakeLock.acquire();
+            }
+            // Only enable if we are not currently using the speaker phone.
+            if (!mView.isSpeakerPhoneOn()) {
+                mView.enableProximitySensor();
+            }
             mPositionUpdater.startUpdating(startPosition, mDuration.get());
         } catch (IOException e) {
             handleError(e);
@@ -446,6 +461,11 @@
     private void stopPlaybackAtPosition(int clipPosition, int duration) {
         mPositionUpdater.stopUpdating();
         mView.playbackStopped();
+        if (mWakeLock.isHeld()) {
+            mWakeLock.release();
+        }
+        // Always disable on stop.
+        mView.disableProximitySensor();
         mView.setClipPosition(clipPosition, duration);
         if (mPlayer.isPlaying()) {
             mPlayer.pause();
@@ -489,7 +509,16 @@
     private class SpeakerphoneListener implements View.OnClickListener {
         @Override
         public void onClick(View v) {
-            mView.setSpeakerPhoneOn(!mView.isSpeakerPhoneOn());
+            boolean previousState = mView.isSpeakerPhoneOn();
+            mView.setSpeakerPhoneOn(!previousState);
+            if (mPlayer.isPlaying() && previousState) {
+                // If we are currently playing and we are disabling the speaker phone, enable the
+                // sensor.
+                mView.enableProximitySensor();
+            } else {
+                // If we are not currently playing, disable the sensor.
+                mView.disableProximitySensor();
+            }
         }
     }
 
@@ -560,5 +589,8 @@
         if (mPlayer.isPlaying()) {
             stopPlaybackAtPosition(mPlayer.getCurrentPosition(), mDuration.get());
         }
+        if (mWakeLock.isHeld()) {
+            mWakeLock.release();
+        }
     }
 }