[DO NOT MERGE] Coordinated DREAMING->LOCKSCREEN animation am: 79c7aaa06e

Original change: https://googleplex-android-review.googlesource.com/c/platform/frameworks/base/+/20650174

Change-Id: I74f8028ae5a714fd4c381deb00e23fb24548d7ec
Signed-off-by: Automerger Merge Worker <android-build-automerger-merge-worker@system.gserviceaccount.com>
This commit is contained in:
Matt Pietal
2022-12-20 18:12:46 +00:00
committed by Automerger Merge Worker
19 changed files with 565 additions and 265 deletions

View File

@@ -743,27 +743,6 @@
<!-- How long in milliseconds before full burn-in protection is achieved. -->
<integer name="config_dreamOverlayMillisUntilFullJitter">240000</integer>
<!-- The duration in milliseconds of the y-translation animation when waking up from
the dream -->
<integer name="config_dreamOverlayOutTranslationYDurationMs">333</integer>
<!-- The delay in milliseconds of the y-translation animation when waking up from
the dream for the complications at the bottom of the screen -->
<integer name="config_dreamOverlayOutTranslationYDelayBottomMs">33</integer>
<!-- The delay in milliseconds of the y-translation animation when waking up from
the dream for the complications at the top of the screen -->
<integer name="config_dreamOverlayOutTranslationYDelayTopMs">117</integer>
<!-- The duration in milliseconds of the alpha animation when waking up from the dream -->
<integer name="config_dreamOverlayOutAlphaDurationMs">200</integer>
<!-- The delay in milliseconds of the alpha animation when waking up from the dream for the
complications at the top of the screen -->
<integer name="config_dreamOverlayOutAlphaDelayTopMs">217</integer>
<!-- The delay in milliseconds of the alpha animation when waking up from the dream for the
complications at the bottom of the screen -->
<integer name="config_dreamOverlayOutAlphaDelayBottomMs">133</integer>
<!-- The duration in milliseconds of the blur animation when waking up from
the dream -->
<integer name="config_dreamOverlayOutBlurDurationMs">250</integer>
<integer name="complicationFadeOutMs">500</integer>
<integer name="complicationFadeInMs">500</integer>

View File

@@ -0,0 +1,45 @@
/*
* Copyright (C) 2022 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.dreams
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.statusbar.policy.CallbackController
import javax.inject.Inject
/** Dream-related callback information */
@SysUISingleton
class DreamCallbackController @Inject constructor() :
CallbackController<DreamCallbackController.DreamCallback> {
private val callbacks = mutableSetOf<DreamCallbackController.DreamCallback>()
override fun addCallback(callback: DreamCallbackController.DreamCallback) {
callbacks.add(callback)
}
override fun removeCallback(callback: DreamCallbackController.DreamCallback) {
callbacks.remove(callback)
}
fun onWakeUp() {
callbacks.forEach { it.onWakeUp() }
}
interface DreamCallback {
fun onWakeUp()
}
}

View File

