Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit a04ccd19 authored by Juan Sebastian Martinez's avatar Juan Sebastian Martinez Committed by Android (Google) Code Review
Browse files

Merge "Introducing the SeekableSliderHapticPlugin for volume slider haptics." into main

parents 6c7dbe9a 86b52bf8
Loading
Loading
Loading
Loading
+156 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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.systemui.haptics.slider

import android.widget.SeekBar
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.kosmos.Kosmos
import com.android.systemui.kosmos.testScope
import com.android.systemui.statusbar.VibratorHelper
import com.android.systemui.util.mockito.whenever
import com.android.systemui.util.time.fakeSystemClock
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.ArgumentMatchers.anyInt
import org.mockito.Mock
import org.mockito.junit.MockitoJUnit
import org.mockito.junit.MockitoRule

@SmallTest
@RunWith(AndroidJUnit4::class)
@OptIn(ExperimentalCoroutinesApi::class)
class SeekableSliderHapticPluginTest : SysuiTestCase() {

    private val kosmos = Kosmos()

    @Rule @JvmField val mMockitoRule: MockitoRule = MockitoJUnit.rule()
    @Mock private lateinit var vibratorHelper: VibratorHelper
    private val seekBar = SeekBar(mContext)
    private lateinit var plugin: SeekableSliderHapticPlugin

    @Before
    fun setup() {
        whenever(vibratorHelper.getPrimitiveDurations(anyInt())).thenReturn(intArrayOf(0))
    }

    @Test
    fun start_beginsTrackingSlider() = runOnStartedPlugin { assertThat(plugin.isTracking).isTrue() }

    @Test
    fun stop_stopsTrackingSlider() = runOnStartedPlugin {
        // WHEN called to stop
        plugin.stop()

        // THEN stops tracking
        assertThat(plugin.isTracking).isFalse()
    }

    @Test
    fun start_afterStop_startsTheTrackingAgain() = runOnStartedPlugin {
        // WHEN the plugin is restarted
        plugin.stop()
        plugin.start()

        // THEN the tracking begins again
        assertThat(plugin.isTracking).isTrue()
    }

    @Test
    fun onKeyDown_startsWaiting() = runOnStartedPlugin {
        // WHEN a keyDown event is recorded
        plugin.onKeyDown()

        // THEN the timer starts waiting
        assertThat(plugin.isKeyUpTimerWaiting).isTrue()
    }

    @Test
    fun keyUpWaitComplete_triggersOnArrowUp() = runOnStartedPlugin {
        // GIVEN an onKeyDown that starts the wait and a program progress change that advances the
        // slider state to ARROW_HANDLE_MOVED_ONCE
        plugin.onKeyDown()
        plugin.onProgressChanged(seekBar, 50, false)
        testScheduler.runCurrent()
        assertThat(plugin.trackerState).isEqualTo(SliderState.ARROW_HANDLE_MOVED_ONCE)

        // WHEN the key-up wait completes after the timeout plus a small buffer
        advanceTimeBy(KEY_UP_TIMEOUT + 10L)

        // THEN the onArrowUp event is delivered causing the slider tracker to move to IDLE
        assertThat(plugin.trackerState).isEqualTo(SliderState.IDLE)
        assertThat(plugin.isKeyUpTimerWaiting).isFalse()
    }

    @Test
    fun onKeyDown_whileWaiting_restartsWait() = runOnStartedPlugin {
        // GIVEN an onKeyDown that starts the wait and a program progress change that advances the
        // slider state to ARROW_HANDLE_MOVED_ONCE
        plugin.onKeyDown()
        plugin.onProgressChanged(seekBar, 50, false)
        testScheduler.runCurrent()
        assertThat(plugin.trackerState).isEqualTo(SliderState.ARROW_HANDLE_MOVED_ONCE)

        // WHEN half the timeout period has elapsed and a new keyDown event occurs
        advanceTimeBy(KEY_UP_TIMEOUT / 2)
        plugin.onKeyDown()

        // AFTER advancing by a period of time that should have complete the original wait
        advanceTimeBy(KEY_UP_TIMEOUT / 2 + 10L)

        // THEN the timer is still waiting and the slider tracker remains on ARROW_HANDLE_MOVED_ONCE
        assertThat(plugin.isKeyUpTimerWaiting).isTrue()
        assertThat(plugin.trackerState).isEqualTo(SliderState.ARROW_HANDLE_MOVED_ONCE)
    }

