Loading packages/SystemUI/multivalentTests/src/com/android/systemui/haptics/slider/SeekableSliderHapticPluginTest.kt 0 → 100644 +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 } } packages/SystemUI/plugin/src/com/android/systemui/plugins/VolumeDialogController.java +5 −0 Original line number Diff line number Diff line Loading @@ -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(); } } packages/SystemUI/src/com/android/systemui/haptics/slider/SeekableSliderHapticPlugin.kt 0 → 100644 +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 } } packages/SystemUI/src/com/android/systemui/volume/VolumeDialogControllerImpl.java +13 −0 Original line number Diff line number Diff line Loading @@ -535,6 +535,7 @@ public class VolumeDialogControllerImpl implements VolumeDialogController, Dumpa } if (changed && fromKey) { Events.writeEvent(Events.EVENT_KEY, stream, lastAudibleStreamVolume); mCallbacks.onVolumeChangedFromKey(); } return changed; } Loading Loading @@ -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; Loading packages/SystemUI/src/com/android/systemui/volume/VolumeDialogImpl.java +116 −3 Original line number Diff line number Diff line Loading @@ -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; Loading Loading @@ -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; Loading @@ -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; Loading @@ -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. * Loading Loading @@ -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, Loading @@ -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); Loading Loading @@ -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); Loading Loading @@ -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(); } Loading Loading @@ -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()); Loading Loading @@ -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) { Loading Loading @@ -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; Loading @@ -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; } Loading @@ -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()); Loading Loading @@ -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; Loading @@ -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) { Loading @@ -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 Loading
packages/SystemUI/multivalentTests/src/com/android/systemui/haptics/slider/SeekableSliderHapticPluginTest.kt 0 → 100644 +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 } }
packages/SystemUI/plugin/src/com/android/systemui/plugins/VolumeDialogController.java +5 −0 Original line number Diff line number Diff line Loading @@ -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(); } }
packages/SystemUI/src/com/android/systemui/haptics/slider/SeekableSliderHapticPlugin.kt 0 → 100644 +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 } }
packages/SystemUI/src/com/android/systemui/volume/VolumeDialogControllerImpl.java +13 −0 Original line number Diff line number Diff line Loading @@ -535,6 +535,7 @@ public class VolumeDialogControllerImpl implements VolumeDialogController, Dumpa } if (changed && fromKey) { Events.writeEvent(Events.EVENT_KEY, stream, lastAudibleStreamVolume); mCallbacks.onVolumeChangedFromKey(); } return changed; } Loading Loading @@ -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; Loading
packages/SystemUI/src/com/android/systemui/volume/VolumeDialogImpl.java +116 −3 Original line number Diff line number Diff line Loading @@ -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; Loading Loading @@ -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; Loading @@ -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; Loading @@ -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. * Loading Loading @@ -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, Loading @@ -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); Loading Loading @@ -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); Loading Loading @@ -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(); } Loading Loading @@ -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()); Loading Loading @@ -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) { Loading Loading @@ -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; Loading @@ -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; } Loading @@ -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()); Loading Loading @@ -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; Loading @@ -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) { Loading @@ -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