@@ -22,6 +22,9 @@ import android.animation.ValueAnimator
import android.view.View
import android.view.animation.Interpolator
import androidx.core.animation.doOnEnd
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.repeatOnLifecycle
import com.android.systemui.R
import com.android.systemui.animation.Interpolators
import com.android.systemui.dreams.complication.ComplicationHostViewController
import com.android.systemui.dreams.complication.ComplicationLayoutParams
@@ -29,10 +32,20 @@ import com.android.systemui.dreams.complication.ComplicationLayoutParams.POSITIO
import com.android.systemui.dreams.complication.ComplicationLayoutParams.POSITION_TOP
import com.android.systemui.dreams.complication.ComplicationLayoutParams.Position
import com.android.systemui.dreams.dagger.DreamOverlayModule
import com.android.systemui.keyguard.ui.viewmodel.DreamingToLockscreenTransitionViewModel
import com.android.systemui.keyguard.ui.viewmodel.DreamingToLockscreenTransitionViewModel.Companion.DREAM_ANIMATION_DURATION
import com.android.systemui.lifecycle.repeatWhenAttached
import com.android.systemui.statusbar.BlurUtils
import com.android.systemui.statusbar.CrossFadeHelper
import com.android.systemui.statusbar.policy.ConfigurationController
import com.android.systemui.statusbar.policy.ConfigurationController.ConfigurationListener
import com.android.systemui.util.concurrency.DelayableExecutor
import javax.inject.Inject
import javax.inject.Named
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.launch
/** Controller for dream overlay animations. */
class DreamOverlayAnimationsController
@@ -43,6 +56,8 @@ constructor(
private val mStatusBarViewController: DreamOverlayStatusBarViewController,
private val mOverlayStateController: DreamOverlayStateController,
@Named(DreamOverlayModule.DREAM_BLUR_RADIUS) private val mDreamBlurRadius: Int,
private val transitionViewModel: DreamingToLockscreenTransitionViewModel,
private val configController: ConfigurationController,
@Named(DreamOverlayModule.DREAM_IN_BLUR_ANIMATION_DURATION)
private val mDreamInBlurAnimDurationMs: Long,
@Named(DreamOverlayModule.DREAM_IN_COMPLICATIONS_ANIMATION_DURATION)
@@ -51,22 +66,10 @@ constructor(
private val mDreamInTranslationYDistance: Int,
@Named(DreamOverlayModule.DREAM_IN_TRANSLATION_Y_DURATION)
private val mDreamInTranslationYDurationMs: Long,
@Named(DreamOverlayModule.DREAM_OUT_TRANSLATION_Y_DISTANCE)
private val mDreamOutTranslationYDistance: Int,
@Named(DreamOverlayModule.DREAM_OUT_TRANSLATION_Y_DURATION)
private val mDreamOutTranslationYDurationMs: Long,
@Named(DreamOverlayModule.DREAM_OUT_TRANSLATION_Y_DELAY_BOTTOM)
private val mDreamOutTranslationYDelayBottomMs: Long,
@Named(DreamOverlayModule.DREAM_OUT_TRANSLATION_Y_DELAY_TOP)
private val mDreamOutTranslationYDelayTopMs: Long,
@Named(DreamOverlayModule.DREAM_OUT_ALPHA_DURATION) private val mDreamOutAlphaDurationMs: Long,
@Named(DreamOverlayModule.DREAM_OUT_ALPHA_DELAY_BOTTOM)
private val mDreamOutAlphaDelayBottomMs: Long,
@Named(DreamOverlayModule.DREAM_OUT_ALPHA_DELAY_TOP) private val mDreamOutAlphaDelayTopMs: Long,
@Named(DreamOverlayModule.DREAM_OUT_BLUR_DURATION) private val mDreamOutBlurDurationMs: Long
) {
private var mAnimator: Animator? = null
private lateinit var view: View
/**
* Store the current alphas at the various positions. This is so that we may resume an animation
@@ -76,9 +79,63 @@ constructor(
private var mCurrentBlurRadius: Float = 0f
fun init(view: View) {
this.view = view
view.repeatWhenAttached {
val configurationBasedDimensions = MutableStateFlow(loadFromResources(view))
val configCallback =
object : ConfigurationListener {
override fun onDensityOrFontScaleChanged() {
configurationBasedDimensions.value = loadFromResources(view)
}
}
configController.addCallback(configCallback)
repeatOnLifecycle(Lifecycle.State.CREATED) {
/* Translation animations, when moving from DREAMING->LOCKSCREEN state */
launch {
configurationBasedDimensions
.flatMapLatest {
transitionViewModel.dreamOverlayTranslationY(it.translationYPx)
}
.collect { px ->
setElementsTranslationYAtPosition(
px,
ComplicationLayoutParams.POSITION_TOP
)
setElementsTranslationYAtPosition(
px,
ComplicationLayoutParams.POSITION_BOTTOM
)
}
}
/* Alpha animations, when moving from DREAMING->LOCKSCREEN state */
launch {
transitionViewModel.dreamOverlayAlpha.collect { alpha ->
setElementsAlphaAtPosition(
alpha = alpha,
position = ComplicationLayoutParams.POSITION_TOP,
fadingOut = true,
)
setElementsAlphaAtPosition(
alpha = alpha,
position = ComplicationLayoutParams.POSITION_BOTTOM,
fadingOut = true,
)
}
}
}
configController.removeCallback(configCallback)
}
}
/** Starts the dream content and dream overlay entry animations. */
@JvmOverloads
fun startEntryAnimations(view: View, animatorBuilder: () -> AnimatorSet = { AnimatorSet() }) {
fun startEntryAnimations(animatorBuilder: () -> AnimatorSet = { AnimatorSet() }) {
cancelAnimations()
mAnimator =
@@ -113,73 +170,9 @@ constructor(
}
/** Starts the dream content and dream overlay exit animations. */
@JvmOverloads
fun startExitAnimations(
view: View,
doneCallback: () -> Unit,
animatorBuilder: () -> AnimatorSet = { AnimatorSet() }
) {
fun wakeUp(doneCallback: Runnable, executor: DelayableExecutor) {
cancelAnimations()
mAnimator =
animatorBuilder().apply {
playTogether(
blurAnimator(
view = view,
// Start the blurring wherever the entry animation ended, in
// case it was cancelled early.
fromBlurRadius = mCurrentBlurRadius,
toBlurRadius = mDreamBlurRadius.toFloat(),
durationMs = mDreamOutBlurDurationMs,
interpolator = Interpolators.EMPHASIZED_ACCELERATE
),
translationYAnimator(
from = 0f,
to = mDreamOutTranslationYDistance.toFloat(),
durationMs = mDreamOutTranslationYDurationMs,
delayMs = mDreamOutTranslationYDelayBottomMs,
positions = POSITION_BOTTOM,
interpolator = Interpolators.EMPHASIZED_ACCELERATE
),
translationYAnimator(
from = 0f,
to = mDreamOutTranslationYDistance.toFloat(),
durationMs = mDreamOutTranslationYDurationMs,
delayMs = mDreamOutTranslationYDelayTopMs,
positions = POSITION_TOP,
interpolator = Interpolators.EMPHASIZED_ACCELERATE
),
alphaAnimator(
from =
mCurrentAlphaAtPosition.getOrDefault(
key = POSITION_BOTTOM,
defaultValue = 1f
),
to = 0f,
durationMs = mDreamOutAlphaDurationMs,
delayMs = mDreamOutAlphaDelayBottomMs,
positions = POSITION_BOTTOM
),
alphaAnimator(
from =
mCurrentAlphaAtPosition.getOrDefault(
key = POSITION_TOP,
defaultValue = 1f
),
to = 0f,
durationMs = mDreamOutAlphaDurationMs,
delayMs = mDreamOutAlphaDelayTopMs,
positions = POSITION_TOP
)
)
doOnEnd {
mAnimator = null
mOverlayStateController.setExitAnimationsRunning(false)
doneCallback()
}
start()
}
mOverlayStateController.setExitAnimationsRunning(true)
executor.executeDelayed(doneCallback, DREAM_ANIMATION_DURATION.inWholeMilliseconds)
}
/** Cancels the dream content and dream overlay animations, if they're currently running. */
@@ -288,4 +281,15 @@ constructor(
mStatusBarViewController.setTranslationY(translationY)
}
}
private fun loadFromResources(view: View): ConfigurationBasedDimensions {
return ConfigurationBasedDimensions(
translationYPx =
view.resources.getDimensionPixelSize(R.dimen.dream_overlay_exit_y_offset),
)
}
private data class ConfigurationBasedDimensions(
val translationYPx: Int,
)
}

View File

@@ -42,9 +42,9 @@ import com.android.systemui.statusbar.BlurUtils;
import com.android.systemui.statusbar.phone.KeyguardBouncer;
import com.android.systemui.statusbar.phone.StatusBarKeyguardViewManager;
import com.android.systemui.util.ViewController;
import com.android.systemui.util.concurrency.DelayableExecutor;
import java.util.Arrays;
import java.util.concurrent.Executor;
import javax.inject.Inject;
import javax.inject.Named;
@@ -170,6 +170,7 @@ public class DreamOverlayContainerViewController extends ViewController<DreamOve
protected void onInit() {
mStatusBarViewController.init();
mComplicationHostViewController.init();
mDreamOverlayAnimationsController.init(mView);
}
@Override
@@ -184,7 +185,7 @@ public class DreamOverlayContainerViewController extends ViewController<DreamOve
// Start dream entry animations. Skip animations for low light clock.
if (!mStateController.isLowLightActive()) {
mDreamOverlayAnimationsController.startEntryAnimations(mView);
mDreamOverlayAnimationsController.startEntryAnimations();
}
}
@@ -261,10 +262,8 @@ public class DreamOverlayContainerViewController extends ViewController<DreamOve
* @param onAnimationEnd Callback to trigger once animations are finished.
* @param callbackExecutor Executor to execute the callback on.
*/
public void wakeUp(@NonNull Runnable onAnimationEnd, @NonNull Executor callbackExecutor) {
mDreamOverlayAnimationsController.startExitAnimations(mView, () -> {
callbackExecutor.execute(onAnimationEnd);
return null;
});
public void wakeUp(@NonNull Runnable onAnimationEnd,
@NonNull DelayableExecutor callbackExecutor) {
mDreamOverlayAnimationsController.wakeUp(onAnimationEnd, callbackExecutor);
}
}

