From 73260bd178c326f31bbd8e999e87aa4748f285c7 Mon Sep 17 00:00:00 2001 From: Alejandro Nijamkin Date: Fri, 20 Jan 2023 16:40:42 -0800 Subject: [PATCH] Lock screen long-press to open wallpaper picker. Implements a long-press gesture on the lock screen that opens the wallpaper picker (WPP) screen (after properly unlocking the device). - Respects device state - Only works on the lock screen - does not work when unlocked, quick or quick-quick settings is shown, etc. - Lets other lock screen UI components receive and handle touch (including smart space, unlock/UDFPS, shortcuts, shade, and bouncer) - Flag gated - this is a brand new flag as we're not sure whether we want to roll this out and it can be rolled out berore the WPP revamp project - Logging included - logs long-press popup impressions and clicks, to help inform launch/no-launch decisions and identify falsing challenges Fix: 265987364 Test: manually verified that it matches the UX spec: does not work unless locked and showing the lock screen un-occluded (tested occlusion with the camera app). Does not work if quick settings or quick-quick settings is visible. Does not work if unlocked with or without the shade being shown. Test: manually verified that smart space clicks, notification clicks and long presses, UDFPS, and shortcut buttons all still work as advertised. Test: unit tests included Change-Id: I600f3e60a03b58691ffd66cddb1be6ff0c80b3ca --- ...eyguard_settings_popup_menu_background.xml | 33 +++ .../layout/keyguard_settings_popup_menu.xml | 50 ++++ .../res/layout/status_bar_expanded.xml | 5 + packages/SystemUI/res/values/config.xml | 4 + packages/SystemUI/res/values/dimens.xml | 6 + packages/SystemUI/res/values/strings.xml | 9 + .../common/ui/view/LongPressHandlingView.kt | 123 ++++++++++ ...LongPressHandlingViewInteractionHandler.kt | 132 +++++++++++ .../src/com/android/systemui/flags/Flags.kt | 15 +- .../data/repository/KeyguardRepository.kt | 13 ++ .../domain/interactor/KeyguardInteractor.kt | 5 + .../interactor/KeyguardLongPressInteractor.kt | 187 +++++++++++++++ .../model/KeyguardSettingsPopupMenuModel.kt | 30 +++ .../KeyguardLongPressPopupViewBinder.kt | 88 ++++++++ .../ui/binder/KeyguardLongPressViewBinder.kt | 95 ++++++++ .../viewmodel/KeyguardLongPressViewModel.kt | 69 ++++++ .../KeyguardSettingsPopupMenuViewModel.kt | 34 +++ .../NotificationPanelViewController.java | 19 +- ...PressHandlingViewInteractionHandlerTest.kt | 163 ++++++++++++++ .../KeyguardLongPressInteractorTest.kt | 213 ++++++++++++++++++ .../NotificationPanelViewControllerTest.java | 14 +- .../data/repository/FakeKeyguardRepository.kt | 12 + .../FakeKeyguardTransitionRepository.kt | 4 +- 23 files changed, 1315 insertions(+), 8 deletions(-) create mode 100644 packages/SystemUI/res/drawable/keyguard_settings_popup_menu_background.xml create mode 100644 packages/SystemUI/res/layout/keyguard_settings_popup_menu.xml create mode 100644 packages/SystemUI/src/com/android/systemui/common/ui/view/LongPressHandlingView.kt create mode 100644 packages/SystemUI/src/com/android/systemui/common/ui/view/LongPressHandlingViewInteractionHandler.kt create mode 100644 packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/KeyguardLongPressInteractor.kt create mode 100644 packages/SystemUI/src/com/android/systemui/keyguard/domain/model/KeyguardSettingsPopupMenuModel.kt create mode 100644 packages/SystemUI/src/com/android/systemui/keyguard/ui/binder/KeyguardLongPressPopupViewBinder.kt create mode 100644 packages/SystemUI/src/com/android/systemui/keyguard/ui/binder/KeyguardLongPressViewBinder.kt create mode 100644 packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/KeyguardLongPressViewModel.kt create mode 100644 packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/KeyguardSettingsPopupMenuViewModel.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/common/ui/view/LongPressHandlingViewInteractionHandlerTest.kt create mode 100644 packages/SystemUI/tests/src/com/android/systemui/keyguard/domain/interactor/KeyguardLongPressInteractorTest.kt diff --git a/packages/SystemUI/res/drawable/keyguard_settings_popup_menu_background.xml b/packages/SystemUI/res/drawable/keyguard_settings_popup_menu_background.xml new file mode 100644 index 0000000000000..3807b92ae39df --- /dev/null +++ b/packages/SystemUI/res/drawable/keyguard_settings_popup_menu_background.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + diff --git a/packages/SystemUI/res/layout/keyguard_settings_popup_menu.xml b/packages/SystemUI/res/layout/keyguard_settings_popup_menu.xml new file mode 100644 index 0000000000000..89d88fea18170 --- /dev/null +++ b/packages/SystemUI/res/layout/keyguard_settings_popup_menu.xml @@ -0,0 +1,50 @@ + + + + + + + + + \ No newline at end of file diff --git a/packages/SystemUI/res/layout/status_bar_expanded.xml b/packages/SystemUI/res/layout/status_bar_expanded.xml index 159323a5b557b..3c860a9c2c31d 100644 --- a/packages/SystemUI/res/layout/status_bar_expanded.xml +++ b/packages/SystemUI/res/layout/status_bar_expanded.xml @@ -26,6 +26,11 @@ android:layout_height="match_parent" android:background="@android:color/transparent"> + + bottom_end:wallet + + + com.android.wallpaper + diff --git a/packages/SystemUI/res/values/dimens.xml b/packages/SystemUI/res/values/dimens.xml index 1ef020643a900..dfc01501a9997 100644 --- a/packages/SystemUI/res/values/dimens.xml +++ b/packages/SystemUI/res/values/dimens.xml @@ -1648,4 +1648,10 @@ 200dp 24dp 16dp + + + 96dp diff --git a/packages/SystemUI/res/values/strings.xml b/packages/SystemUI/res/values/strings.xml index 066b185c6d1f1..3a4b9548a1dd1 100644 --- a/packages/SystemUI/res/values/strings.xml +++ b/packages/SystemUI/res/values/strings.xml @@ -2798,4 +2798,13 @@ Close + + + Lock screen settings diff --git a/packages/SystemUI/src/com/android/systemui/common/ui/view/LongPressHandlingView.kt b/packages/SystemUI/src/com/android/systemui/common/ui/view/LongPressHandlingView.kt new file mode 100644 index 0000000000000..2dd98dcf41b6e --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/common/ui/view/LongPressHandlingView.kt @@ -0,0 +1,123 @@ +/* + * 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.common.ui.view + +import android.annotation.SuppressLint +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.View +import kotlin.math.pow +import kotlin.math.sqrt +import kotlinx.coroutines.DisposableHandle + +/** + * View designed to handle long-presses. + * + * The view will not handle any long pressed by default. To set it up, set up a listener and, when + * ready to start consuming long-presses, set [setLongPressHandlingEnabled] to `true`. + */ +class LongPressHandlingView( + context: Context, + attrs: AttributeSet?, +) : + View( + context, + attrs, + ) { + interface Listener { + /** Notifies that a long-press has been detected by the given view. */ + fun onLongPressDetected( + view: View, + x: Int, + y: Int, + ) + + /** Notifies that the gesture was too short for a long press, it is actually a click. */ + fun onSingleTapDetected(view: View) = Unit + } + + var listener: Listener? = null + + private val interactionHandler: LongPressHandlingViewInteractionHandler by lazy { + LongPressHandlingViewInteractionHandler( + postDelayed = { block, timeoutMs -> + val dispatchToken = Any() + + handler.postDelayed( + block, + dispatchToken, + timeoutMs, + ) + + DisposableHandle { handler.removeCallbacksAndMessages(dispatchToken) } + }, + isAttachedToWindow = ::isAttachedToWindow, + onLongPressDetected = { x, y -> + listener?.onLongPressDetected( + view = this, + x = x, + y = y, + ) + }, + onSingleTapDetected = { listener?.onSingleTapDetected(this@LongPressHandlingView) }, + ) + } + + fun setLongPressHandlingEnabled(isEnabled: Boolean) { + interactionHandler.isLongPressHandlingEnabled = isEnabled + } + + @SuppressLint("ClickableViewAccessibility") + override fun onTouchEvent(event: MotionEvent?): Boolean { + return interactionHandler.onTouchEvent(event?.toModel()) + } +} + +private fun MotionEvent.toModel(): LongPressHandlingViewInteractionHandler.MotionEventModel { + return when (actionMasked) { + MotionEvent.ACTION_DOWN -> + LongPressHandlingViewInteractionHandler.MotionEventModel.Down( + x = x.toInt(), + y = y.toInt(), + ) + MotionEvent.ACTION_MOVE -> + LongPressHandlingViewInteractionHandler.MotionEventModel.Move( + distanceMoved = distanceMoved(), + ) + MotionEvent.ACTION_UP -> + LongPressHandlingViewInteractionHandler.MotionEventModel.Up( + distanceMoved = distanceMoved(), + gestureDuration = gestureDuration(), + ) + MotionEvent.ACTION_CANCEL -> LongPressHandlingViewInteractionHandler.MotionEventModel.Cancel + else -> LongPressHandlingViewInteractionHandler.MotionEventModel.Other + } +} + +private fun MotionEvent.distanceMoved(): Float { + return if (historySize > 0) { + sqrt((x - getHistoricalX(0)).pow(2) + (y - getHistoricalY(0)).pow(2)) + } else { + 0f + } +} + +private fun MotionEvent.gestureDuration(): Long { + return eventTime - downTime +} diff --git a/packages/SystemUI/src/com/android/systemui/common/ui/view/LongPressHandlingViewInteractionHandler.kt b/packages/SystemUI/src/com/android/systemui/common/ui/view/LongPressHandlingViewInteractionHandler.kt new file mode 100644 index 0000000000000..c2d4d12d03c32 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/common/ui/view/LongPressHandlingViewInteractionHandler.kt @@ -0,0 +1,132 @@ +/* + * 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.common.ui.view + +import android.view.ViewConfiguration +import kotlinx.coroutines.DisposableHandle + +/** Encapsulates logic to handle complex touch interactions with a [LongPressHandlingView]. */ +class LongPressHandlingViewInteractionHandler( + /** + * Callback to run the given [Runnable] with the given delay, returning a [DisposableHandle] + * allowing the delayed runnable to be canceled before it is run. + */ + private val postDelayed: (block: Runnable, delayMs: Long) -> DisposableHandle, + /** Callback to be queried to check if the view is attached to its window. */ + private val isAttachedToWindow: () -> Boolean, + /** Callback reporting the a long-press gesture was detected at the given coordinates. */ + private val onLongPressDetected: (x: Int, y: Int) -> Unit, + /** Callback reporting the a single tap gesture was detected at the given coordinates. */ + private val onSingleTapDetected: () -> Unit, +) { + sealed class MotionEventModel { + object Other : MotionEventModel() + + data class Down( + val x: Int, + val y: Int, + ) : MotionEventModel() + + data class Move( + val distanceMoved: Float, + ) : MotionEventModel() + + data class Up( + val distanceMoved: Float, + val gestureDuration: Long, + ) : MotionEventModel() + + object Cancel : MotionEventModel() + } + + var isLongPressHandlingEnabled: Boolean = false + var scheduledLongPressHandle: DisposableHandle? = null + + fun onTouchEvent(event: MotionEventModel?): Boolean { + if (!isLongPressHandlingEnabled) { + return false + } + + return when (event) { + is MotionEventModel.Down -> { + scheduleLongPress(event.x, event.y) + true + } + is MotionEventModel.Move -> { + if (event.distanceMoved > ViewConfiguration.getTouchSlop()) { + cancelScheduledLongPress() + } + false + } + is MotionEventModel.Up -> { + cancelScheduledLongPress() + if ( + event.distanceMoved <= ViewConfiguration.getTouchSlop() && + event.gestureDuration < ViewConfiguration.getLongPressTimeout() + ) { + dispatchSingleTap() + } + false + } + is MotionEventModel.Cancel -> { + cancelScheduledLongPress() + false + } + else -> false + } + } + + private fun scheduleLongPress( + x: Int, + y: Int, + ) { + scheduledLongPressHandle = + postDelayed( + { + dispatchLongPress( + x = x, + y = y, + ) + }, + ViewConfiguration.getLongPressTimeout().toLong(), + ) + } + + private fun dispatchLongPress( + x: Int, + y: Int, + ) { + if (!isAttachedToWindow()) { + return + } + + onLongPressDetected(x, y) + } + + private fun cancelScheduledLongPress() { + scheduledLongPressHandle?.dispose() + } + + private fun dispatchSingleTap() { + if (!isAttachedToWindow()) { + return + } + + onSingleTapDetected() + } +} diff --git a/packages/SystemUI/src/com/android/systemui/flags/Flags.kt b/packages/SystemUI/src/com/android/systemui/flags/Flags.kt index 9a28ab9f78114..c142e3f77fa8e 100644 --- a/packages/SystemUI/src/com/android/systemui/flags/Flags.kt +++ b/packages/SystemUI/src/com/android/systemui/flags/Flags.kt @@ -65,8 +65,7 @@ object Flags { val FSI_ON_DND_UPDATE = unreleasedFlag(259130119, "fsi_on_dnd_update", teamfood = true) // TODO(b/265804648): Tracking Bug - @JvmField - val DISABLE_FSI = unreleasedFlag(265804648, "disable_fsi") + @JvmField val DISABLE_FSI = unreleasedFlag(265804648, "disable_fsi") // TODO(b/254512538): Tracking Bug val INSTANT_VOICE_REPLY = unreleasedFlag(111, "instant_voice_reply", teamfood = true) @@ -221,6 +220,15 @@ object Flags { val WALLPAPER_FULLSCREEN_PREVIEW = unreleasedFlag(227, "wallpaper_fullscreen_preview", teamfood = true) + /** Whether the long-press gesture to open wallpaper picker is enabled. */ + // TODO(b/266242192): Tracking Bug + @JvmField + val LOCK_SCREEN_LONG_PRESS_ENABLED = + unreleasedFlag( + 228, + "lock_screen_long_press_enabled", + ) + // 300 - power menu // TODO(b/254512600): Tracking Bug @JvmField val POWER_MENU_LITE = releasedFlag(300, "power_menu_lite") @@ -570,6 +578,5 @@ object Flags { // 2600 - keyboard shortcut // TODO(b/259352579): Tracking Bug - @JvmField - val SHORTCUT_LIST_SEARCH_LAYOUT = unreleasedFlag(2600, "shortcut_list_search_layout") + @JvmField val SHORTCUT_LIST_SEARCH_LAYOUT = unreleasedFlag(2600, "shortcut_list_search_layout") } diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/data/repository/KeyguardRepository.kt b/packages/SystemUI/src/com/android/systemui/keyguard/data/repository/KeyguardRepository.kt index d99af90ab6dcd..db955622f707b 100644 --- a/packages/SystemUI/src/com/android/systemui/keyguard/data/repository/KeyguardRepository.kt +++ b/packages/SystemUI/src/com/android/systemui/keyguard/data/repository/KeyguardRepository.kt @@ -148,6 +148,9 @@ interface KeyguardRepository { /** Source of the most recent biometric unlock, such as fingerprint or face. */ val biometricUnlockSource: Flow + /** Whether quick settings or quick-quick settings is visible. */ + val isQuickSettingsVisible: Flow + /** * Returns `true` if the keyguard is showing; `false` otherwise. * @@ -172,6 +175,9 @@ interface KeyguardRepository { * Returns whether the keyguard bottom area should be constrained to the top of the lock icon */ fun isUdfpsSupported(): Boolean + + /** Sets whether quick settings or quick-quick settings is visible. */ + fun setQuickSettingsVisible(isVisible: Boolean) } /** Encapsulates application state for the keyguard. */ @@ -581,6 +587,9 @@ constructor( awaitClose { keyguardUpdateMonitor.removeCallback(callback) } } + private val _isQuickSettingsVisible = MutableStateFlow(false) + override val isQuickSettingsVisible: Flow = _isQuickSettingsVisible.asStateFlow() + override fun setAnimateDozingTransitions(animate: Boolean) { _animateBottomAreaDozingTransitions.value = animate } @@ -595,6 +604,10 @@ constructor( override fun isUdfpsSupported(): Boolean = keyguardUpdateMonitor.isUdfpsSupported + override fun setQuickSettingsVisible(isVisible: Boolean) { + _isQuickSettingsVisible.value = isVisible + } + private fun statusBarStateIntToObject(value: Int): StatusBarState { return when (value) { 0 -> StatusBarState.SHADE diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/KeyguardInteractor.kt b/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/KeyguardInteractor.kt index 4cf56fe2c0310..3d39da626f0d4 100644 --- a/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/KeyguardInteractor.kt +++ b/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/KeyguardInteractor.kt @@ -155,6 +155,11 @@ constructor( } } + /** Sets whether quick settings or quick-quick settings is visible. */ + fun setQuickSettingsVisible(isVisible: Boolean) { + repository.setQuickSettingsVisible(isVisible) + } + companion object { private const val TAG = "KeyguardInteractor" } diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/KeyguardLongPressInteractor.kt b/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/KeyguardLongPressInteractor.kt new file mode 100644 index 0000000000000..6525a13fc44f7 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/keyguard/domain/interactor/KeyguardLongPressInteractor.kt @@ -0,0 +1,187 @@ +/* + * 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.keyguard.domain.interactor + +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import com.android.internal.logging.UiEvent +import com.android.internal.logging.UiEventLogger +import com.android.systemui.R +import com.android.systemui.broadcast.BroadcastDispatcher +import com.android.systemui.common.shared.model.Position +import com.android.systemui.dagger.SysUISingleton +import com.android.systemui.dagger.qualifiers.Application +import com.android.systemui.flags.FeatureFlags +import com.android.systemui.flags.Flags +import com.android.systemui.keyguard.data.repository.KeyguardRepository +import com.android.systemui.keyguard.domain.model.KeyguardSettingsPopupMenuModel +import com.android.systemui.keyguard.shared.model.KeyguardState +import com.android.systemui.plugins.ActivityStarter +import javax.inject.Inject +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn + +/** Business logic for use-cases related to the keyguard long-press feature. */ +@OptIn(ExperimentalCoroutinesApi::class) +@SysUISingleton +class KeyguardLongPressInteractor +@Inject +constructor( + @Application unsafeContext: Context, + @Application scope: CoroutineScope, + transitionInteractor: KeyguardTransitionInteractor, + repository: KeyguardRepository, + private val activityStarter: ActivityStarter, + private val logger: UiEventLogger, + private val featureFlags: FeatureFlags, + broadcastDispatcher: BroadcastDispatcher, +) { + private val appContext = unsafeContext.applicationContext + + private val _isLongPressHandlingEnabled: StateFlow = + if (isFeatureEnabled()) { + combine( + transitionInteractor.finishedKeyguardState.map { + it == KeyguardState.LOCKSCREEN + }, + repository.isQuickSettingsVisible, + ) { isFullyTransitionedToLockScreen, isQuickSettingsVisible -> + isFullyTransitionedToLockScreen && !isQuickSettingsVisible + } + } else { + flowOf(false) + } + .stateIn( + scope = scope, + started = SharingStarted.WhileSubscribed(), + initialValue = false, + ) + + /** Whether the long-press handling feature should be enabled. */ + val isLongPressHandlingEnabled: Flow = _isLongPressHandlingEnabled + + private val _menu = MutableStateFlow(null) + /** Model for a menu that should be shown; `null` when no menu should be shown. */ + val menu: Flow = + isLongPressHandlingEnabled.flatMapLatest { isEnabled -> + if (isEnabled) { + _menu + } else { + flowOf(null) + } + } + + init { + if (isFeatureEnabled()) { + broadcastDispatcher + .broadcastFlow( + IntentFilter(Intent.ACTION_CLOSE_SYSTEM_DIALOGS), + ) + .onEach { hideMenu() } + .launchIn(scope) + } + } + + /** Notifies that the user has long-pressed on the lock screen. */ + fun onLongPress(x: Int, y: Int) { + if (!_isLongPressHandlingEnabled.value) { + return + } + + showMenu( + x = x, + y = y, + ) + } + + private fun isFeatureEnabled(): Boolean { + return featureFlags.isEnabled(Flags.LOCK_SCREEN_LONG_PRESS_ENABLED) && + featureFlags.isEnabled(Flags.REVAMPED_WALLPAPER_UI) + } + + /** Updates application state to ask to show the menu at the given coordinates. */ + private fun showMenu( + x: Int, + y: Int, + ) { + _menu.value = + KeyguardSettingsPopupMenuModel( + position = + Position( + x = x, + y = y, + ), + onClicked = { + hideMenu() + navigateToLockScreenSettings() + }, + onDismissed = { hideMenu() }, + ) + logger.log(LogEvents.LOCK_SCREEN_LONG_PRESS_POPUP_SHOWN) + } + + /** Updates application state to ask to hide the menu. */ + private fun hideMenu() { + _menu.value = null + } + + /** Opens the wallpaper picker screen after the device is unlocked by the user. */ + private fun navigateToLockScreenSettings() { + logger.log(LogEvents.LOCK_SCREEN_LONG_PRESS_POPUP_CLICKED) + activityStarter.dismissKeyguardThenExecute( + /* action= */ { + appContext.startActivity( + Intent(Intent.ACTION_SET_WALLPAPER).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + appContext + .getString(R.string.config_wallpaperPickerPackage) + .takeIf { it.isNotEmpty() } + ?.let { packageName -> setPackage(packageName) } + } + ) + true + }, + /* cancel= */ {}, + /* afterKeyguardGone= */ true, + ) + } + + enum class LogEvents( + private val _id: Int, + ) : UiEventLogger.UiEventEnum { + @UiEvent(doc = "The lock screen was long-pressed and we showed the settings popup menu.") + LOCK_SCREEN_LONG_PRESS_POPUP_SHOWN(1292), + @UiEvent(doc = "The lock screen long-press popup menu was clicked.") + LOCK_SCREEN_LONG_PRESS_POPUP_CLICKED(1293), + ; + + override fun getId() = _id + } +} diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/domain/model/KeyguardSettingsPopupMenuModel.kt b/packages/SystemUI/src/com/android/systemui/keyguard/domain/model/KeyguardSettingsPopupMenuModel.kt new file mode 100644 index 0000000000000..7c61e7108265e --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/keyguard/domain/model/KeyguardSettingsPopupMenuModel.kt @@ -0,0 +1,30 @@ +/* + * 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.keyguard.domain.model + +import com.android.systemui.common.shared.model.Position + +/** Models a settings popup menu for the lock screen. */ +data class KeyguardSettingsPopupMenuModel( + /** Where the menu should be anchored, roughly in screen space. */ + val position: Position, + /** Callback to invoke when the menu gets clicked by the user. */ + val onClicked: () -> Unit, + /** Callback to invoke when the menu gets dismissed by the user. */ + val onDismissed: () -> Unit, +) diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/ui/binder/KeyguardLongPressPopupViewBinder.kt b/packages/SystemUI/src/com/android/systemui/keyguard/ui/binder/KeyguardLongPressPopupViewBinder.kt new file mode 100644 index 0000000000000..d85682b3bab8e --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/keyguard/ui/binder/KeyguardLongPressPopupViewBinder.kt @@ -0,0 +1,88 @@ +/* + * 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.keyguard.ui.binder + +import android.annotation.SuppressLint +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.WindowManager +import android.widget.PopupWindow +import com.android.systemui.R +import com.android.systemui.common.ui.binder.IconViewBinder +import com.android.systemui.common.ui.binder.TextViewBinder +import com.android.systemui.keyguard.ui.viewmodel.KeyguardSettingsPopupMenuViewModel + +object KeyguardLongPressPopupViewBinder { + @SuppressLint("InflateParams") // We don't care that the parent is null. + fun createAndShow( + container: View, + viewModel: KeyguardSettingsPopupMenuViewModel, + onDismissed: () -> Unit, + ): () -> Unit { + val contentView: View = + LayoutInflater.from(container.context) + .inflate( + R.layout.keyguard_settings_popup_menu, + null, + ) + + contentView.setOnClickListener { viewModel.onClicked() } + IconViewBinder.bind( + icon = viewModel.icon, + view = contentView.requireViewById(R.id.icon), + ) + TextViewBinder.bind( + view = contentView.requireViewById(R.id.text), + viewModel = viewModel.text, + ) + + val popupWindow = + PopupWindow(container.context).apply { + windowLayoutType = WindowManager.LayoutParams.TYPE_KEYGUARD_DIALOG + setBackgroundDrawable(null) + animationStyle = com.android.internal.R.style.Animation_Dialog + isOutsideTouchable = true + isFocusable = true + setContentView(contentView) + setOnDismissListener { onDismissed() } + contentView.measure( + View.MeasureSpec.makeMeasureSpec( + 0, + View.MeasureSpec.UNSPECIFIED, + ), + View.MeasureSpec.makeMeasureSpec( + 0, + View.MeasureSpec.UNSPECIFIED, + ), + ) + showAtLocation( + container, + Gravity.NO_GRAVITY, + viewModel.position.x - contentView.measuredWidth / 2, + viewModel.position.y - + contentView.measuredHeight - + container.context.resources.getDimensionPixelSize( + R.dimen.keyguard_long_press_settings_popup_vertical_offset + ), + ) + } + + return { popupWindow.dismiss() } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/ui/binder/KeyguardLongPressViewBinder.kt b/packages/SystemUI/src/com/android/systemui/keyguard/ui/binder/KeyguardLongPressViewBinder.kt new file mode 100644 index 0000000000000..ef3f242a39a96 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/keyguard/ui/binder/KeyguardLongPressViewBinder.kt @@ -0,0 +1,95 @@ +/* + * 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.keyguard.ui.binder + +import android.view.View +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle +import com.android.systemui.common.ui.view.LongPressHandlingView +import com.android.systemui.keyguard.ui.viewmodel.KeyguardLongPressViewModel +import com.android.systemui.lifecycle.repeatWhenAttached +import com.android.systemui.plugins.FalsingManager +import kotlinx.coroutines.launch + +object KeyguardLongPressViewBinder { + /** + * Drives UI for the lock screen long-press feature. + * + * @param view The view that listens for long-presses. + * @param viewModel The view-model that models the UI state. + * @param onSingleTap A callback to invoke when the system decides that there was a single tap. + * @param falsingManager [FalsingManager] for making sure the long-press didn't just happen in + * the user's pocket. + */ + @JvmStatic + fun bind( + view: LongPressHandlingView, + viewModel: KeyguardLongPressViewModel, + onSingleTap: () -> Unit, + falsingManager: FalsingManager, + ) { + view.listener = + object : LongPressHandlingView.Listener { + override fun onLongPressDetected(view: View, x: Int, y: Int) { + if (falsingManager.isFalseLongTap(FalsingManager.LOW_PENALTY)) { + return + } + + viewModel.onLongPress( + x = x, + y = y, + ) + } + + override fun onSingleTapDetected(view: View) { + if (falsingManager.isFalseTap(FalsingManager.LOW_PENALTY)) { + return + } + + onSingleTap() + } + } + + view.repeatWhenAttached { + repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.isLongPressHandlingEnabled.collect { isEnabled -> + view.setLongPressHandlingEnabled(isEnabled) + } + } + + launch { + var dismissMenu: (() -> Unit)? = null + + viewModel.menu.collect { menuOrNull -> + if (menuOrNull != null) { + dismissMenu = + KeyguardLongPressPopupViewBinder.createAndShow( + container = view, + viewModel = menuOrNull, + onDismissed = menuOrNull.onDismissed, + ) + } else { + dismissMenu?.invoke() + } + } + } + } + } + } +} diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/KeyguardLongPressViewModel.kt b/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/KeyguardLongPressViewModel.kt new file mode 100644 index 0000000000000..d896390fd4719 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/KeyguardLongPressViewModel.kt @@ -0,0 +1,69 @@ +/* + * 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.keyguard.ui.viewmodel + +import com.android.systemui.R +import com.android.systemui.common.shared.model.Icon +import com.android.systemui.common.shared.model.Text +import com.android.systemui.keyguard.domain.interactor.KeyguardLongPressInteractor +import javax.inject.Inject +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +/** Models UI state to support the lock screen long-press feature. */ +class KeyguardLongPressViewModel +@Inject +constructor( + private val interactor: KeyguardLongPressInteractor, +) { + + /** Whether the long-press handling feature should be enabled. */ + val isLongPressHandlingEnabled: Flow = interactor.isLongPressHandlingEnabled + + /** View-model for a menu that should be shown; `null` when no menu should be shown. */ + val menu: Flow = + interactor.menu.map { model -> + model?.let { + KeyguardSettingsPopupMenuViewModel( + icon = + Icon.Resource( + res = R.drawable.ic_settings, + contentDescription = null, + ), + text = + Text.Resource( + res = R.string.lock_screen_settings, + ), + position = model.position, + onClicked = model.onClicked, + onDismissed = model.onDismissed, + ) + } + } + + /** Notifies that the user has long-pressed on the lock screen. */ + fun onLongPress( + x: Int, + y: Int, + ) { + interactor.onLongPress( + x = x, + y = y, + ) + } +} diff --git a/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/KeyguardSettingsPopupMenuViewModel.kt b/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/KeyguardSettingsPopupMenuViewModel.kt new file mode 100644 index 0000000000000..0571b05b4751b --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/keyguard/ui/viewmodel/KeyguardSettingsPopupMenuViewModel.kt @@ -0,0 +1,34 @@ +/* + * 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.keyguard.ui.viewmodel + +import com.android.systemui.common.shared.model.Icon +import com.android.systemui.common.shared.model.Position +import com.android.systemui.common.shared.model.Text + +/** Models the UI state of a keyguard settings popup menu. */ +data class KeyguardSettingsPopupMenuViewModel( + val icon: Icon, + val text: Text, + /** Where the menu should be anchored, roughly in screen space. */ + val position: Position, + /** Callback to invoke when the menu gets clicked by the user. */ + val onClicked: () -> Unit, + /** Callback to invoke when the menu gets dismissed by the user. */ + val onDismissed: () -> Unit, +) diff --git a/packages/SystemUI/src/com/android/systemui/shade/NotificationPanelViewController.java b/packages/SystemUI/src/com/android/systemui/shade/NotificationPanelViewController.java index 392a851290de7..b324ec333ed56 100644 --- a/packages/SystemUI/src/com/android/systemui/shade/NotificationPanelViewController.java +++ b/packages/SystemUI/src/com/android/systemui/shade/NotificationPanelViewController.java @@ -140,12 +140,15 @@ import com.android.systemui.fragments.FragmentService; import com.android.systemui.keyguard.KeyguardUnlockAnimationController; import com.android.systemui.keyguard.domain.interactor.AlternateBouncerInteractor; import com.android.systemui.keyguard.domain.interactor.KeyguardBottomAreaInteractor; +import com.android.systemui.keyguard.domain.interactor.KeyguardInteractor; import com.android.systemui.keyguard.domain.interactor.KeyguardTransitionInteractor; import com.android.systemui.keyguard.shared.model.TransitionState; import com.android.systemui.keyguard.shared.model.TransitionStep; +import com.android.systemui.keyguard.ui.binder.KeyguardLongPressViewBinder; import com.android.systemui.keyguard.ui.viewmodel.DreamingToLockscreenTransitionViewModel; import com.android.systemui.keyguard.ui.viewmodel.GoneToDreamingTransitionViewModel; import com.android.systemui.keyguard.ui.viewmodel.KeyguardBottomAreaViewModel; +import com.android.systemui.keyguard.ui.viewmodel.KeyguardLongPressViewModel; import com.android.systemui.keyguard.ui.viewmodel.LockscreenToDreamingTransitionViewModel; import com.android.systemui.keyguard.ui.viewmodel.LockscreenToOccludedTransitionViewModel; import com.android.systemui.keyguard.ui.viewmodel.OccludedToLockscreenTransitionViewModel; @@ -243,6 +246,7 @@ import java.util.function.Consumer; import javax.inject.Inject; import javax.inject.Provider; +import kotlin.Unit; import kotlinx.coroutines.CoroutineDispatcher; @CentralSurfacesComponent.CentralSurfacesScope @@ -698,6 +702,7 @@ public final class NotificationPanelViewController implements Dumpable { private LockscreenToOccludedTransitionViewModel mLockscreenToOccludedTransitionViewModel; private KeyguardTransitionInteractor mKeyguardTransitionInteractor; + private final KeyguardInteractor mKeyguardInteractor; private CoroutineDispatcher mMainDispatcher; private boolean mIsOcclusionTransitionRunning = false; private int mDreamingToLockscreenTransitionTranslationY; @@ -827,7 +832,9 @@ public final class NotificationPanelViewController implements Dumpable { LockscreenToOccludedTransitionViewModel lockscreenToOccludedTransitionViewModel, @Main CoroutineDispatcher mainDispatcher, KeyguardTransitionInteractor keyguardTransitionInteractor, - DumpManager dumpManager) { + DumpManager dumpManager, + KeyguardLongPressViewModel keyguardLongPressViewModel, + KeyguardInteractor keyguardInteractor) { keyguardStateController.addCallback(new KeyguardStateController.Callback() { @Override public void onKeyguardFadingAwayChanged() { @@ -848,6 +855,7 @@ public final class NotificationPanelViewController implements Dumpable { mGoneToDreamingTransitionViewModel = goneToDreamingTransitionViewModel; mLockscreenToOccludedTransitionViewModel = lockscreenToOccludedTransitionViewModel; mKeyguardTransitionInteractor = keyguardTransitionInteractor; + mKeyguardInteractor = keyguardInteractor; mView.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() { @Override public void onViewAttachedToWindow(View v) { @@ -1000,6 +1008,14 @@ public final class NotificationPanelViewController implements Dumpable { updateUserSwitcherFlags(); mKeyguardBottomAreaViewModel = keyguardBottomAreaViewModel; mKeyguardBottomAreaInteractor = keyguardBottomAreaInteractor; + KeyguardLongPressViewBinder.bind( + mView.requireViewById(R.id.keyguard_long_press), + keyguardLongPressViewModel, + () -> { + onEmptySpaceClick(); + return Unit.INSTANCE; + }, + mFalsingManager); onFinishInflate(); keyguardUnlockAnimationController.addKeyguardUnlockAnimationListener( new KeyguardUnlockAnimationController.KeyguardUnlockAnimationListener() { @@ -3129,6 +3145,7 @@ public final class NotificationPanelViewController implements Dumpable { mQsClipBottom, radius, qsVisible && !mSplitShadeEnabled); + mKeyguardInteractor.setQuickSettingsVisible(mQsVisible); } // The padding on this area is large enough that we can use a cheaper clipping strategy mKeyguardStatusViewController.setClipBounds(clipStatusView ? mLastQsClipBounds : null); diff --git a/packages/SystemUI/tests/src/com/android/systemui/common/ui/view/LongPressHandlingViewInteractionHandlerTest.kt b/packages/SystemUI/tests/src/com/android/systemui/common/ui/view/LongPressHandlingViewInteractionHandlerTest.kt new file mode 100644 index 0000000000000..fe352fd078f06 --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/common/ui/view/LongPressHandlingViewInteractionHandlerTest.kt @@ -0,0 +1,163 @@ +/* + * 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.common.ui.view + +import android.view.ViewConfiguration +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import com.android.systemui.common.ui.view.LongPressHandlingViewInteractionHandler.MotionEventModel +import com.android.systemui.common.ui.view.LongPressHandlingViewInteractionHandler.MotionEventModel.Down +import com.android.systemui.common.ui.view.LongPressHandlingViewInteractionHandler.MotionEventModel.Move +import com.android.systemui.common.ui.view.LongPressHandlingViewInteractionHandler.MotionEventModel.Up +import com.android.systemui.util.mockito.any +import com.android.systemui.util.mockito.whenever +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.DisposableHandle +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.mockito.Mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations + +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +@RunWith(JUnit4::class) +class LongPressHandlingViewInteractionHandlerTest : SysuiTestCase() { + + @Mock private lateinit var postDelayed: (Runnable, Long) -> DisposableHandle + @Mock private lateinit var onLongPressDetected: (Int, Int) -> Unit + @Mock private lateinit var onSingleTapDetected: () -> Unit + + private lateinit var underTest: LongPressHandlingViewInteractionHandler + + private var isAttachedToWindow: Boolean = true + private var delayedRunnable: Runnable? = null + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + whenever(postDelayed.invoke(any(), any())).thenAnswer { invocation -> + delayedRunnable = invocation.arguments[0] as Runnable + DisposableHandle { delayedRunnable = null } + } + + underTest = + LongPressHandlingViewInteractionHandler( + postDelayed = postDelayed, + isAttachedToWindow = { isAttachedToWindow }, + onLongPressDetected = onLongPressDetected, + onSingleTapDetected = onSingleTapDetected, + ) + underTest.isLongPressHandlingEnabled = true + } + + @Test + fun `long-press`() = runTest { + val downX = 123 + val downY = 456 + dispatchTouchEvents( + Down( + x = downX, + y = downY, + ), + Move( + distanceMoved = ViewConfiguration.getTouchSlop() - 0.1f, + ), + ) + delayedRunnable?.run() + + verify(onLongPressDetected).invoke(downX, downY) + verify(onSingleTapDetected, never()).invoke() + } + + @Test + fun `long-press but feature not enabled`() = runTest { + underTest.isLongPressHandlingEnabled = false + dispatchTouchEvents( + Down( + x = 123, + y = 456, + ), + ) + + assertThat(delayedRunnable).isNull() + verify(onLongPressDetected, never()).invoke(any(), any()) + verify(onSingleTapDetected, never()).invoke() + } + + @Test + fun `long-press but view not attached`() = runTest { + isAttachedToWindow = false + dispatchTouchEvents( + Down( + x = 123, + y = 456, + ), + ) + delayedRunnable?.run() + + verify(onLongPressDetected, never()).invoke(any(), any()) + verify(onSingleTapDetected, never()).invoke() + } + + @Test + fun `dragged too far to be considered a long-press`() = runTest { + dispatchTouchEvents( + Down( + x = 123, + y = 456, + ), + Move( + distanceMoved = ViewConfiguration.getTouchSlop() + 0.1f, + ), + ) + + assertThat(delayedRunnable).isNull() + verify(onLongPressDetected, never()).invoke(any(), any()) + verify(onSingleTapDetected, never()).invoke() + } + + @Test + fun `held down too briefly to be considered a long-press`() = runTest { + dispatchTouchEvents( + Down( + x = 123, + y = 456, + ), + Up( + distanceMoved = ViewConfiguration.getTouchSlop().toFloat(), + gestureDuration = ViewConfiguration.getLongPressTimeout() - 1L, + ), + ) + + assertThat(delayedRunnable).isNull() + verify(onLongPressDetected, never()).invoke(any(), any()) + verify(onSingleTapDetected).invoke() + } + + private fun dispatchTouchEvents( + vararg models: MotionEventModel, + ) { + models.forEach { model -> underTest.onTouchEvent(model) } + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/keyguard/domain/interactor/KeyguardLongPressInteractorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/keyguard/domain/interactor/KeyguardLongPressInteractorTest.kt new file mode 100644 index 0000000000000..9d60b16eba8bd --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/keyguard/domain/interactor/KeyguardLongPressInteractorTest.kt @@ -0,0 +1,213 @@ +/* + * 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.keyguard.domain.interactor + +import android.content.Intent +import androidx.test.filters.SmallTest +import com.android.internal.logging.UiEventLogger +import com.android.systemui.SysuiTestCase +import com.android.systemui.coroutines.collectLastValue +import com.android.systemui.flags.FakeFeatureFlags +import com.android.systemui.flags.Flags +import com.android.systemui.keyguard.data.repository.FakeKeyguardRepository +import com.android.systemui.keyguard.data.repository.FakeKeyguardTransitionRepository +import com.android.systemui.keyguard.shared.model.KeyguardState +import com.android.systemui.keyguard.shared.model.TransitionStep +import com.android.systemui.plugins.ActivityStarter +import com.android.systemui.util.mockito.any +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.Mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.MockitoAnnotations + +@OptIn(ExperimentalCoroutinesApi::class) +@SmallTest +@RunWith(JUnit4::class) +class KeyguardLongPressInteractorTest : SysuiTestCase() { + + @Mock private lateinit var activityStarter: ActivityStarter + @Mock private lateinit var logger: UiEventLogger + + private lateinit var underTest: KeyguardLongPressInteractor + + private lateinit var testScope: TestScope + private lateinit var keyguardRepository: FakeKeyguardRepository + private lateinit var keyguardTransitionRepository: FakeKeyguardTransitionRepository + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + runBlocking { createUnderTest() } + } + + @Test + fun isEnabled() = + testScope.runTest { + val isEnabled = collectLastValue(underTest.isLongPressHandlingEnabled) + KeyguardState.values().forEach { keyguardState -> + setUpState( + keyguardState = keyguardState, + ) + + if (keyguardState == KeyguardState.LOCKSCREEN) { + assertThat(isEnabled()).isTrue() + } else { + assertThat(isEnabled()).isFalse() + } + } + } + + @Test + fun `isEnabled - always false when quick settings are visible`() = + testScope.runTest { + val isEnabled = collectLastValue(underTest.isLongPressHandlingEnabled) + KeyguardState.values().forEach { keyguardState -> + setUpState( + keyguardState = keyguardState, + isQuickSettingsVisible = true, + ) + + assertThat(isEnabled()).isFalse() + } + } + + @Test + fun `long-pressed - pop-up clicked - starts activity`() = + testScope.runTest { + val menu = collectLastValue(underTest.menu) + runCurrent() + + val x = 100 + val y = 123 + underTest.onLongPress(x, y) + assertThat(menu()).isNotNull() + assertThat(menu()?.position?.x).isEqualTo(x) + assertThat(menu()?.position?.y).isEqualTo(y) + + menu()?.onClicked?.invoke() + + assertThat(menu()).isNull() + verify(activityStarter).dismissKeyguardThenExecute(any(), any(), anyBoolean()) + } + + @Test + fun `long-pressed - pop-up dismissed - never starts activity`() = + testScope.runTest { + val menu = collectLastValue(underTest.menu) + runCurrent() + + menu()?.onDismissed?.invoke() + + assertThat(menu()).isNull() + verify(activityStarter, never()).dismissKeyguardThenExecute(any(), any(), anyBoolean()) + } + + @Suppress("DEPRECATION") // We're okay using ACTION_CLOSE_SYSTEM_DIALOGS on system UI. + @Test + fun `long pressed - close dialogs broadcast received - popup dismissed`() = + testScope.runTest { + val menu = collectLastValue(underTest.menu) + runCurrent() + + underTest.onLongPress(123, 456) + assertThat(menu()).isNotNull() + + fakeBroadcastDispatcher.registeredReceivers.forEach { broadcastReceiver -> + broadcastReceiver.onReceive(context, Intent(Intent.ACTION_CLOSE_SYSTEM_DIALOGS)) + } + + assertThat(menu()).isNull() + } + + @Test + fun `logs when menu is shown`() = + testScope.runTest { + collectLastValue(underTest.menu) + runCurrent() + + underTest.onLongPress(100, 123) + + verify(logger) + .log(KeyguardLongPressInteractor.LogEvents.LOCK_SCREEN_LONG_PRESS_POPUP_SHOWN) + } + + @Test + fun `logs when menu is clicked`() = + testScope.runTest { + val menu = collectLastValue(underTest.menu) + runCurrent() + + underTest.onLongPress(100, 123) + menu()?.onClicked?.invoke() + + verify(logger) + .log(KeyguardLongPressInteractor.LogEvents.LOCK_SCREEN_LONG_PRESS_POPUP_CLICKED) + } + + private suspend fun createUnderTest( + isLongPressFeatureEnabled: Boolean = true, + isRevampedWppFeatureEnabled: Boolean = true, + ) { + testScope = TestScope() + keyguardRepository = FakeKeyguardRepository() + keyguardTransitionRepository = FakeKeyguardTransitionRepository() + + underTest = + KeyguardLongPressInteractor( + unsafeContext = context, + scope = testScope.backgroundScope, + transitionInteractor = + KeyguardTransitionInteractor( + repository = keyguardTransitionRepository, + ), + repository = keyguardRepository, + activityStarter = activityStarter, + logger = logger, + featureFlags = + FakeFeatureFlags().apply { + set(Flags.LOCK_SCREEN_LONG_PRESS_ENABLED, isLongPressFeatureEnabled) + set(Flags.REVAMPED_WALLPAPER_UI, isRevampedWppFeatureEnabled) + }, + broadcastDispatcher = fakeBroadcastDispatcher, + ) + setUpState() + } + + private suspend fun setUpState( + keyguardState: KeyguardState = KeyguardState.LOCKSCREEN, + isQuickSettingsVisible: Boolean = false, + ) { + keyguardTransitionRepository.sendTransitionStep( + TransitionStep( + to = keyguardState, + ), + ) + keyguardRepository.setQuickSettingsVisible(isVisible = isQuickSettingsVisible) + } +} diff --git a/packages/SystemUI/tests/src/com/android/systemui/shade/NotificationPanelViewControllerTest.java b/packages/SystemUI/tests/src/com/android/systemui/shade/NotificationPanelViewControllerTest.java index 0f3d4a8ca59ad..28f7edf4ffb9b 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/shade/NotificationPanelViewControllerTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/shade/NotificationPanelViewControllerTest.java @@ -97,6 +97,7 @@ import com.android.systemui.SysuiTestCase; import com.android.systemui.biometrics.AuthController; import com.android.systemui.classifier.FalsingCollectorFake; import com.android.systemui.classifier.FalsingManagerFake; +import com.android.systemui.common.ui.view.LongPressHandlingView; import com.android.systemui.doze.DozeLog; import com.android.systemui.dump.DumpManager; import com.android.systemui.flags.FeatureFlags; @@ -105,10 +106,12 @@ import com.android.systemui.fragments.FragmentService; import com.android.systemui.keyguard.KeyguardUnlockAnimationController; import com.android.systemui.keyguard.domain.interactor.AlternateBouncerInteractor; import com.android.systemui.keyguard.domain.interactor.KeyguardBottomAreaInteractor; +import com.android.systemui.keyguard.domain.interactor.KeyguardInteractor; import com.android.systemui.keyguard.domain.interactor.KeyguardTransitionInteractor; import com.android.systemui.keyguard.ui.viewmodel.DreamingToLockscreenTransitionViewModel; import com.android.systemui.keyguard.ui.viewmodel.GoneToDreamingTransitionViewModel; import com.android.systemui.keyguard.ui.viewmodel.KeyguardBottomAreaViewModel; +import com.android.systemui.keyguard.ui.viewmodel.KeyguardLongPressViewModel; import com.android.systemui.keyguard.ui.viewmodel.LockscreenToDreamingTransitionViewModel; import com.android.systemui.keyguard.ui.viewmodel.LockscreenToOccludedTransitionViewModel; import com.android.systemui.keyguard.ui.viewmodel.OccludedToLockscreenTransitionViewModel; @@ -197,7 +200,7 @@ import kotlinx.coroutines.CoroutineDispatcher; @SmallTest @RunWith(AndroidTestingRunner.class) -@TestableLooper.RunWithLooper +@TestableLooper.RunWithLooper(setAsMainLooper = true) public class NotificationPanelViewControllerTest extends SysuiTestCase { private static final int SPLIT_SHADE_FULL_TRANSITION_DISTANCE = 400; @@ -302,6 +305,8 @@ public class NotificationPanelViewControllerTest extends SysuiTestCase { @Mock private GoneToDreamingTransitionViewModel mGoneToDreamingTransitionViewModel; @Mock private KeyguardTransitionInteractor mKeyguardTransitionInteractor; + @Mock private KeyguardInteractor mKeyguardInteractor; + @Mock private KeyguardLongPressViewModel mKeyuardLongPressViewModel; @Mock private CoroutineDispatcher mMainDispatcher; @Mock private AlternateBouncerInteractor mAlternateBouncerInteractor; @Mock private MotionEvent mDownMotionEvent; @@ -459,6 +464,9 @@ public class NotificationPanelViewControllerTest extends SysuiTestCase { mMainHandler = new Handler(Looper.getMainLooper()); + when(mView.requireViewById(R.id.keyguard_long_press)) + .thenReturn(mock(LongPressHandlingView.class)); + mNotificationPanelViewController = new NotificationPanelViewController( mView, mMainHandler, @@ -528,7 +536,9 @@ public class NotificationPanelViewControllerTest extends SysuiTestCase { mLockscreenToOccludedTransitionViewModel, mMainDispatcher, mKeyguardTransitionInteractor, - mDumpManager); + mDumpManager, + mKeyuardLongPressViewModel, + mKeyguardInteractor); mNotificationPanelViewController.initDependencies( mCentralSurfaces, null, diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/data/repository/FakeKeyguardRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/data/repository/FakeKeyguardRepository.kt index 15b473640de75..065fe8903e404 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/data/repository/FakeKeyguardRepository.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/data/repository/FakeKeyguardRepository.kt @@ -29,6 +29,7 @@ import com.android.systemui.keyguard.shared.model.WakefulnessState import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow /** Fake implementation of [KeyguardRepository] */ class FakeKeyguardRepository : KeyguardRepository { @@ -101,6 +102,13 @@ class FakeKeyguardRepository : KeyguardRepository { private val _biometricUnlockSource = MutableStateFlow(null) override val biometricUnlockSource: Flow = _biometricUnlockSource + private val _isQuickSettingsVisible = MutableStateFlow(false) + override val isQuickSettingsVisible: Flow = _isQuickSettingsVisible.asStateFlow() + + override fun setQuickSettingsVisible(isVisible: Boolean) { + _isQuickSettingsVisible.value = isVisible + } + override fun isKeyguardShowing(): Boolean { return _isKeyguardShowing.value } @@ -169,6 +177,10 @@ class FakeKeyguardRepository : KeyguardRepository { _dozeTransitionModel.value = model } + fun setStatusBarState(state: StatusBarState) { + _statusBarState.value = state + } + override fun isUdfpsSupported(): Boolean { return _isUdfpsSupported.value } diff --git a/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/data/repository/FakeKeyguardTransitionRepository.kt b/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/data/repository/FakeKeyguardTransitionRepository.kt index 6c4424470c1cd..eac1bd1450333 100644 --- a/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/data/repository/FakeKeyguardTransitionRepository.kt +++ b/packages/SystemUI/tests/utils/src/com/android/systemui/keyguard/data/repository/FakeKeyguardTransitionRepository.kt @@ -22,13 +22,15 @@ import com.android.systemui.keyguard.shared.model.TransitionInfo import com.android.systemui.keyguard.shared.model.TransitionState import com.android.systemui.keyguard.shared.model.TransitionStep import java.util.UUID +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow /** Fake implementation of [KeyguardTransitionRepository] */ class FakeKeyguardTransitionRepository : KeyguardTransitionRepository { - private val _transitions = MutableSharedFlow() + private val _transitions = + MutableSharedFlow(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST) override val transitions: SharedFlow = _transitions suspend fun sendTransitionStep(step: TransitionStep) {