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) {