Help reduce falsing on the seek bar

To help reduce falsing, only accepting two gestures:
 - single tap anywhere to seek to that position
 - scroll starting from the thumb
All other guestures will be handled by the carousel.

Bug: 155673905
Test: manual - play music and tap anywhere. Verify that playback
position changes.
Test: manual - play music and drag thumb. Verify that playback position
changes.
Test: manual - play music and drag on seek bar away from thumb. Verify
that playback position doesn't change.

Change-Id: I11f93de5e61a679c9d56ca98b478e793d73daa13
diff --git a/packages/SystemUI/res/layout/media_view.xml b/packages/SystemUI/res/layout/media_view.xml
index d721818..a722fbf 100644
--- a/packages/SystemUI/res/layout/media_view.xml
+++ b/packages/SystemUI/res/layout/media_view.xml
@@ -39,7 +39,7 @@
             android:layout_alignParentLeft="true"
             android:fontFamily="@*android:string/config_bodyFontFamily"
             android:textColor="@color/media_primary_text"
-            android:gravity="left"
+            android:gravity="start"
             android:textSize="14sp" />
 
         <TextView
@@ -49,7 +49,7 @@
             android:layout_alignParentRight="true"
             android:fontFamily="@*android:string/config_bodyFontFamily"
             android:textColor="@color/media_primary_text"
-            android:gravity="right"
+            android:gravity="end"
             android:textSize="14sp" />
     </FrameLayout>
 
diff --git a/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java b/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java
index f039fc2..ee541bc 100644
--- a/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java
+++ b/packages/SystemUI/src/com/android/systemui/media/MediaControlPanel.java
@@ -35,7 +35,6 @@
 import android.view.View;
 import android.widget.ImageButton;
 import android.widget.ImageView;
-import android.widget.SeekBar;
 import android.widget.TextView;
 
 import androidx.annotation.NonNull;
@@ -165,9 +164,7 @@
         TransitionLayout player = vh.getPlayer();
         mSeekBarObserver = new SeekBarObserver(vh);
         mSeekBarViewModel.getProgress().observeForever(mSeekBarObserver);
-        SeekBar bar = vh.getSeekBar();
-        bar.setOnSeekBarChangeListener(mSeekBarViewModel.getSeekBarListener());
-        bar.setOnTouchListener(mSeekBarViewModel.getSeekBarTouchListener());
+        mSeekBarViewModel.attachTouchHandlers(vh.getSeekBar());
         mMediaViewController.attach(player);
     }
 
diff --git a/packages/SystemUI/src/com/android/systemui/media/SeekBarViewModel.kt b/packages/SystemUI/src/com/android/systemui/media/SeekBarViewModel.kt
index efc476d..63f3d44 100644
--- a/packages/SystemUI/src/com/android/systemui/media/SeekBarViewModel.kt
+++ b/packages/SystemUI/src/com/android/systemui/media/SeekBarViewModel.kt
@@ -20,11 +20,13 @@
 import android.media.session.MediaController
 import android.media.session.PlaybackState
 import android.os.SystemClock
+import android.view.GestureDetector
 import android.view.MotionEvent
 import android.view.View
 import android.widget.SeekBar
 import androidx.annotation.AnyThread
 import androidx.annotation.WorkerThread
+import androidx.core.view.GestureDetectorCompat
 import androidx.lifecycle.MutableLiveData
 import androidx.lifecycle.LiveData
 import com.android.systemui.dagger.qualifiers.Background
@@ -68,7 +70,6 @@
 
 /** ViewModel for seek bar in QS media player. */
 class SeekBarViewModel @Inject constructor(@Background private val bgExecutor: RepeatableExecutor) {
-
     private var _data = Progress(false, false, null, null)
         set(value) {
             field = value
@@ -198,11 +199,11 @@
             return SeekBarChangeListener(this, bgExecutor)
         }
 
-    /** Gets a listener to attach to the seek bar to disable touch intercepting. */
-    val seekBarTouchListener: View.OnTouchListener
-        get() {
-            return SeekBarTouchListener()
-        }
+    /** Attach touch handlers to the seek bar view. */
+    fun attachTouchHandlers(bar: SeekBar) {
+        bar.setOnSeekBarChangeListener(seekBarListener)
+        bar.setOnTouchListener(SeekBarTouchListener(bar))
+    }
 
     private class SeekBarChangeListener(
         val viewModel: SeekBarViewModel,
@@ -225,11 +226,130 @@
         }
     }
 