    private fun runOnStartedPlugin(test: suspend TestScope.() -> Unit) =
        with(kosmos) {
            testScope.runTest {
                createPlugin(this, UnconfinedTestDispatcher(testScheduler))
                // GIVEN that the plugin is started
                plugin.start()

                // THEN run the test
                test()
            }
        }

    private fun createPlugin(scope: CoroutineScope, dispatcher: CoroutineDispatcher) {
        plugin =
            SeekableSliderHapticPlugin(
                vibratorHelper,
                kosmos.fakeSystemClock,
                dispatcher,
                scope,
            )
    }

    companion object {
        private const val KEY_UP_TIMEOUT = 100L
    }
}
+5 −0
Original line number Diff line number Diff line
@@ -227,5 +227,10 @@ public interface VolumeDialogController {
        void onCaptionEnabledStateChanged(Boolean isEnabled, Boolean checkBeforeSwitch);
        // requires version 2
        void onShowCsdWarning(@AudioManager.CsdWarning int csdWarning, int durationMs);

        /**
         * Callback function for when the volume changed due to a physical key press.
         */
        void onVolumeChangedFromKey();
    }
}
+171 −0
Original line number Diff line number Diff line
/*
 * Copyright (C) 2024 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.systemui.haptics.slider

import android.view.MotionEvent
import android.view.VelocityTracker
import android.widget.SeekBar
import androidx.annotation.VisibleForTesting
import com.android.systemui.dagger.qualifiers.Application
import com.android.systemui.dagger.qualifiers.Main
import com.android.systemui.statusbar.VibratorHelper
import com.android.systemui.util.time.SystemClock
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch

/**
 * A plugin added to a manager of a [android.widget.SeekBar] that adds dynamic haptic feedback.
 *
 * A [SeekableSliderEventProducer] is used as the producer of slider events, a
 * [SliderHapticFeedbackProvider] is used as the listener of slider states to play haptic feedback
 * depending on the state, and a [SeekableSliderTracker] is used as the state machine handler that
 * tracks and manipulates the slider state.
 */
class SeekableSliderHapticPlugin
@JvmOverloads
constructor(
    vibratorHelper: VibratorHelper,
    systemClock: SystemClock,
    @Main private val mainDispatcher: CoroutineDispatcher,
    @Application private val applicationScope: CoroutineScope,
    sliderHapticFeedbackConfig: SliderHapticFeedbackConfig = SliderHapticFeedbackConfig(),
    sliderTrackerConfig: SeekableSliderTrackerConfig = SeekableSliderTrackerConfig(),
) {

    private val velocityTracker = VelocityTracker.obtain()

    private val sliderEventProducer = SeekableSliderEventProducer()

    private val sliderHapticFeedbackProvider =
        SliderHapticFeedbackProvider(
            vibratorHelper,
            velocityTracker,
            sliderHapticFeedbackConfig,
            systemClock,
        )

    private val sliderTracker =
        SeekableSliderTracker(
            sliderHapticFeedbackProvider,
            sliderEventProducer,
            mainDispatcher,
            sliderTrackerConfig,
        )

    val isTracking: Boolean
        get() = sliderTracker.isTracking

    val trackerState: SliderState
        get() = sliderTracker.currentState

    /**
     * A waiting [Job] for a timer that estimates the key-up event when a key-down event is
     * received.
     *
     * This is useful for the cases where the slider is being operated by an external key, but the
     * release of the key is not easily accessible (e.g., the volume keys)
     */
    private var keyUpJob: Job? = null

    @VisibleForTesting
    val isKeyUpTimerWaiting: Boolean
        get() = keyUpJob != null && keyUpJob?.isActive == true

    /**
     * Start the plugin.
     *
     * This starts the tracking of slider states, events and triggering of haptic feedback.
     */
    fun start() {
        if (!isTracking) {
            sliderTracker.startTracking()
        }
    }

    /**
     * Stop the plugin
     *
     * This stops the tracking of slider states, events and triggers of haptic feedback.
     */
    fun stop() = sliderTracker.stopTracking()

    /** React to a touch event */
    fun onTouchEvent(event: MotionEvent?) {
        when (event?.actionMasked) {
            MotionEvent.ACTION_UP,
            MotionEvent.ACTION_CANCEL -> velocityTracker.clear()
            MotionEvent.ACTION_DOWN,
            MotionEvent.ACTION_MOVE -> velocityTracker.addMovement(event)
        }
    }

    /** onStartTrackingTouch event from the slider's [android.widget.SeekBar] */
    fun onStartTrackingTouch(seekBar: SeekBar) {
        if (isTracking) {
            sliderEventProducer.onStartTrackingTouch(seekBar)
        }
    }

    /** onProgressChanged event from the slider's [android.widget.SeekBar] */
    fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
        if (isTracking) {
            sliderEventProducer.onProgressChanged(seekBar, progress, fromUser)
        }
    }

    /** onStopTrackingTouch event from the slider's [android.widget.SeekBar] */
    fun onStopTrackingTouch(seekBar: SeekBar) {
        if (isTracking) {
            sliderEventProducer.onStopTrackingTouch(seekBar)
        }
    }

    /** onArrowUp event recorded */
    fun onArrowUp() {
        if (isTracking) {
            sliderEventProducer.onArrowUp()
        }
    }

    /**
     * An external key was pressed (e.g., a volume key).
     *
     * This event is used to estimate the key-up event based on by running a timer as a waiting
     * coroutine in the [keyUpTimerScope]. A key-up event in a slider corresponds to an onArrowUp
     * event. Therefore, [onArrowUp] must be called after the timeout.
     */
    fun onKeyDown() {
        if (!isTracking) return

        if (isKeyUpTimerWaiting) {
            // Cancel the ongoing wait
            keyUpJob?.cancel()
        }
        keyUpJob =
            applicationScope.launch {
                delay(KEY_UP_TIMEOUT)
                onArrowUp()
            }
    }

    companion object {
        const val KEY_UP_TIMEOUT = 100L
    }
}
+13 −0
Original line number Diff line number Diff line
@@ -535,6 +535,7 @@ public class VolumeDialogControllerImpl implements VolumeDialogController, Dumpa
        }
        if (changed && fromKey) {
            Events.writeEvent(Events.EVENT_KEY, stream, lastAudibleStreamVolume);
            mCallbacks.onVolumeChangedFromKey();
        }
        return changed;
    }
