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
This commit is contained in:
Michal Brzezinski
2023-07-12 18:55:09 +01:00
parent f966e40a2e
commit a10268d777
15 changed files with 455 additions and 54 deletions

View File

@@ -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<Rect> tintAreas, View view, int color) {
static int getTint(Collection<Rect> 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<Rect> areas, View view) {
static boolean isInAreas(Collection<Rect> areas, View view) {
if (areas.isEmpty()) {
return true;
}

View File

@@ -26,6 +26,8 @@
<color name="qs_detail_button_white">#B3FFFFFF</color><!-- 70% white -->
<color name="status_bar_clock_color">#FFFFFFFF</color>
<color name="qs_tile_disabled_color">#9E9E9E</color> <!-- 38% black -->
<color name="status_bar_icons_hover_color_light">#38FFFFFF</color> <!-- 22% white -->
<color name="status_bar_icons_hover_color_dark">#38000000</color> <!-- 22% black -->
<!-- The color of the background in the separated list of the Global Actions menu -->
<color name="global_actions_separated_background">#F5F5F5</color>

View File

@@ -475,6 +475,8 @@
<!-- Margin start of the system icons super container -->
<dimen name="system_icons_super_container_margin_start">16dp</dimen>
<dimen name="status_icons_hover_state_background_radius">16dp</dimen>
<!-- Width for the notification panel and related windows -->
<dimen name="match_parent">-1px</dimen>

View File

@@ -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);

View File

@@ -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<View>(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) {

View File

@@ -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<DarkChange> mDarkChangeFlow = StateFlowKt.MutableStateFlow(
DarkChange.EMPTY);
/**
*/
@Inject
@@ -66,6 +74,11 @@ public class DarkIconDispatcherImpl implements SysuiDarkIconDispatcher,
return mTransitionsController;
}
@Override
public StateFlow<DarkChange> 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);
}

View File

@@ -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<DarkChange> 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<DarkChange> darkChangeFlow() {
return FlowKt.asStateFlow(mDarkChange);
}
}

View File

@@ -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<KeyguardStatusBarView> {
private static final String TAG = "KeyguardStatusBarViewController";
@@ -119,6 +119,9 @@ public class KeyguardStatusBarViewController extends ViewController<KeyguardStat
private final Object mLock = new Object();
private final KeyguardLogger mLogger;
private View mSystemIconsContainer;
private final StatusOverlayHoverListenerFactory mStatusOverlayHoverListenerFactory;
// TODO(b/273443374): remove
private NotificationMediaManager mNotificationMediaManager;
@@ -286,7 +289,8 @@ public class KeyguardStatusBarViewController extends ViewController<KeyguardStat
CommandQueue commandQueue,
@Main Executor mainExecutor,
KeyguardLogger logger,
NotificationMediaManager notificationMediaManager
NotificationMediaManager notificationMediaManager,
StatusOverlayHoverListenerFactory statusOverlayHoverListenerFactory
) {
super(view);
mCarrierTextController = carrierTextController;
@@ -339,6 +343,7 @@ public class KeyguardStatusBarViewController extends ViewController<KeyguardStat
this::updateViewState
);
mNotificationMediaManager = notificationMediaManager;
mStatusOverlayHoverListenerFactory = statusOverlayHoverListenerFactory;
}
@Override
@@ -363,6 +368,10 @@ public class KeyguardStatusBarViewController extends ViewController<KeyguardStat
mTintedIconManager.setBlockList(getBlockedIcons());
mStatusBarIconController.addIconGroup(mTintedIconManager);
}
mSystemIconsContainer = mView.findViewById(R.id.system_icons);
StatusOverlayHoverListener hoverListener = mStatusOverlayHoverListenerFactory
.createDarkAwareListener(mSystemIconsContainer, mView.darkChangeFlow());
mSystemIconsContainer.setOnHoverListener(hoverListener);
mView.setOnApplyWindowInsetsListener(
(view, windowInsets) -> mView.updateWindowInsets(windowInsets, mInsetsProvider));
mSecureSettings.registerContentObserverForUser(
@@ -376,6 +385,7 @@ public class KeyguardStatusBarViewController extends ViewController<KeyguardStat
@Override
protected void onViewDetached() {
mSystemIconsContainer.setOnHoverListener(null);
mConfigurationController.removeCallback(mConfigurationListener);
mAnimationScheduler.removeCallback(mAnimationCallback);
mUserInfoController.removeCallback(mOnUserInfoChangedListener);

View File

@@ -63,9 +63,12 @@ class PhoneStatusBarViewController private constructor(
private val userChipViewModel: StatusBarUserChipViewModel,
private val viewUtil: ViewUtil,
private val featureFlags: FeatureFlags,
private val configurationController: ConfigurationController
private val configurationController: ConfigurationController,
private val statusOverlayHoverListenerFactory: StatusOverlayHoverListenerFactory,
) : ViewController<PhoneStatusBarView>(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,
)
}
}

View File

@@ -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<DarkChange>) =
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<HoverTheme>,
) : 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)
}
}

View File

@@ -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<DarkChange> darkChangeFlow();
/** Model for {@link #darkChangeFlow()} */
class DarkChange {
public static final DarkChange EMPTY = new DarkChange(new ArrayList<>(), 0, 0);
public DarkChange(Collection<Rect> areas, float darkIntensity, int tint) {
this.areas = areas;
this.darkIntensity = darkIntensity;
this.tint = tint;
}
public final Collection<Rect> areas;
public final float darkIntensity;
public final int tint;
}
}

View File

@@ -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()

View File

@@ -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
);
}

View File

@@ -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()
}

View File

@@ -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<ViewGroupOverlay>()
private val overlayCaptor = argumentCaptor<Drawable>()
private val darkDispatcher = mock<SysuiDarkIconDispatcher>()
private val darkChange: MutableStateFlow<DarkChange> = 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
)
}
}