-    private class SeekBarTouchListener : View.OnTouchListener {
+    /**
+     * Responsible for intercepting touch events before they reach the seek bar.
+     *
+     * This reduces the gestures seen by the seek bar so that users don't accidentially seek when
+     * they intend to scroll the carousel.
+     */
+    private class SeekBarTouchListener(
+        private val bar: SeekBar
+    ) : View.OnTouchListener, GestureDetector.OnGestureListener {
+
+        // Gesture detector helps decide which touch events to intercept.
+        private val detector = GestureDetectorCompat(bar.context, this)
+        // Defines a tap target around the thumb at the beginning of a gesture.
+        private var onDownTargetBoxMinX: Int = -1
+        private var onDownTargetBoxMaxX: Int = -1
+
+        /**
+         * Decide which touch events to intercept before they reach the seek bar.
+         *
+         * Based on the gesture detected, we decide whether we want the event to reach the seek bar.
+         * If we want the seek bar to see the event, then we return false so that the event isn't
+         * handled here and it will be passed along. If, however, we don't want the seek bar to see
+         * the event, then return true so that the event is handled here.
+         *
+         * When the seek bar is contained in the carousel, the carousel still has the ability to
+         * intercept the touch event. So, even though we may handle the event here, the carousel can
+         * still intercept the event. This way, gestures that we consider falses on the seek bar can
+         * still be used by the carousel for paging.
+         *
+         * Returns true for events that we don't want dispatched to the seek bar.
+         */
         override fun onTouch(view: View, event: MotionEvent): Boolean {
-            view.parent.requestDisallowInterceptTouchEvent(true)
-            return view.onTouchEvent(event)
+            if (view != bar) {
+                return false
+            }
+            val shouldGoToSeekBar = detector.onTouchEvent(event)
+            return !shouldGoToSeekBar
         }
+
+        /**
+         * Handle down events that press down on the thumb.
+         *
+         * On the down action, determine a target box around the thumb to know when a scroll
+         * gesture starts by clicking on the thumb. The target box will be used by subsequent
+         * onScroll events.
+         *
+         * Returns true when the down event hits within the target box of the thumb.
+         */
+        override fun onDown(event: MotionEvent): Boolean {
+            val padL = bar.paddingLeft
+            val padR = bar.paddingRight
+            // Compute the X location of the thumb as a function of the seek bar progress.
+            // TODO: account for thumb offset
+            val progress = bar.getProgress()
+            val range = bar.max - bar.min
+            val widthFraction = if (range > 0) {
+                (progress - bar.min).toDouble() / range
+            } else {
+                0.0
+            }
+            val availableWidth = bar.width - padL - padR
+            val thumbX = if (bar.isLayoutRtl()) {
+                padL + availableWidth * (1 - widthFraction)
+            } else {
+                padL + availableWidth * widthFraction
+            }
+            // Set the min, max boundaries of the thumb box.
+            // I'm cheating by using the height of the seek bar as the width of the box.
+            val halfHeight: Int = bar.height / 2
+            onDownTargetBoxMinX = (Math.round(thumbX) - halfHeight).toInt()
+            onDownTargetBoxMaxX = (Math.round(thumbX) + halfHeight).toInt()
+            // If the x position of the down event is within the box, then request that the parent
+            // not intercept the event.
+            val x = Math.round(event.x)
+            val accept = x >= onDownTargetBoxMinX && x <= onDownTargetBoxMaxX
+            if (accept) {
+                bar.parent?.requestDisallowInterceptTouchEvent(true)
+            }
+            return accept
+        }
+
+        /**
+         * Always handle single tap up.
+         *
+         * This enables the user to single tap anywhere on the seek bar to seek to that position.
+         */
+        override fun onSingleTapUp(event: MotionEvent) = true
+
+        /**
+         * Handle scroll events when the down event is on the thumb.
+         *
+         * Returns true when the down event of the scroll hits within the target box of the thumb.
+         */
+        override fun onScroll(
+            eventStart: MotionEvent,
+            event: MotionEvent,
+            distanceX: Float,
+            distanceY: Float
+        ): Boolean {
+            val x = Math.round(eventStart.x)
+            return x >= onDownTargetBoxMinX && x <= onDownTargetBoxMaxX
+        }
+
+        /**
+         * Handle fling events when the down event is on the thumb.
+         *
+         * TODO: Ignore entire gesture when it includes a fling.
+         * If a user is flinging, then they are probably trying to page the carousel. It would be
+         * better to ignore the entire gesture when it includes a fling. This could be achieved by
+         * reseting the seek bar position to where it was when the gesture started.
+         */
+        override fun onFling(
+            eventStart: MotionEvent,
+            event: MotionEvent,
+            velocityX: Float,
+            velocityY: Float
+        ): Boolean {
+            val x = Math.round(eventStart.x)
+            return x >= onDownTargetBoxMinX && x <= onDownTargetBoxMaxX
+        }
+
+        override fun onShowPress(event: MotionEvent) {}
+
+        override fun onLongPress(event: MotionEvent) {}
     }
 
     /** State seen by seek bar UI. */