From a10268d777faff23437424b151690ce78b2c77bd Mon Sep 17 00:00:00 2001 From: Michal Brzezinski Date: Wed, 12 Jul 2023 18:55:09 +0100 Subject: [PATCH] Adding hover state to system icons in status bar and shade header Adding StatusHoverListener which is added as onHoverListener to the status view that should be hoverable in keyguard status bar, regular status bar and shade header. Listener adds drawable overlay on top of the view on cursor hover. The color of the drawable is: - keyguard: managed manually from KeyboardStatusBarView (based on theme) - regular status bar: dependent on dark areas - shade header: always light Also adding StateFlow to SysuiDarkIconDispatcher so new consumers don't have to use regular listeners. Test: StatusHoverListenerTest Test: screenshot test coming soon Fixes: 283800652 Fixes: 249859409 Change-Id: Id9c63f5d125a662d98abe5c39f6e450a516e9823 --- .../systemui/plugins/DarkIconDispatcher.java | 5 +- packages/SystemUI/res/values/colors.xml | 2 + packages/SystemUI/res/values/dimens.xml | 2 + .../android/systemui/dagger/PluginModule.java | 5 + .../systemui/shade/ShadeHeaderController.kt | 6 + .../phone/DarkIconDispatcherImpl.java | 14 ++ .../phone/KeyguardStatusBarView.java | 58 ++---- .../KeyguardStatusBarViewController.java | 16 +- .../phone/PhoneStatusBarViewController.kt | 13 +- .../phone/StatusOverlayHoverListener.kt | 155 ++++++++++++++ .../phone/SysuiDarkIconDispatcher.java | 29 +++ .../shade/ShadeHeaderControllerTest.kt | 3 + .../KeyguardStatusBarViewControllerTest.java | 4 +- .../phone/PhoneStatusBarViewControllerTest.kt | 5 +- .../phone/StatusOverlayHoverListenerTest.kt | 192 ++++++++++++++++++ 15 files changed, 455 insertions(+), 54 deletions(-) create mode 100644 packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusOverlayHoverListener.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/StatusOverlayHoverListenerTest.kt diff --git a/packages/SystemUI/plugin/src/com/android/systemui/plugins/DarkIconDispatcher.java b/packages/SystemUI/plugin/src/com/android/systemui/plugins/DarkIconDispatcher.java index b33c5449c1eb6..a5e5aaa499f13 100644 --- a/packages/SystemUI/plugin/src/com/android/systemui/plugins/DarkIconDispatcher.java +++ b/packages/SystemUI/plugin/src/com/android/systemui/plugins/DarkIconDispatcher.java @@ -26,6 +26,7 @@ import com.android.systemui.plugins.annotations.DependsOn; import com.android.systemui.plugins.annotations.ProvidesInterface; import java.util.ArrayList; +import java.util.Collection; /** * Dispatches events to {@link DarkReceiver}s about changes in darkness, tint area and dark @@ -78,7 +79,7 @@ public interface DarkIconDispatcher { * @return the tint to apply to view depending on the desired tint color and * the screen tintArea in which to apply that tint */ - static int getTint(ArrayList tintAreas, View view, int color) { + static int getTint(Collection tintAreas, View view, int color) { if (isInAreas(tintAreas, view)) { return color; } else { @@ -90,7 +91,7 @@ public interface DarkIconDispatcher { * @return true if more than half of the view area are in any of the given * areas, false otherwise */ - static boolean isInAreas(ArrayList areas, View view) { + static boolean isInAreas(Collection areas, View view) { if (areas.isEmpty()) { return true; } diff --git a/packages/SystemUI/res/values/colors.xml b/packages/SystemUI/res/values/colors.xml index ab754985e11db..9f4fc396ae555 100644 --- a/packages/SystemUI/res/values/colors.xml +++ b/packages/SystemUI/res/values/colors.xml @@ -26,6 +26,8 @@ #B3FFFFFF #FFFFFFFF #9E9E9E + #38FFFFFF + #38000000 #F5F5F5 diff --git a/packages/SystemUI/res/values/dimens.xml b/packages/SystemUI/res/values/dimens.xml index 5a15dcec52233..9588498dddb38 100644 --- a/packages/SystemUI/res/values/dimens.xml +++ b/packages/SystemUI/res/values/dimens.xml @@ -475,6 +475,8 @@ 16dp + 16dp + -1px diff --git a/packages/SystemUI/src/com/android/systemui/dagger/PluginModule.java b/packages/SystemUI/src/com/android/systemui/dagger/PluginModule.java index a3e26b881a3bf..d727a70de377d 100644 --- a/packages/SystemUI/src/com/android/systemui/dagger/PluginModule.java +++ b/packages/SystemUI/src/com/android/systemui/dagger/PluginModule.java @@ -28,6 +28,7 @@ import com.android.systemui.plugins.statusbar.StatusBarStateController; import com.android.systemui.statusbar.StatusBarStateControllerImpl; import com.android.systemui.statusbar.phone.ActivityStarterImpl; import com.android.systemui.statusbar.phone.DarkIconDispatcherImpl; +import com.android.systemui.statusbar.phone.SysuiDarkIconDispatcher; import com.android.systemui.volume.VolumeDialogControllerImpl; import dagger.Binds; @@ -49,6 +50,10 @@ public abstract class PluginModule { @Binds abstract DarkIconDispatcher provideDarkIconDispatcher(DarkIconDispatcherImpl controllerImpl); + @Binds + abstract SysuiDarkIconDispatcher provideSysuiDarkIconDispatcher( + DarkIconDispatcherImpl controllerImpl); + /** */ @Binds abstract FalsingManager provideFalsingManager(FalsingManagerProxy falsingManagerImpl); diff --git a/packages/SystemUI/src/com/android/systemui/shade/ShadeHeaderController.kt b/packages/SystemUI/src/com/android/systemui/shade/ShadeHeaderController.kt index 529f12e0658ef..c6cb9c4d347c4 100644 --- a/packages/SystemUI/src/com/android/systemui/shade/ShadeHeaderController.kt +++ b/packages/SystemUI/src/com/android/systemui/shade/ShadeHeaderController.kt @@ -61,6 +61,7 @@ import com.android.systemui.statusbar.phone.StatusBarContentInsetsProvider import com.android.systemui.statusbar.phone.StatusBarIconController import com.android.systemui.statusbar.phone.StatusBarLocation import com.android.systemui.statusbar.phone.StatusIconContainer +import com.android.systemui.statusbar.phone.StatusOverlayHoverListenerFactory import com.android.systemui.statusbar.policy.Clock import com.android.systemui.statusbar.policy.ConfigurationController import com.android.systemui.statusbar.policy.NextAlarmController @@ -99,6 +100,7 @@ constructor( private val qsBatteryModeController: QsBatteryModeController, private val nextAlarmController: NextAlarmController, private val activityStarter: ActivityStarter, + private val statusOverlayHoverListenerFactory: StatusOverlayHoverListenerFactory, ) : ViewController(header), Dumpable { companion object { @@ -326,6 +328,9 @@ constructor( demoModeController.addCallback(demoModeReceiver) statusBarIconController.addIconGroup(iconManager) nextAlarmController.addCallback(nextAlarmCallback) + systemIcons.setOnHoverListener( + statusOverlayHoverListenerFactory.createListener(systemIcons) + ) } override fun onViewDetached() { @@ -336,6 +341,7 @@ constructor( demoModeController.removeCallback(demoModeReceiver) statusBarIconController.removeIconGroup(iconManager) nextAlarmController.removeCallback(nextAlarmCallback) + systemIcons.setOnHoverListener(null) } fun disable(state1: Int, state2: Int, animate: Boolean) { diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/DarkIconDispatcherImpl.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/DarkIconDispatcherImpl.java index 2677c3f2a8bd0..c4495edbda4ef 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/DarkIconDispatcherImpl.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/DarkIconDispatcherImpl.java @@ -32,6 +32,11 @@ import java.util.ArrayList; import javax.inject.Inject; +import kotlinx.coroutines.flow.FlowKt; +import kotlinx.coroutines.flow.MutableStateFlow; +import kotlinx.coroutines.flow.StateFlow; +import kotlinx.coroutines.flow.StateFlowKt; + /** */ @SysUISingleton @@ -47,6 +52,9 @@ public class DarkIconDispatcherImpl implements SysuiDarkIconDispatcher, private int mDarkModeIconColorSingleTone; private int mLightModeIconColorSingleTone; + private final MutableStateFlow mDarkChangeFlow = StateFlowKt.MutableStateFlow( + DarkChange.EMPTY); + /** */ @Inject @@ -66,6 +74,11 @@ public class DarkIconDispatcherImpl implements SysuiDarkIconDispatcher, return mTransitionsController; } + @Override + public StateFlow darkChangeFlow() { + return FlowKt.asStateFlow(mDarkChangeFlow); + } + public void addDarkReceiver(DarkReceiver receiver) { mReceivers.put(receiver, receiver); receiver.onDarkChanged(mTintAreas, mDarkIntensity, mIconTint); @@ -122,6 +135,7 @@ public class DarkIconDispatcherImpl implements SysuiDarkIconDispatcher, } private void applyIconTint() { + mDarkChangeFlow.setValue(new DarkChange(mTintAreas, mDarkIntensity, mIconTint)); for (int i = 0; i < mReceivers.size(); i++) { mReceivers.valueAt(i).onDarkChanged(mTintAreas, mDarkIntensity, mIconTint); } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/KeyguardStatusBarView.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/KeyguardStatusBarView.java index 720eeba0fd4e4..5c1f824bc6871 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/KeyguardStatusBarView.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/KeyguardStatusBarView.java @@ -34,7 +34,6 @@ import android.view.DisplayCutout; import android.view.Gravity; import android.view.View; import android.view.ViewGroup; -import android.view.ViewTreeObserver; import android.view.WindowInsets; import android.widget.ImageView; import android.widget.LinearLayout; @@ -43,11 +42,11 @@ import android.widget.TextView; import androidx.annotation.VisibleForTesting; -import com.android.app.animation.Interpolators; import com.android.settingslib.Utils; import com.android.systemui.R; import com.android.systemui.battery.BatteryMeterView; import com.android.systemui.plugins.DarkIconDispatcher.DarkReceiver; +import com.android.systemui.statusbar.phone.SysuiDarkIconDispatcher.DarkChange; import com.android.systemui.statusbar.phone.userswitcher.StatusBarUserSwitcherContainer; import com.android.systemui.user.ui.binder.StatusBarUserChipViewBinder; import com.android.systemui.user.ui.viewmodel.StatusBarUserChipViewModel; @@ -55,6 +54,11 @@ import com.android.systemui.user.ui.viewmodel.StatusBarUserChipViewModel; import java.io.PrintWriter; import java.util.ArrayList; +import kotlinx.coroutines.flow.FlowKt; +import kotlinx.coroutines.flow.MutableStateFlow; +import kotlinx.coroutines.flow.StateFlow; +import kotlinx.coroutines.flow.StateFlowKt; + /** * The header group on Keyguard. */ @@ -83,6 +87,8 @@ public class KeyguardStatusBarView extends RelativeLayout { private int mStatusBarPaddingEnd; private int mMinDotWidth; private View mSystemIconsContainer; + private final MutableStateFlow mDarkChange = StateFlowKt.MutableStateFlow( + DarkChange.EMPTY); private View mCutoutSpace; private ViewGroup mStatusIconArea; @@ -374,49 +380,6 @@ public class KeyguardStatusBarView extends RelativeLayout { return mKeyguardUserAvatarEnabled; } - private void animateNextLayoutChange() { - final int systemIconsCurrentX = mSystemIconsContainer.getLeft(); - final boolean userAvatarVisible = mMultiUserAvatar.getParent() == mStatusIconArea; - getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { - @Override - public boolean onPreDraw() { - getViewTreeObserver().removeOnPreDrawListener(this); - boolean userAvatarHiding = userAvatarVisible - && mMultiUserAvatar.getParent() != mStatusIconArea; - mSystemIconsContainer.setX(systemIconsCurrentX); - mSystemIconsContainer.animate() - .translationX(0) - .setDuration(400) - .setStartDelay(userAvatarHiding ? 300 : 0) - .setInterpolator(Interpolators.FAST_OUT_SLOW_IN) - .start(); - if (userAvatarHiding) { - getOverlay().add(mMultiUserAvatar); - mMultiUserAvatar.animate() - .alpha(0f) - .setDuration(300) - .setStartDelay(0) - .setInterpolator(Interpolators.ALPHA_OUT) - .withEndAction(() -> { - mMultiUserAvatar.setAlpha(1f); - getOverlay().remove(mMultiUserAvatar); - }) - .start(); - - } else { - mMultiUserAvatar.setAlpha(0f); - mMultiUserAvatar.animate() - .alpha(1f) - .setDuration(300) - .setStartDelay(200) - .setInterpolator(Interpolators.ALPHA_IN); - } - return true; - } - }); - - } - @Override public void setVisibility(int visibility) { super.setVisibility(visibility); @@ -474,6 +437,7 @@ public class KeyguardStatusBarView extends RelativeLayout { iconManager.setTint(iconColor); } + mDarkChange.setValue(new DarkChange(mEmptyTintRect, intensity, iconColor)); applyDarkness(R.id.battery, mEmptyTintRect, intensity, iconColor); applyDarkness(R.id.clock, mEmptyTintRect, intensity, iconColor); } @@ -536,4 +500,8 @@ public class KeyguardStatusBarView extends RelativeLayout { super.onMeasure(widthMeasureSpec, heightMeasureSpec); Trace.endSection(); } + + public StateFlow darkChangeFlow() { + return FlowKt.asStateFlow(mDarkChange); + } } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/KeyguardStatusBarViewController.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/KeyguardStatusBarViewController.java index 680f19a79a052..be336e59f534d 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/KeyguardStatusBarViewController.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/KeyguardStatusBarViewController.java @@ -69,8 +69,6 @@ import com.android.systemui.user.ui.viewmodel.StatusBarUserChipViewModel; import com.android.systemui.util.ViewController; import com.android.systemui.util.settings.SecureSettings; -import kotlin.Unit; - import java.io.PrintWriter; import java.util.ArrayList; import java.util.List; @@ -78,6 +76,8 @@ import java.util.concurrent.Executor; import javax.inject.Inject; +import kotlin.Unit; + /** View Controller for {@link com.android.systemui.statusbar.phone.KeyguardStatusBarView}. */ public class KeyguardStatusBarViewController extends ViewController { private static final String TAG = "KeyguardStatusBarViewController"; @@ -119,6 +119,9 @@ public class KeyguardStatusBarViewController extends ViewController mView.updateWindowInsets(windowInsets, mInsetsProvider)); mSecureSettings.registerContentObserverForUser( @@ -376,6 +385,7 @@ public class KeyguardStatusBarViewController extends ViewController(view) { + private lateinit var statusContainer: View + private val configurationListener = object : ConfigurationController.ConfigurationListener { override fun onConfigChanged(newConfig: Configuration?) { mView.updateResources() @@ -73,6 +76,9 @@ class PhoneStatusBarViewController private constructor( } override fun onViewAttached() { + statusContainer = mView.findViewById(R.id.system_icons) + statusContainer.setOnHoverListener( + statusOverlayHoverListenerFactory.createDarkAwareListener(statusContainer)) if (moveFromCenterAnimationController == null) return val statusBarLeftSide: View = mView.findViewById(R.id.status_bar_start_side_except_heads_up) @@ -104,6 +110,7 @@ class PhoneStatusBarViewController private constructor( } override fun onViewDetached() { + statusContainer.setOnHoverListener(null) progressProvider?.setReadyToHandleTransition(false) moveFromCenterAnimationController?.onViewDetached() configurationController.removeCallback(configurationListener) @@ -245,6 +252,7 @@ class PhoneStatusBarViewController private constructor( private val shadeLogger: ShadeLogger, private val viewUtil: ViewUtil, private val configurationController: ConfigurationController, + private val statusOverlayHoverListenerFactory: StatusOverlayHoverListenerFactory, ) { fun create( view: PhoneStatusBarView @@ -268,7 +276,8 @@ class PhoneStatusBarViewController private constructor( userChipViewModel, viewUtil, featureFlags, - configurationController + configurationController, + statusOverlayHoverListenerFactory, ) } } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusOverlayHoverListener.kt b/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusOverlayHoverListener.kt new file mode 100644 index 0000000000000..881741ae57461 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusOverlayHoverListener.kt @@ -0,0 +1,155 @@ +/* + * Copyright (C) 2023 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.statusbar.phone + +import android.content.res.Configuration +import android.content.res.Resources +import android.graphics.Color +import android.graphics.drawable.PaintDrawable +import android.view.MotionEvent +import android.view.View +import android.view.View.OnHoverListener +import androidx.annotation.ColorInt +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import com.android.systemui.R +import com.android.systemui.dagger.qualifiers.Main +import com.android.systemui.lifecycle.repeatWhenAttached +import com.android.systemui.plugins.DarkIconDispatcher +import com.android.systemui.statusbar.phone.SysuiDarkIconDispatcher.DarkChange +import com.android.systemui.statusbar.policy.ConfigurationController +import com.android.systemui.statusbar.policy.ConfigurationController.ConfigurationListener +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + +class StatusOverlayHoverListenerFactory +@Inject +constructor( + @Main private val resources: Resources, + private val configurationController: ConfigurationController, + private val darkIconDispatcher: SysuiDarkIconDispatcher, +) { + + /** Creates listener always using the same light color for overlay */ + fun createListener(view: View) = + StatusOverlayHoverListener( + view, + configurationController, + resources, + flowOf(HoverTheme.LIGHT), + ) + + /** + * Creates listener using [DarkIconDispatcher] to determine light or dark color of the overlay + */ + fun createDarkAwareListener(view: View) = + createDarkAwareListener(view, darkIconDispatcher.darkChangeFlow()) + + /** + * Creates listener using provided [DarkChange] producer to determine light or dark color of the + * overlay + */ + fun createDarkAwareListener(view: View, darkFlow: StateFlow) = + StatusOverlayHoverListener( + view, + configurationController, + resources, + darkFlow.map { toHoverTheme(view, it) }, + ) + + private fun toHoverTheme(view: View, darkChange: DarkChange): HoverTheme { + val calculatedTint = DarkIconDispatcher.getTint(darkChange.areas, view, darkChange.tint) + // currently calculated tint is either white or some shade of black. + // So checking for Color.WHITE is deterministic compared to checking for Color.BLACK. + // In the future checking Color.luminance() might be more appropriate. + return if (calculatedTint == Color.WHITE) HoverTheme.LIGHT else HoverTheme.DARK + } +} + +/** + * theme of hover drawable - it's different from device theme. This theme depends on view's + * background and/or dark value returned from [DarkIconDispatcher] + */ +enum class HoverTheme { + LIGHT, + DARK +} + +/** + * [OnHoverListener] that adds [Drawable] overlay on top of the status icons when cursor/stylus + * starts hovering over them and removes overlay when status icons are no longer hovered + */ +class StatusOverlayHoverListener( + view: View, + configurationController: ConfigurationController, + private val resources: Resources, + private val themeFlow: Flow, +) : OnHoverListener { + + @ColorInt private var darkColor: Int = 0 + @ColorInt private var lightColor: Int = 0 + private var cornerRadius = 0f + + private var lastTheme = HoverTheme.LIGHT + + val backgroundColor + get() = if (lastTheme == HoverTheme.LIGHT) lightColor else darkColor + + init { + view.repeatWhenAttached { + lifecycleScope.launch { + val configurationListener = + object : ConfigurationListener { + override fun onConfigChanged(newConfig: Configuration?) { + updateResources() + } + } + repeatOnLifecycle(Lifecycle.State.CREATED) { + configurationController.addCallback(configurationListener) + } + configurationController.removeCallback(configurationListener) + } + lifecycleScope.launch { themeFlow.collect { lastTheme = it } } + } + updateResources() + } + + override fun onHover(v: View, event: MotionEvent): Boolean { + if (event.action == MotionEvent.ACTION_HOVER_ENTER) { + val drawable = + PaintDrawable(backgroundColor).apply { + setCornerRadius(cornerRadius) + setBounds(0, 0, v.width, v.height) + } + v.overlay.add(drawable) + } else if (event.action == MotionEvent.ACTION_HOVER_EXIT) { + v.overlay.clear() + } + return true + } + + private fun updateResources() { + lightColor = resources.getColor(R.color.status_bar_icons_hover_color_light) + darkColor = resources.getColor(R.color.status_bar_icons_hover_color_dark) + cornerRadius = resources.getDimension(R.dimen.status_icons_hover_state_background_radius) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/SysuiDarkIconDispatcher.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/SysuiDarkIconDispatcher.java index d53772127601b..f5e90348a7c4c 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/SysuiDarkIconDispatcher.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/SysuiDarkIconDispatcher.java @@ -16,9 +16,16 @@ package com.android.systemui.statusbar.phone; +import android.graphics.Rect; + import com.android.systemui.Dumpable; import com.android.systemui.plugins.DarkIconDispatcher; +import java.util.ArrayList; +import java.util.Collection; + +import kotlinx.coroutines.flow.StateFlow; + /** * Dispatches events to {@link DarkReceiver}s about changes in darkness, tint area * and dark intensity. @@ -29,4 +36,26 @@ public interface SysuiDarkIconDispatcher extends DarkIconDispatcher, Dumpable { * @return LightBarTransitionsController */ LightBarTransitionsController getTransitionsController(); + + /** + * Flow equivalent of registering {@link DarkReceiver} using + * {@link DarkIconDispatcher#addDarkReceiver(DarkReceiver)} + */ + StateFlow darkChangeFlow(); + + /** Model for {@link #darkChangeFlow()} */ + class DarkChange { + + public static final DarkChange EMPTY = new DarkChange(new ArrayList<>(), 0, 0); + + public DarkChange(Collection areas, float darkIntensity, int tint) { + this.areas = areas; + this.darkIntensity = darkIntensity; + this.tint = tint; + } + + public final Collection areas; + public final float darkIntensity; + public final int tint; + } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/shade/ShadeHeaderControllerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/shade/ShadeHeaderControllerTest.kt index bf25f29752535..2501f85798aaa 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/shade/ShadeHeaderControllerTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/shade/ShadeHeaderControllerTest.kt @@ -55,6 +55,7 @@ import com.android.systemui.shade.carrier.ShadeCarrierGroupController import com.android.systemui.statusbar.phone.StatusBarContentInsetsProvider import com.android.systemui.statusbar.phone.StatusBarIconController import com.android.systemui.statusbar.phone.StatusIconContainer +import com.android.systemui.statusbar.phone.StatusOverlayHoverListenerFactory import com.android.systemui.statusbar.policy.Clock import com.android.systemui.statusbar.policy.FakeConfigurationController import com.android.systemui.statusbar.policy.NextAlarmController @@ -123,6 +124,7 @@ class ShadeHeaderControllerTest : SysuiTestCase() { @Mock private lateinit var qsBatteryModeController: QsBatteryModeController @Mock private lateinit var nextAlarmController: NextAlarmController @Mock private lateinit var activityStarter: ActivityStarter + @Mock private lateinit var mStatusOverlayHoverListenerFactory: StatusOverlayHoverListenerFactory @JvmField @Rule val mockitoRule = MockitoJUnit.rule() var viewVisibility = View.GONE @@ -194,6 +196,7 @@ class ShadeHeaderControllerTest : SysuiTestCase() { qsBatteryModeController, nextAlarmController, activityStarter, + mStatusOverlayHoverListenerFactory ) whenever(view.isAttachedToWindow).thenReturn(true) shadeHeaderController.init() diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/KeyguardStatusBarViewControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/KeyguardStatusBarViewControllerTest.java index e838a480f3cc1..d100c687d8027 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/KeyguardStatusBarViewControllerTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/KeyguardStatusBarViewControllerTest.java @@ -122,6 +122,7 @@ public class KeyguardStatusBarViewControllerTest extends SysuiTestCase { @Mock private KeyguardLogger mLogger; @Mock private NotificationMediaManager mNotificationMediaManager; + @Mock private StatusOverlayHoverListenerFactory mStatusOverlayHoverListenerFactory; private TestShadeViewStateProvider mShadeViewStateProvider; private KeyguardStatusBarView mKeyguardStatusBarView; @@ -171,7 +172,8 @@ public class KeyguardStatusBarViewControllerTest extends SysuiTestCase { mCommandQueue, mFakeExecutor, mLogger, - mNotificationMediaManager + mNotificationMediaManager, + mStatusOverlayHoverListenerFactory ); } diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/PhoneStatusBarViewControllerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/PhoneStatusBarViewControllerTest.kt index 7de0075c45ff1..2e92bb948c606 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/PhoneStatusBarViewControllerTest.kt +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/PhoneStatusBarViewControllerTest.kt @@ -69,6 +69,8 @@ class PhoneStatusBarViewControllerTest : SysuiTestCase() { @Mock private lateinit var configurationController: ConfigurationController @Mock + private lateinit var mStatusOverlayHoverListenerFactory: StatusOverlayHoverListenerFactory + @Mock private lateinit var userChipViewModel: StatusBarUserChipViewModel @Mock private lateinit var centralSurfacesImpl: CentralSurfacesImpl @@ -204,7 +206,8 @@ class PhoneStatusBarViewControllerTest : SysuiTestCase() { sceneInteractor, shadeLogger, viewUtil, - configurationController + configurationController, + mStatusOverlayHoverListenerFactory ).create(view).also { it.init() } diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/StatusOverlayHoverListenerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/StatusOverlayHoverListenerTest.kt new file mode 100644 index 0000000000000..63508e193bb8a --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/StatusOverlayHoverListenerTest.kt @@ -0,0 +1,192 @@ +/* + * Copyright (C) 2023 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.statusbar.phone + +import android.content.Context +import android.graphics.Color +import android.graphics.drawable.Drawable +import android.graphics.drawable.PaintDrawable +import android.os.SystemClock +import android.testing.AndroidTestingRunner +import android.testing.TestableLooper +import android.testing.TestableLooper.RunWithLooper +import android.testing.ViewUtils +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroupOverlay +import android.widget.LinearLayout +import androidx.annotation.ColorInt +import androidx.test.filters.SmallTest +import com.android.systemui.R +import com.android.systemui.SysuiTestCase +import com.android.systemui.statusbar.phone.SysuiDarkIconDispatcher.DarkChange +import com.android.systemui.statusbar.policy.FakeConfigurationController +import com.android.systemui.util.mockito.argumentCaptor +import com.android.systemui.util.mockito.mock +import com.android.systemui.util.mockito.whenever +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.verify + +@RunWith(AndroidTestingRunner::class) +@RunWithLooper(setAsMainLooper = true) +@SmallTest +class StatusOverlayHoverListenerTest : SysuiTestCase() { + + private val viewOverlay = mock() + private val overlayCaptor = argumentCaptor() + private val darkDispatcher = mock() + private val darkChange: MutableStateFlow = MutableStateFlow(DarkChange.EMPTY) + + private val factory = + StatusOverlayHoverListenerFactory( + context.resources, + FakeConfigurationController(), + darkDispatcher + ) + private val view = TestableStatusContainer(context, viewOverlay) + + private lateinit var looper: TestableLooper + + @Before + fun setUp() { + looper = TestableLooper.get(this) + whenever(darkDispatcher.darkChangeFlow()).thenReturn(darkChange) + } + + @Test + fun onHoverStarted_addsOverlay() { + view.setUpHoverListener() + + view.hoverStarted() + + assertThat(overlayDrawable).isNotNull() + } + + @Test + fun onHoverEnded_removesOverlay() { + view.setUpHoverListener() + + view.hoverStarted() // stopped callback will be called only if hover has started + view.hoverStopped() + + verify(viewOverlay).clear() + } + + @Test + fun onHoverStarted_overlayHasLightColor() { + view.setUpHoverListener() + + view.hoverStarted() + + assertThat(overlayColor) + .isEqualTo(context.resources.getColor(R.color.status_bar_icons_hover_color_light)) + } + + @Test + fun onDarkAwareHoverStarted_withBlackIcons_overlayHasDarkColor() { + view.setUpDarkAwareHoverListener() + setIconsTint(Color.BLACK) + + view.hoverStarted() + + assertThat(overlayColor) + .isEqualTo(context.resources.getColor(R.color.status_bar_icons_hover_color_dark)) + } + + @Test + fun onHoverStarted_withBlackIcons_overlayHasLightColor() { + view.setUpHoverListener() + setIconsTint(Color.BLACK) + + view.hoverStarted() + + assertThat(overlayColor) + .isEqualTo(context.resources.getColor(R.color.status_bar_icons_hover_color_light)) + } + + @Test + fun onDarkAwareHoverStarted_withWhiteIcons_overlayHasLightColor() { + view.setUpDarkAwareHoverListener() + setIconsTint(Color.WHITE) + + view.hoverStarted() + + assertThat(overlayColor) + .isEqualTo(context.resources.getColor(R.color.status_bar_icons_hover_color_light)) + } + + private fun View.setUpHoverListener() { + setOnHoverListener(factory.createListener(view)) + attachView(view) + } + + private fun View.setUpDarkAwareHoverListener() { + setOnHoverListener(factory.createDarkAwareListener(view)) + attachView(view) + } + + private fun attachView(view: View) { + ViewUtils.attachView(view) + // attaching is async so processAllMessages is required for view.repeatWhenAttached to run + looper.processAllMessages() + } + + private val overlayDrawable: Drawable + get() { + verify(viewOverlay).add(overlayCaptor.capture()) + return overlayCaptor.value + } + + private val overlayColor + get() = (overlayDrawable as PaintDrawable).paint.color + + private fun setIconsTint(@ColorInt color: Int) { + // passing empty ArrayList is equivalent to just accepting passed color as icons color + darkChange.value = DarkChange(/* areas= */ ArrayList(), /* darkIntensity= */ 1f, color) + } + + private fun TestableStatusContainer.hoverStarted() { + injectHoverEvent(hoverEvent(MotionEvent.ACTION_HOVER_ENTER)) + } + + private fun TestableStatusContainer.hoverStopped() { + injectHoverEvent(hoverEvent(MotionEvent.ACTION_HOVER_EXIT)) + } + + class TestableStatusContainer(context: Context, private val mockOverlay: ViewGroupOverlay) : + LinearLayout(context) { + + fun injectHoverEvent(event: MotionEvent) = dispatchHoverEvent(event) + + override fun getOverlay() = mockOverlay + } + + private fun hoverEvent(action: Int): MotionEvent { + return MotionEvent.obtain( + /* downTime= */ SystemClock.uptimeMillis(), + /* eventTime= */ SystemClock.uptimeMillis(), + /* action= */ action, + /* x= */ 0f, + /* y= */ 0f, + /* metaState= */ 0 + ) + } +}