@@ -1029,6 +1030,18 @@ public class VolumeDialogControllerImpl implements VolumeDialogController, Dumpa
            }
        }

        @Override
        public void onVolumeChangedFromKey() {
            for (final Map.Entry<Callbacks, Handler> entry : mCallbackMap.entrySet()) {
                entry.getValue().post(new Runnable() {
                    @Override
                    public void run() {
                        entry.getKey().onVolumeChangedFromKey();
                    }
                });
            }
        }

        @Override
        public void onAccessibilityModeChanged(Boolean showA11yStream) {
            boolean show = showA11yStream != null && showA11yStream;
+116 −3
Original line number Diff line number Diff line
@@ -34,6 +34,7 @@ import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT;

import static com.android.internal.jank.InteractionJankMonitor.CUJ_VOLUME_CONTROL;
import static com.android.internal.jank.InteractionJankMonitor.Configuration.Builder;
import static com.android.systemui.Flags.hapticVolumeSlider;
import static com.android.systemui.volume.Events.DISMISS_REASON_POSTURE_CHANGED;
import static com.android.systemui.volume.Events.DISMISS_REASON_SETTINGS_CLICKED;

@@ -117,7 +118,11 @@ import com.android.internal.view.RotationPolicy;
import com.android.settingslib.Utils;
import com.android.systemui.Dumpable;
import com.android.systemui.Prefs;
import com.android.systemui.dagger.qualifiers.Application;
import com.android.systemui.dagger.qualifiers.Main;
import com.android.systemui.dump.DumpManager;
import com.android.systemui.haptics.slider.SeekableSliderHapticPlugin;
import com.android.systemui.haptics.slider.SliderHapticFeedbackConfig;
import com.android.systemui.media.dialog.MediaOutputDialogFactory;
import com.android.systemui.plugins.ActivityStarter;
import com.android.systemui.plugins.VolumeDialog;
@@ -125,6 +130,7 @@ import com.android.systemui.plugins.VolumeDialogController;
import com.android.systemui.plugins.VolumeDialogController.State;
import com.android.systemui.plugins.VolumeDialogController.StreamState;
import com.android.systemui.res.R;
import com.android.systemui.statusbar.VibratorHelper;
import com.android.systemui.statusbar.policy.AccessibilityManagerWrapper;
import com.android.systemui.statusbar.policy.ConfigurationController;
import com.android.systemui.statusbar.policy.DevicePostureController;
@@ -140,6 +146,9 @@ import java.util.ArrayList;
import java.util.List;
import java.util.function.Consumer;

import kotlinx.coroutines.CoroutineDispatcher;
import kotlinx.coroutines.CoroutineScope;

/**
 * Visual presentation of the volume dialog.
 *
@@ -303,6 +312,10 @@ public class VolumeDialogImpl implements VolumeDialog, Dumpable,
    private int mOrientation;
    private final Lazy<SecureSettings> mSecureSettings;
    private int mDialogTimeoutMillis;
    private final CoroutineDispatcher mMainDispatcher;
    private final CoroutineScope mApplicationScope;
    private final VibratorHelper mVibratorHelper;
    private final com.android.systemui.util.time.SystemClock mSystemClock;

    public VolumeDialogImpl(
            Context context,
@@ -319,11 +332,18 @@ public class VolumeDialogImpl implements VolumeDialog, Dumpable,
            DevicePostureController devicePostureController,
            Looper looper,
            DumpManager dumpManager,
            Lazy<SecureSettings> secureSettings) {
            Lazy<SecureSettings> secureSettings,
            VibratorHelper vibratorHelper,
            @Main CoroutineDispatcher mainDispatcher,
            @Application CoroutineScope applicationScope,
            com.android.systemui.util.time.SystemClock systemClock) {
        mContext =
                new ContextThemeWrapper(context, R.style.volume_dialog_theme);
        mHandler = new H(looper);

        mMainDispatcher = mainDispatcher;
        mApplicationScope = applicationScope;
        mVibratorHelper = vibratorHelper;
        mSystemClock = systemClock;
        mShouldListenForJank = shouldListenForJank;
        mController = volumeDialogController;
        mKeyguard = (KeyguardManager) mContext.getSystemService(Context.KEYGUARD_SERVICE);
@@ -839,6 +859,7 @@ public class VolumeDialogImpl implements VolumeDialog, Dumpable,
            row.header.setFilters(new InputFilter[] {new InputFilter.LengthFilter(13)});
        }
        row.slider = row.view.findViewById(R.id.volume_row_slider);
        row.createPlugin(mVibratorHelper, mSystemClock, mMainDispatcher, mApplicationScope);
        row.slider.setOnSeekBarChangeListener(new VolumeSeekBarChangeListener(row));
        row.number = row.view.findViewById(R.id.volume_number);

@@ -1480,6 +1501,12 @@ public class VolumeDialogImpl implements VolumeDialog, Dumpable,
        mController.getCaptionsComponentState(false);
        checkODICaptionsTooltip(false);
        updateBackgroundForDrawerClosedAmount();
        for (int i = 0; i < mRows.size(); i++) {
            VolumeRow row = mRows.get(i);
            if (row.slider.getVisibility() == VISIBLE) {
                row.addHaptics();
            }
        }
        Trace.endSection();
    }

@@ -1532,7 +1559,9 @@ public class VolumeDialogImpl implements VolumeDialog, Dumpable,

    protected void dismissH(int reason) {
        Trace.beginSection("VolumeDialogImpl#dismissH");

        for (int i = 0; i < mRows.size(); i++) {
            mRows.get(i).removeHaptics();
        }
        Log.i(TAG, "mDialog.dismiss() reason: " + Events.DISMISS_REASONS[reason]
                + " from: " + Debug.getCaller());

@@ -2358,6 +2387,14 @@ public class VolumeDialogImpl implements VolumeDialog, Dumpable,
        public void onCaptionEnabledStateChanged(Boolean isEnabled, Boolean checkForSwitchState) {
            updateCaptionsEnabledH(isEnabled, checkForSwitchState);
        }

        @Override
        public void onVolumeChangedFromKey() {
            VolumeRow activeRow = getActiveRow();
            if (activeRow.mHapticPlugin != null) {
                activeRow.mHapticPlugin.onKeyDown();
            }
        }
    };

    @VisibleForTesting void onPostureChanged(int posture) {
@@ -2459,6 +2496,15 @@ public class VolumeDialogImpl implements VolumeDialog, Dumpable,
        @Override
        public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) {
            if (mRow.ss == null) return;
            if (getActiveRow().equals(mRow)
                    && mRow.slider.getVisibility() == VISIBLE
                    && mRow.mHapticPlugin != null) {
                mRow.mHapticPlugin.onProgressChanged(seekBar, progress, fromUser);
                if (!fromUser) {
                    // Consider a change from program as the volume key being continuously pressed
                    mRow.mHapticPlugin.onKeyDown();
                }
            }
            if (D.BUG) Log.d(TAG, AudioSystem.streamToString(mRow.stream)
                    + " onProgressChanged " + progress + " fromUser=" + fromUser);
            if (!fromUser) return;
@@ -2485,6 +2531,9 @@ public class VolumeDialogImpl implements VolumeDialog, Dumpable,
        @Override
        public void onStartTrackingTouch(SeekBar seekBar) {
            if (D.BUG) Log.d(TAG, "onStartTrackingTouch"+ " " + mRow.stream);
            if (mRow.mHapticPlugin != null) {
                mRow.mHapticPlugin.onStartTrackingTouch(seekBar);
            }
            mController.setActiveStream(mRow.stream);
            mRow.tracking = true;
        }
@@ -2492,6 +2541,9 @@ public class VolumeDialogImpl implements VolumeDialog, Dumpable,
        @Override
        public void onStopTrackingTouch(SeekBar seekBar) {
            if (D.BUG) Log.d(TAG, "onStopTrackingTouch"+ " " + mRow.stream);
            if (mRow.mHapticPlugin != null) {
                mRow.mHapticPlugin.onStopTrackingTouch(seekBar);
            }
            mRow.tracking = false;
            mRow.userAttempt = SystemClock.uptimeMillis();
            final int userLevel = getImpliedLevel(seekBar, seekBar.getProgress());
@@ -2524,6 +2576,22 @@ public class VolumeDialogImpl implements VolumeDialog, Dumpable,
    }

    private static class VolumeRow {
        private static final SliderHapticFeedbackConfig sSliderHapticFeedbackConfig =
                new SliderHapticFeedbackConfig(
                /* velocityInterpolatorFactor= */ 1f,
                /* progressInterpolatorFactor= */ 1f,
                /* progressBasedDragMinScale= */ 0f,
                /* progressBasedDragMaxScale= */ 0.2f,
                /* additionalVelocityMaxBump= */ 0.15f,
                /* deltaMillisForDragInterval= */ 0f,
                /* deltaProgressForDragThreshold= */ 0.015f,
                /* numberOfLowTicks= */ 5,
                /* maxVelocityToScale= */ 300f,
                /* velocityAxis= */ MotionEvent.AXIS_Y,
                /* upperBookendScale= */ 1f,
                /* lowerBookendScale= */ 0.05f,
                /* exponent= */ 1f / 0.89f);

        private View view;
        private TextView header;
        private ImageButton icon;
