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
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
~ 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.
|
||||
~
|
||||
-->
|
||||
<ripple
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:androidprv="http://schemas.android.com/apk/prv/res/android"
|
||||
android:color="?android:attr/colorControlHighlight">
|
||||
<item android:id="@android:id/mask">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="@android:color/white"/>
|
||||
<corners android:radius="28dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item>
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="?androidprv:attr/colorSurface" />
|
||||
<corners android:radius="28dp" />
|
||||
</shape>
|
||||
</item>
|
||||
</ripple>
|
||||
@@ -0,0 +1,50 @@
|
||||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
~ 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.
|
||||
~
|
||||
-->
|
||||
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="52dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:background="@drawable/keyguard_settings_popup_menu_background"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="24dp"
|
||||
android:paddingVertical="16dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/icon"
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:tint="?android:attr/textColorPrimary"
|
||||
android:importantForAccessibility="no"
|
||||
tools:ignore="UseAppTint" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?android:attr/textAppearanceMedium"
|
||||
android:textColor="?android:attr/textColorPrimary"
|
||||
android:textSize="14sp"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -26,6 +26,11 @@
|
||||
android:layout_height="match_parent"
|
||||
android:background="@android:color/transparent">
|
||||
|
||||
<com.android.systemui.common.ui.view.LongPressHandlingView
|
||||
android:id="@+id/keyguard_long_press"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<ViewStub
|
||||
android:id="@+id/keyguard_qs_user_switch_stub"
|
||||
android:layout="@layout/keyguard_qs_user_switch"
|
||||
|
||||
@@ -824,4 +824,8 @@
|
||||
<item>bottom_end:wallet</item>
|
||||
</string-array>
|
||||
|
||||
<!-- Package name for the app that implements the wallpaper picker. -->
|
||||
<string name="config_wallpaperPickerPackage" translatable="false">
|
||||
com.android.wallpaper
|
||||
</string>
|
||||
</resources>
|
||||
|
||||
@@ -1648,4 +1648,10 @@
|
||||
<dimen name="rear_display_animation_height">200dp</dimen>
|
||||
<dimen name="rear_display_title_top_padding">24dp</dimen>
|
||||
<dimen name="rear_display_title_bottom_padding">16dp</dimen>
|
||||
|
||||
<!--
|
||||
Vertical distance between the pointer and the popup menu that shows up on the lock screen when
|
||||
it is long-pressed.
|
||||
-->
|
||||
<dimen name="keyguard_long_press_settings_popup_vertical_offset">96dp</dimen>
|
||||
</resources>
|
||||
|
||||
@@ -2798,4 +2798,13 @@
|
||||
<!-- Label for the close button on switch to work profile dialog. Switch to work profile dialog guide users to make call from work
|
||||
profile dialer app as it's not possible to make call from current profile due to an admin policy.[CHAR LIMIT=60] -->
|
||||
<string name="call_from_work_profile_close">Close</string>
|
||||
|
||||
<!--
|
||||
Label for a menu item in a menu that is shown when the user wishes to configure the lock screen.
|
||||
Clicking on this menu item takes the user to a screen where they can modify the settings of the
|
||||
lock screen.
|
||||
|
||||
[CHAR LIMIT=32]
|
||||
-->
|
||||
<string name="lock_screen_settings">Lock screen settings</string>
|
||||
</resources>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -148,6 +148,9 @@ interface KeyguardRepository {
|
||||
/** Source of the most recent biometric unlock, such as fingerprint or face. */
|
||||
val biometricUnlockSource: Flow<BiometricUnlockSource?>
|
||||
|
||||
/** Whether quick settings or quick-quick settings is visible. */
|
||||
val isQuickSettingsVisible: Flow<Boolean>
|
||||
|
||||
/**
|
||||
* 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<Boolean> = _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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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<Boolean> =
|
||||
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<Boolean> = _isLongPressHandlingEnabled
|
||||
|
||||
private val _menu = MutableStateFlow<KeyguardSettingsPopupMenuModel?>(null)
|
||||
/** Model for a menu that should be shown; `null` when no menu should be shown. */
|
||||
val menu: Flow<KeyguardSettingsPopupMenuModel?> =
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Boolean> = interactor.isLongPressHandlingEnabled
|
||||
|
||||
/** View-model for a menu that should be shown; `null` when no menu should be shown. */
|
||||
val menu: Flow<KeyguardSettingsPopupMenuViewModel?> =
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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);
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<BiometricUnlockSource?>(null)
|
||||
override val biometricUnlockSource: Flow<BiometricUnlockSource?> = _biometricUnlockSource
|
||||
|
||||
private val _isQuickSettingsVisible = MutableStateFlow(false)
|
||||
override val isQuickSettingsVisible: Flow<Boolean> = _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
|
||||
}
|
||||
|
||||
@@ -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<TransitionStep>()
|
||||
private val _transitions =
|
||||
MutableSharedFlow<TransitionStep>(replay = 1, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
override val transitions: SharedFlow<TransitionStep> = _transitions
|
||||
|
||||
suspend fun sendTransitionStep(step: TransitionStep) {
|
||||
|
||||
Reference in New Issue
Block a user