View File

@@ -43,8 +43,7 @@ import com.android.systemui.dagger.qualifiers.Main;
import com.android.systemui.dreams.complication.Complication;
import com.android.systemui.dreams.dagger.DreamOverlayComponent;
import com.android.systemui.dreams.touch.DreamOverlayTouchMonitor;
import java.util.concurrent.Executor;
import com.android.systemui.util.concurrency.DelayableExecutor;
import javax.inject.Inject;
import javax.inject.Named;
@@ -61,10 +60,11 @@ public class DreamOverlayService extends android.service.dreams.DreamOverlayServ
// The Context is used to construct the hosting constraint layout and child overlay views.
private final Context mContext;
// The Executor ensures actions and ui updates happen on the same thread.
private final Executor mExecutor;
private final DelayableExecutor mExecutor;
// A controller for the dream overlay container view (which contains both the status bar and the
// content area).
private DreamOverlayContainerViewController mDreamOverlayContainerViewController;
private final DreamCallbackController mDreamCallbackController;
private final KeyguardUpdateMonitor mKeyguardUpdateMonitor;
@Nullable
private final ComponentName mLowLightDreamComponent;
@@ -126,14 +126,15 @@ public class DreamOverlayService extends android.service.dreams.DreamOverlayServ
@Inject
public DreamOverlayService(
Context context,
@Main Executor executor,
@Main DelayableExecutor executor,
WindowManager windowManager,
DreamOverlayComponent.Factory dreamOverlayComponentFactory,
DreamOverlayStateController stateController,
KeyguardUpdateMonitor keyguardUpdateMonitor,
UiEventLogger uiEventLogger,
@Nullable @Named(LowLightDreamModule.LOW_LIGHT_DREAM_COMPONENT)
ComponentName lowLightDreamComponent) {
ComponentName lowLightDreamComponent,
DreamCallbackController dreamCallbackController) {
mContext = context;
mExecutor = executor;
mWindowManager = windowManager;
@@ -142,6 +143,7 @@ public class DreamOverlayService extends android.service.dreams.DreamOverlayServ
mKeyguardUpdateMonitor.registerCallback(mKeyguardCallback);
mStateController = stateController;
mUiEventLogger = uiEventLogger;
mDreamCallbackController = dreamCallbackController;
final ViewModelStore viewModelStore = new ViewModelStore();
final Complication.Host host =
@@ -217,6 +219,7 @@ public class DreamOverlayService extends android.service.dreams.DreamOverlayServ
public void onWakeUp(@NonNull Runnable onCompletedCallback) {
mExecutor.execute(() -> {
if (mDreamOverlayContainerViewController != null) {
mDreamCallbackController.onWakeUp();
mDreamOverlayContainerViewController.wakeUp(onCompletedCallback, mExecutor);
}
});

View File

@@ -55,22 +55,6 @@ public abstract class DreamOverlayModule {
"dream_in_complications_translation_y";
public static final String DREAM_IN_TRANSLATION_Y_DURATION =
"dream_in_complications_translation_y_duration";
public static final String DREAM_OUT_TRANSLATION_Y_DISTANCE =
"dream_out_complications_translation_y";
public static final String DREAM_OUT_TRANSLATION_Y_DURATION =
"dream_out_complications_translation_y_duration";
public static final String DREAM_OUT_TRANSLATION_Y_DELAY_BOTTOM =
"dream_out_complications_translation_y_delay_bottom";
public static final String DREAM_OUT_TRANSLATION_Y_DELAY_TOP =
"dream_out_complications_translation_y_delay_top";
public static final String DREAM_OUT_ALPHA_DURATION =
"dream_out_complications_alpha_duration";
public static final String DREAM_OUT_ALPHA_DELAY_BOTTOM =
"dream_out_complications_alpha_delay_bottom";
public static final String DREAM_OUT_ALPHA_DELAY_TOP =
"dream_out_complications_alpha_delay_top";
public static final String DREAM_OUT_BLUR_DURATION =
"dream_out_blur_duration";
/** */
@Provides
@@ -185,66 +169,6 @@ public abstract class DreamOverlayModule {
return (long) resources.getInteger(R.integer.config_dreamOverlayInTranslationYDurationMs);
}
/**
* Provides the number of pixels to translate complications when waking up from dream.
*/
@Provides
@Named(DREAM_OUT_TRANSLATION_Y_DISTANCE)
@DreamOverlayComponent.DreamOverlayScope
static int providesDreamOutComplicationsTranslationY(@Main Resources resources) {
return resources.getDimensionPixelSize(R.dimen.dream_overlay_exit_y_offset);
}
@Provides
@Named(DREAM_OUT_TRANSLATION_Y_DURATION)
@DreamOverlayComponent.DreamOverlayScope
static long providesDreamOutComplicationsTranslationYDuration(@Main Resources resources) {
return (long) resources.getInteger(R.integer.config_dreamOverlayOutTranslationYDurationMs);
}
@Provides
@Named(DREAM_OUT_TRANSLATION_Y_DELAY_BOTTOM)
@DreamOverlayComponent.DreamOverlayScope
static long providesDreamOutComplicationsTranslationYDelayBottom(@Main Resources resources) {
return (long) resources.getInteger(
R.integer.config_dreamOverlayOutTranslationYDelayBottomMs);
}
@Provides
@Named(DREAM_OUT_TRANSLATION_Y_DELAY_TOP)
@DreamOverlayComponent.DreamOverlayScope
static long providesDreamOutComplicationsTranslationYDelayTop(@Main Resources resources) {
return (long) resources.getInteger(R.integer.config_dreamOverlayOutTranslationYDelayTopMs);
}
@Provides
@Named(DREAM_OUT_ALPHA_DURATION)
@DreamOverlayComponent.DreamOverlayScope
static long providesDreamOutComplicationsAlphaDuration(@Main Resources resources) {
return (long) resources.getInteger(R.integer.config_dreamOverlayOutAlphaDurationMs);
}
@Provides
@Named(DREAM_OUT_ALPHA_DELAY_BOTTOM)
@DreamOverlayComponent.DreamOverlayScope
static long providesDreamOutComplicationsAlphaDelayBottom(@Main Resources resources) {
return (long) resources.getInteger(R.integer.config_dreamOverlayOutAlphaDelayBottomMs);
}
@Provides
@Named(DREAM_OUT_ALPHA_DELAY_TOP)
@DreamOverlayComponent.DreamOverlayScope
static long providesDreamOutComplicationsAlphaDelayTop(@Main Resources resources) {
return (long) resources.getInteger(R.integer.config_dreamOverlayOutAlphaDelayTopMs);
}
@Provides
@Named(DREAM_OUT_BLUR_DURATION)
@DreamOverlayComponent.DreamOverlayScope
static long providesDreamOutBlurDuration(@Main Resources resources) {
return (long) resources.getInteger(R.integer.config_dreamOverlayOutBlurDurationMs);
}
@Provides
@DreamOverlayComponent.DreamOverlayScope
static LifecycleOwner providesLifecycleOwner(Lazy<LifecycleRegistry> lifecycleRegistryLazy) {

View File

@@ -33,6 +33,7 @@ import static com.android.internal.widget.LockPatternUtils.StrongAuthTracker.STR
import static com.android.internal.widget.LockPatternUtils.StrongAuthTracker.STRONG_AUTH_REQUIRED_AFTER_TIMEOUT;
import static com.android.internal.widget.LockPatternUtils.StrongAuthTracker.STRONG_AUTH_REQUIRED_FOR_UNATTENDED_UPDATE;
import static com.android.systemui.DejankUtils.whitelistIpcs;
import static com.android.systemui.keyguard.domain.interactor.DreamingTransitionInteractor.TO_LOCKSCREEN_DURATION_MS;
import android.animation.Animator;
import android.animation.AnimatorListenerAdapter;
@@ -1231,8 +1232,7 @@ public class KeyguardViewMediator implements CoreStartable, Dumpable,
mDreamOpenAnimationDuration = context.getResources().getInteger(
com.android.internal.R.integer.config_dreamOpenAnimationDuration);
mDreamCloseAnimationDuration = context.getResources().getInteger(
com.android.internal.R.integer.config_dreamCloseAnimationDuration);
mDreamCloseAnimationDuration = (int) TO_LOCKSCREEN_DURATION_MS;
}
public void userActivity() {

View File

@@ -29,6 +29,8 @@ import com.android.systemui.doze.DozeHost
import com.android.systemui.doze.DozeMachine
import com.android.systemui.doze.DozeTransitionCallback
import com.android.systemui.doze.DozeTransitionListener
import com.android.systemui.dreams.DreamCallbackController
import com.android.systemui.dreams.DreamCallbackController.DreamCallback
import com.android.systemui.keyguard.WakefulnessLifecycle
import com.android.systemui.keyguard.shared.model.BiometricUnlockModel
import com.android.systemui.keyguard.shared.model.BiometricUnlockSource
@@ -47,6 +49,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.merge
/** Defines interface for classes that encapsulate application state for the keyguard. */
interface KeyguardRepository {
@@ -176,6 +179,7 @@ constructor(
private val keyguardUpdateMonitor: KeyguardUpdateMonitor,
private val dozeTransitionListener: DozeTransitionListener,
private val authController: AuthController,
private val dreamCallbackController: DreamCallbackController,
) : KeyguardRepository {
private val _animateBottomAreaDozingTransitions = MutableStateFlow(false)
override val animateBottomAreaDozingTransitions =
@@ -276,22 +280,35 @@ constructor(
.distinctUntilChanged()
override val isDreaming: Flow<Boolean> =
conflatedCallbackFlow {
val callback =
object : KeyguardUpdateMonitorCallback() {
override fun onDreamingStateChanged(isDreaming: Boolean) {
trySendWithFailureLogging(isDreaming, TAG, "updated isDreaming")
merge(
conflatedCallbackFlow {
val callback =
object : KeyguardUpdateMonitorCallback() {
override fun onDreamingStateChanged(isDreaming: Boolean) {
trySendWithFailureLogging(isDreaming, TAG, "updated isDreaming")
}
}
}
keyguardUpdateMonitor.registerCallback(callback)
trySendWithFailureLogging(
keyguardUpdateMonitor.isDreaming,
TAG,
"initial isDreaming",
)
keyguardUpdateMonitor.registerCallback(callback)
trySendWithFailureLogging(
keyguardUpdateMonitor.isDreaming,
TAG,
"initial isDreaming",
)
awaitClose { keyguardUpdateMonitor.removeCallback(callback) }
}
awaitClose { keyguardUpdateMonitor.removeCallback(callback) }
},
conflatedCallbackFlow {
val callback =
object : DreamCallback {
override fun onWakeUp() {
trySendWithFailureLogging(false, TAG, "updated isDreaming")
}
}
dreamCallbackController.addCallback(callback)
awaitClose { dreamCallbackController.removeCallback(callback) }
}
)
.distinctUntilChanged()
override val linearDozeAmount: Flow<Float> = conflatedCallbackFlow {

View File

@@ -66,8 +66,8 @@ interface KeyguardTransitionRepository {
}
/**
* Begin a transition from one state to another. Will not start if another transition is in
* progress.
* Begin a transition from one state to another. Transitions are interruptible, and will issue a
* [TransitionStep] with state = [TransitionState.CANCELED] before beginning the next one.
*/
fun startTransition(info: TransitionInfo): UUID?

View File

@@ -28,6 +28,8 @@ import com.android.systemui.keyguard.shared.model.KeyguardState
import com.android.systemui.keyguard.shared.model.TransitionInfo
import com.android.systemui.util.kotlin.sample
import javax.inject.Inject
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
@@ -110,7 +112,7 @@ constructor(
name,
KeyguardState.DREAMING,
KeyguardState.LOCKSCREEN,
getAnimator(),
getAnimator(TO_LOCKSCREEN_DURATION),
)
)
}
@@ -167,14 +169,16 @@ constructor(
}
}
private fun getAnimator(): ValueAnimator {
private fun getAnimator(duration: Duration = DEFAULT_DURATION): ValueAnimator {
return ValueAnimator().apply {
setInterpolator(Interpolators.LINEAR)
setDuration(TRANSITION_DURATION_MS)
setDuration(duration.inWholeMilliseconds)
}
}
companion object {
private const val TRANSITION_DURATION_MS = 500L
private val DEFAULT_DURATION = 500.milliseconds
val TO_LOCKSCREEN_DURATION = 1183.milliseconds
@JvmField val TO_LOCKSCREEN_DURATION_MS = TO_LOCKSCREEN_DURATION.inWholeMilliseconds
}
}

View File

@@ -19,12 +19,15 @@ package com.android.systemui.keyguard.domain.interactor
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.keyguard.data.repository.KeyguardTransitionRepository
import com.android.systemui.keyguard.shared.model.AnimationParams
import com.android.systemui.keyguard.shared.model.KeyguardState
import com.android.systemui.keyguard.shared.model.KeyguardState.AOD
import com.android.systemui.keyguard.shared.model.KeyguardState.DREAMING
import com.android.systemui.keyguard.shared.model.KeyguardState.LOCKSCREEN
import com.android.systemui.keyguard.shared.model.TransitionState
import com.android.systemui.keyguard.shared.model.TransitionStep
import javax.inject.Inject
import kotlin.time.Duration
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
@@ -43,6 +46,10 @@ constructor(
/** LOCKSCREEN->AOD transition information. */
val lockscreenToAodTransition: Flow<TransitionStep> = repository.transition(LOCKSCREEN, AOD)
/** DREAMING->LOCKSCREEN transition information. */
val dreamingToLockscreenTransition: Flow<TransitionStep> =
repository.transition(DREAMING, LOCKSCREEN)
/** (any)->AOD transition information */
val anyStateToAodTransition: Flow<TransitionStep> =
repository.transitions.filter { step -> step.to == KeyguardState.AOD }
@@ -72,4 +79,21 @@ constructor(
/* The last completed [KeyguardState] transition */
val finishedKeyguardState: Flow<KeyguardState> =
finishedKeyguardTransitionStep.map { step -> step.to }
/**
* Transitions will occur over a [totalDuration] with [TransitionStep]s being emitted in the
* range of [0, 1]. View animations should begin and end within a subset of this range. This
* function maps the [startTime] and [duration] into [0, 1], when this subset is valid.
*/
fun transitionStepAnimation(
flow: Flow<TransitionStep>,
params: AnimationParams,
totalDuration: Duration,
): Flow<Float> {
val start = (params.startTime / totalDuration).toFloat()
val chunks = (totalDuration / params.duration).toFloat()
return flow
.map { step -> (step.value - start) * chunks }
.filter { value -> value >= 0f && value <= 1f }
}
}

View File

@@ -0,0 +1,25 @@
/*
* Copyright (C) 2022 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.shared.model
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
/** Animation parameters */
data class AnimationParams(
val startTime: Duration = 0.milliseconds,
val duration: Duration,
)

View File

@@ -0,0 +1,79 @@
/*
* Copyright (C) 2022 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.animation.Interpolators.EMPHASIZED_ACCELERATE
import com.android.systemui.dagger.SysUISingleton
import com.android.systemui.keyguard.domain.interactor.DreamingTransitionInteractor.Companion.TO_LOCKSCREEN_DURATION
import com.android.systemui.keyguard.domain.interactor.KeyguardTransitionInteractor
import com.android.systemui.keyguard.shared.model.AnimationParams
import javax.inject.Inject
import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
/**
* Breaks down DREAMING->LOCKSCREEN transition into discrete steps for corresponding views to
* consume.
*/
@SysUISingleton
class DreamingToLockscreenTransitionViewModel
@Inject
constructor(
private val interactor: KeyguardTransitionInteractor,
) {
/** Dream overlay y-translation on exit */
fun dreamOverlayTranslationY(translatePx: Int): Flow<Float> {
return flowForAnimation(DREAM_OVERLAY_TRANSLATION_Y).map { value ->
EMPHASIZED_ACCELERATE.getInterpolation(value) * translatePx
}
}
/** Dream overlay views alpha - fade out */
val dreamOverlayAlpha: Flow<Float> = flowForAnimation(DREAM_OVERLAY_ALPHA).map { 1f - it }
/** Dream background alpha - fade out */
val dreamBackgroundAlpha: Flow<Float> = flowForAnimation(DREAM_BACKGROUND_ALPHA).map { 1f - it }
/** Lockscreen views y-translation */
fun lockscreenTranslationY(translatePx: Int): Flow<Float> {
return flowForAnimation(LOCKSCREEN_TRANSLATION_Y).map { it * translatePx }
}
/** Lockscreen views alpha */
val lockscreenAlpha: Flow<Float> = flowForAnimation(LOCKSCREEN_ALPHA)
private fun flowForAnimation(params: AnimationParams): Flow<Float> {
return interactor.transitionStepAnimation(
interactor.dreamingToLockscreenTransition,
params,
totalDuration = TO_LOCKSCREEN_DURATION
)
}
companion object {
/* Length of time before ending the dream activity, in order to start unoccluding */
val DREAM_ANIMATION_DURATION = 250.milliseconds
val DREAM_OVERLAY_TRANSLATION_Y = AnimationParams(duration = 500.milliseconds)
val DREAM_OVERLAY_ALPHA = AnimationParams(duration = 250.milliseconds)
val DREAM_BACKGROUND_ALPHA = AnimationParams(duration = 250.milliseconds)
val LOCKSCREEN_TRANSLATION_Y = AnimationParams(duration = TO_LOCKSCREEN_DURATION)
val LOCKSCREEN_ALPHA =
AnimationParams(startTime = 233.milliseconds, duration = 250.milliseconds)
}
}

View File

@@ -0,0 +1,63 @@
/*
* Copyright (C) 2022 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.dreams
import android.testing.AndroidTestingRunner
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.Mockito.never
import org.mockito.Mockito.reset
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
import org.mockito.MockitoAnnotations
@SmallTest
@RunWith(AndroidTestingRunner::class)
class DreamCallbackControllerTest : SysuiTestCase() {
@Mock private lateinit var callback: DreamCallbackController.DreamCallback
private lateinit var underTest: DreamCallbackController
@Before
fun setUp() {
MockitoAnnotations.initMocks(this)
underTest = DreamCallbackController()
}
@Test
fun testOnWakeUpInvokesCallback() {
underTest.addCallback(callback)
underTest.onWakeUp()
verify(callback).onWakeUp()
// Adding twice should not invoke twice
reset(callback)
underTest.addCallback(callback)
underTest.onWakeUp()
verify(callback, times(1)).onWakeUp()
// After remove, no call to callback
reset(callback)
underTest.removeCallback(callback)
underTest.onWakeUp()
verify(callback, never()).onWakeUp()
}
}

View File

@@ -1,18 +1,25 @@
package com.android.systemui.dreams
import android.animation.Animator
import android.animation.AnimatorSet
import android.testing.AndroidTestingRunner
import android.view.View
import androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.dreams.complication.ComplicationHostViewController
import com.android.systemui.keyguard.ui.viewmodel.DreamingToLockscreenTransitionViewModel
import com.android.systemui.statusbar.BlurUtils
import com.android.systemui.util.mockito.argumentCaptor
import com.android.systemui.statusbar.policy.ConfigurationController
import com.android.systemui.util.concurrency.DelayableExecutor
import com.android.systemui.util.mockito.mock
import com.android.systemui.util.mockito.whenever
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.Mockito.anyLong
import org.mockito.Mockito.eq
import org.mockito.Mockito.never
import org.mockito.Mockito.times
import org.mockito.Mockito.verify
@@ -28,14 +35,6 @@ class DreamOverlayAnimationsControllerTest : SysuiTestCase() {
private const val DREAM_IN_COMPLICATIONS_ANIMATION_DURATION = 3L
private const val DREAM_IN_TRANSLATION_Y_DISTANCE = 6
private const val DREAM_IN_TRANSLATION_Y_DURATION = 7L
private const val DREAM_OUT_TRANSLATION_Y_DISTANCE = 6
private const val DREAM_OUT_TRANSLATION_Y_DURATION = 7L
private const val DREAM_OUT_TRANSLATION_Y_DELAY_BOTTOM = 8L
private const val DREAM_OUT_TRANSLATION_Y_DELAY_TOP = 9L
private const val DREAM_OUT_ALPHA_DURATION = 10L
private const val DREAM_OUT_ALPHA_DELAY_BOTTOM = 11L
private const val DREAM_OUT_ALPHA_DELAY_TOP = 12L
private const val DREAM_OUT_BLUR_DURATION = 13L
}
@Mock private lateinit var mockAnimator: AnimatorSet
@@ -43,6 +42,8 @@ class DreamOverlayAnimationsControllerTest : SysuiTestCase() {
@Mock private lateinit var hostViewController: ComplicationHostViewController
@Mock private lateinit var statusBarViewController: DreamOverlayStatusBarViewController
@Mock private lateinit var stateController: DreamOverlayStateController
@Mock private lateinit var configController: ConfigurationController
@Mock private lateinit var transitionViewModel: DreamingToLockscreenTransitionViewModel
private lateinit var controller: DreamOverlayAnimationsController
@Before
@@ -55,71 +56,48 @@ class DreamOverlayAnimationsControllerTest : SysuiTestCase() {
statusBarViewController,
stateController,
DREAM_BLUR_RADIUS,
transitionViewModel,
configController,
DREAM_IN_BLUR_ANIMATION_DURATION,
DREAM_IN_COMPLICATIONS_ANIMATION_DURATION,
DREAM_IN_TRANSLATION_Y_DISTANCE,
DREAM_IN_TRANSLATION_Y_DURATION,
DREAM_OUT_TRANSLATION_Y_DISTANCE,
DREAM_OUT_TRANSLATION_Y_DURATION,
DREAM_OUT_TRANSLATION_Y_DELAY_BOTTOM,
DREAM_OUT_TRANSLATION_Y_DELAY_TOP,
DREAM_OUT_ALPHA_DURATION,
DREAM_OUT_ALPHA_DELAY_BOTTOM,
DREAM_OUT_ALPHA_DELAY_TOP,
DREAM_OUT_BLUR_DURATION
)
val mockView: View = mock()
whenever(mockView.resources).thenReturn(mContext.resources)
runBlocking(Dispatchers.Main.immediate) { controller.init(mockView) }
}
@Test
fun testExitAnimationOnEnd() {
val mockCallback: () -> Unit = mock()
fun testWakeUpCallsExecutor() {
val mockExecutor: DelayableExecutor = mock()
val mockCallback: Runnable = mock()
controller.startExitAnimations(
view = mock(),
controller.wakeUp(
doneCallback = mockCallback,
animatorBuilder = { mockAnimator }
executor = mockExecutor,
)
val captor = argumentCaptor<Animator.AnimatorListener>()
verify(mockAnimator).addListener(captor.capture())
val listener = captor.value
verify(mockCallback, never()).invoke()
listener.onAnimationEnd(mockAnimator)
verify(mockCallback, times(1)).invoke()
verify(mockExecutor).executeDelayed(eq(mockCallback), anyLong())
}
@Test
fun testCancellation() {
controller.startExitAnimations(
view = mock(),
doneCallback = mock(),
animatorBuilder = { mockAnimator }
)
verify(mockAnimator, never()).cancel()
controller.cancelAnimations()
verify(mockAnimator, times(1)).cancel()
}
@Test
fun testExitAfterStartWillCancel() {
fun testWakeUpAfterStartWillCancel() {
val mockStartAnimator: AnimatorSet = mock()
val mockExitAnimator: AnimatorSet = mock()
controller.startEntryAnimations(view = mock(), animatorBuilder = { mockStartAnimator })
controller.startEntryAnimations(animatorBuilder = { mockStartAnimator })
verify(mockStartAnimator, never()).cancel()
controller.startExitAnimations(
view = mock(),
controller.wakeUp(
doneCallback = mock(),
animatorBuilder = { mockExitAnimator }
executor = mock(),
)
// Verify that we cancelled the start animator in favor of the exit
// animator.
verify(mockStartAnimator, times(1)).cancel()
verify(mockExitAnimator, never()).cancel()
}
}

View File

@@ -203,7 +203,7 @@ public class DreamOverlayContainerViewControllerTest extends SysuiTestCase {
mController.onViewAttached();
verify(mAnimationsController).startEntryAnimations(mDreamOverlayContainerView);
verify(mAnimationsController).startEntryAnimations();
verify(mAnimationsController, never()).cancelAnimations();
}
@@ -213,7 +213,7 @@ public class DreamOverlayContainerViewControllerTest extends SysuiTestCase {
mController.onViewAttached();
verify(mAnimationsController, never()).startEntryAnimations(mDreamOverlayContainerView);
verify(mAnimationsController, never()).startEntryAnimations();
}
@Test

View File

@@ -113,6 +113,9 @@ public class DreamOverlayServiceTest extends SysuiTestCase {
@Mock
UiEventLogger mUiEventLogger;
@Mock
DreamCallbackController mDreamCallbackController;
@Captor
ArgumentCaptor<View> mViewCaptor;
@@ -141,7 +144,8 @@ public class DreamOverlayServiceTest extends SysuiTestCase {
mStateController,
mKeyguardUpdateMonitor,
mUiEventLogger,
LOW_LIGHT_COMPONENT);
LOW_LIGHT_COMPONENT,
mDreamCallbackController);
}
@Test
@@ -353,6 +357,7 @@ public class DreamOverlayServiceTest extends SysuiTestCase {
mService.onWakeUp(callback);
mMainExecutor.runAllReady();
verify(mDreamOverlayContainerViewController).wakeUp(callback, mMainExecutor);
verify(mDreamCallbackController).onWakeUp();
}
@Test

View File

@@ -28,6 +28,8 @@ import com.android.systemui.doze.DozeHost
import com.android.systemui.doze.DozeMachine
import com.android.systemui.doze.DozeTransitionCallback
import com.android.systemui.doze.DozeTransitionListener
import com.android.systemui.dreams.DreamCallbackController
import com.android.systemui.dreams.DreamCallbackController.DreamCallback
import com.android.systemui.keyguard.WakefulnessLifecycle
import com.android.systemui.keyguard.shared.model.BiometricUnlockModel
import com.android.systemui.keyguard.shared.model.BiometricUnlockSource
@@ -66,6 +68,7 @@ class KeyguardRepositoryImplTest : SysuiTestCase() {
@Mock private lateinit var dozeTransitionListener: DozeTransitionListener
@Mock private lateinit var authController: AuthController
@Mock private lateinit var keyguardUpdateMonitor: KeyguardUpdateMonitor
@Mock private lateinit var dreamCallbackController: DreamCallbackController
private lateinit var underTest: KeyguardRepositoryImpl
@@ -83,6 +86,7 @@ class KeyguardRepositoryImplTest : SysuiTestCase() {
keyguardUpdateMonitor,
dozeTransitionListener,
authController,
dreamCallbackController,
)
}
@@ -318,7 +322,7 @@ class KeyguardRepositoryImplTest : SysuiTestCase() {
}
@Test
fun isDreaming() =
fun isDreamingFromKeyguardUpdateMonitor() =
runTest(UnconfinedTestDispatcher()) {
whenever(keyguardUpdateMonitor.isDreaming()).thenReturn(false)
var latest: Boolean? = null
@@ -338,6 +342,26 @@ class KeyguardRepositoryImplTest : SysuiTestCase() {
job.cancel()
}
@Test
fun isDreamingFromDreamCallbackController() =
runTest(UnconfinedTestDispatcher()) {
whenever(keyguardUpdateMonitor.isDreaming()).thenReturn(true)
var latest: Boolean? = null
val job = underTest.isDreaming.onEach { latest = it }.launchIn(this)
assertThat(latest).isTrue()
val listener =
withArgCaptor<DreamCallbackController.DreamCallback> {
verify(dreamCallbackController).addCallback(capture())
}
listener.onWakeUp()
assertThat(latest).isFalse()
job.cancel()
}
@Test
fun biometricUnlockState() =
runTest(UnconfinedTestDispatcher()) {

View File

@@ -0,0 +1,127 @@
/*
* Copyright (C) 2022 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 androidx.test.filters.SmallTest
import com.android.systemui.SysuiTestCase
import com.android.systemui.animation.Interpolators.EMPHASIZED_ACCELERATE
import com.android.systemui.keyguard.data.repository.FakeKeyguardTransitionRepository
import com.android.systemui.keyguard.domain.interactor.DreamingTransitionInteractor.Companion.TO_LOCKSCREEN_DURATION
import com.android.systemui.keyguard.domain.interactor.KeyguardTransitionInteractor
import com.android.systemui.keyguard.shared.model.AnimationParams
import com.android.systemui.keyguard.shared.model.KeyguardState
import com.android.systemui.keyguard.shared.model.TransitionState
import com.android.systemui.keyguard.shared.model.TransitionStep
import com.android.systemui.keyguard.ui.viewmodel.DreamingToLockscreenTransitionViewModel.Companion.DREAM_OVERLAY_ALPHA
import com.android.systemui.keyguard.ui.viewmodel.DreamingToLockscreenTransitionViewModel.Companion.DREAM_OVERLAY_TRANSLATION_Y
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.JUnit4
@SmallTest
@RunWith(JUnit4::class)
class DreamingToLockscreenTransitionViewModelTest : SysuiTestCase() {
private lateinit var underTest: DreamingToLockscreenTransitionViewModel
private lateinit var repository: FakeKeyguardTransitionRepository
@Before
fun setUp() {
repository = FakeKeyguardTransitionRepository()
val interactor = KeyguardTransitionInteractor(repository)
underTest = DreamingToLockscreenTransitionViewModel(interactor)
}
@Test
fun dreamOverlayTranslationY() =
runTest(UnconfinedTestDispatcher()) {
val values = mutableListOf<Float>()
val pixels = 100
val job =
underTest.dreamOverlayTranslationY(pixels).onEach { values.add(it) }.launchIn(this)
repository.sendTransitionStep(step(0f))
repository.sendTransitionStep(step(0.3f))
repository.sendTransitionStep(step(0.5f))
repository.sendTransitionStep(step(1f))
// Only two values should be present, since the dream overlay runs for a small fraction
// of
// the overall animation time
assertThat(values.size).isEqualTo(2)
assertThat(values[0])
.isEqualTo(
EMPHASIZED_ACCELERATE.getInterpolation(
animValue(0f, DREAM_OVERLAY_TRANSLATION_Y)
) * pixels
)
assertThat(values[1])
.isEqualTo(
EMPHASIZED_ACCELERATE.getInterpolation(
animValue(0.3f, DREAM_OVERLAY_TRANSLATION_Y)
) * pixels
)
job.cancel()
}
@Test
fun dreamOverlayFadeOut() =
runTest(UnconfinedTestDispatcher()) {
val values = mutableListOf<Float>()
val job = underTest.dreamOverlayAlpha.onEach { values.add(it) }.launchIn(this)
repository.sendTransitionStep(step(0f))
repository.sendTransitionStep(step(0.1f))
repository.sendTransitionStep(step(0.5f))
repository.sendTransitionStep(step(1f))
// Only two values should be present, since the dream overlay runs for a small fraction
// of
// the overall animation time
assertThat(values.size).isEqualTo(2)
assertThat(values[0]).isEqualTo(1f - animValue(0f, DREAM_OVERLAY_ALPHA))
assertThat(values[1]).isEqualTo(1f - animValue(0.1f, DREAM_OVERLAY_ALPHA))
job.cancel()
}
private fun animValue(stepValue: Float, params: AnimationParams): Float {
val totalDuration = TO_LOCKSCREEN_DURATION
val startValue = (params.startTime / totalDuration).toFloat()
val multiplier = (totalDuration / params.duration).toFloat()
return (stepValue - startValue) * multiplier
}
private fun step(value: Float): TransitionStep {
return TransitionStep(
from = KeyguardState.DREAMING,
to = KeyguardState.LOCKSCREEN,
value = value,
transitionState = TransitionState.RUNNING,
ownerName = "DreamingToLockscreenTransitionViewModelTest"
)
}
}