@@ -2544,6 +2612,7 @@ public class VolumeDialogImpl implements VolumeDialog, Dumpable,
        private ObjectAnimator anim;  // slider progress animation for non-touch-related updates
        private int animTargetProgress;
        private int lastAudibleLevel = 1;
        private SeekableSliderHapticPlugin mHapticPlugin;

        void setIcon(int iconRes, Resources.Theme theme) {
            if (icon != null) {
@@ -2554,6 +2623,50 @@ public class VolumeDialogImpl implements VolumeDialog, Dumpable,
                sliderProgressIcon.setDrawable(view.getResources().getDrawable(iconRes, theme));
            }
        }

        void createPlugin(
                VibratorHelper vibratorHelper,
                com.android.systemui.util.time.SystemClock systemClock,
                CoroutineDispatcher mainDispatcher,
                CoroutineScope applicationScope) {
            if (!hapticVolumeSlider() || mHapticPlugin != null) return;

            mHapticPlugin = new SeekableSliderHapticPlugin(
                    vibratorHelper,
                    systemClock,
                    mainDispatcher,
                    applicationScope,
                    sSliderHapticFeedbackConfig);
        }


        @SuppressLint("ClickableViewAccessibility")
        void addTouchListener() {
            slider.setOnTouchListener(new View.OnTouchListener() {
                @Override
                public boolean onTouch(View view, MotionEvent motionEvent) {
                    if (mHapticPlugin != null) {
                        mHapticPlugin.onTouchEvent(motionEvent);
                    }
                    return false;
                }
            });
        }

        void addHaptics() {
            if (mHapticPlugin != null) {
                addTouchListener();
                mHapticPlugin.start();
            }
        }

        @SuppressLint("ClickableViewAccessibility")
        void removeHaptics() {
            slider.setOnTouchListener(null);
            if (mHapticPlugin != null) {
                mHapticPlugin.stop();
            }
        }
    }

    /**
Loading