Refactor DialogLaunchAnimator and remove host dialog (1/2)

This CL is a refactoring of the DialogLaunchAnimator and a major change
in how it works internally.

Before this CL, when DialogLaunchAnimator.show(originalDialog, ...) was
called, it would create a fullscreen dialog (called hostDialog) to
which we would add the content view stolen from the originalDialog (that
would then be hidden). We would then run the launch animation in the
host dialog and listen for show(), hide() or dismiss() calls to the
originalDialog to know when we should show(), hide() or dismiss() the
hostDialog.

The main reason we did that was because there was no way to override the
dismiss() behavior of the originalDialog, which we need to do given that
we want to animate the dialog into the view that showed the dialog
before actually dismissing it.

This approach had multiple downsides:
 - We were showing the dialog content view inside a different window
   than the one in which it was created, therefore any change made to or
   any listener added to the originalDialog window was lost.
 - Code calling DialogLaunchAnimator.showFromView needed to know that
   their dialog content will be moved to another window, which is
   unexpected and can lead to subtle bugs.
 - We were waiting for 2 dialogs to be shown instead of 1 before being
   able to start the animation.

This CL does what we should have done since the beginning: it adds a
hidden API to Dialog so that we can override what happens when
Dialog.dismiss() is called. This allows the DialogLaunchAnimator to run
the exit animation into the view that triggered the dialog before
actually dismissing it. The only modification that
DialogLaunchAnimator.show(originalDialog, ...) now does to the
originalDialog is making its window fullscreen and inserting two views
between the originalDialog DecorView and its children. The first
inserted view is a fullscreen transparent background used to dismiss the
dialog when the user taps outside the dialog content. The second
inserted view is a View that we size and position the same way that the
DecorView was before we made it fullscreen, and to which we set the
originalDialog background. This view now serves as a "fake window" with
the same size, background and position as the original window
(DecorView).

This CL improves the time to start the launch animation by 20-25%
(measured in a totally unrigorous logcat way).

Bug: 193634619
Test: atest DialogLaunchAnimatorTest
Change-Id: If09eda7b06e83b3ed7714cec97afef08b3d9fd3e
This commit is contained in:
Jordan Demeulenaere
2021-11-23 14:44:18 +01:00
parent a5f83144a3
commit c424d4801f
16 changed files with 349 additions and 696 deletions

View File

@@ -151,6 +151,9 @@ public class Dialog implements DialogInterface, Window.Callback,
private final Runnable mDismissAction = this::dismissDialog;
/** A {@link Runnable} to run instead of dismissing when {@link #dismiss()} is called. */
private Runnable mDismissOverride;
/**
* Creates a dialog window that uses the default dialog theme.
* <p>
@@ -370,6 +373,11 @@ public class Dialog implements DialogInterface, Window.Callback,
*/
@Override
public void dismiss() {
if (mDismissOverride != null) {
mDismissOverride.run();
return;
}
if (Looper.myLooper() == mHandler.getLooper()) {
dismissDialog();
} else {
@@ -1354,6 +1362,21 @@ public class Dialog implements DialogInterface, Window.Callback,
mDismissMessage = msg;
}
/**
* Set a {@link Runnable} to run when this dialog is dismissed instead of directly dismissing
* it. This allows to animate the dialog in its window before dismissing it.
*
* Note that {@code override} should always end up calling this method with {@code null}
* followed by a call to {@link #dismiss() dismiss} to actually dismiss the dialog.
*
* @see #dismiss()
*
* @hide
*/
public void setDismissOverride(@Nullable Runnable override) {
mDismissOverride = override;
}
/** @hide */
public boolean takeCancelAndDismissListeners(@Nullable String msg,
@Nullable OnCancelListener cancel, @Nullable OnDismissListener dismiss) {

View File

@@ -16,5 +16,4 @@
-->
<resources>
<item type="id" name="launch_animation_running"/>
<item type="id" name="dialog_content_parent" />
</resources>

View File

@@ -15,15 +15,10 @@
limitations under the License.
-->
<resources>
<style name="HostDialogTheme">
<item name="android:windowAnimationStyle">@style/Animation.HostDialog</item>
<item name="android:windowIsFloating">false</item>
<item name="android:backgroundDimEnabled">true</item>
<item name="android:navigationBarColor">@android:color/transparent</item>
</style>
<style name="Animation.HostDialog" parent="@android:style/Animation">
<item name="android:windowEnterAnimation">@anim/launch_host_dialog_enter</item>
<item name="android:windowExitAnimation">@anim/launch_host_dialog_exit</item>
<!-- An animation used by DialogLaunchAnimator to make a dialog appear instantly (to animate -->
<!-- in-window) and disappear by fading out (when the exit into view is disabled). -->
<style name="Animation.LaunchAnimation" parent="@android:style/Animation">
<item name="android:windowEnterAnimation">@anim/launch_dialog_enter</item>
<item name="android:windowExitAnimation">@anim/launch_dialog_exit</item>
</style>
</resources>

View File

@@ -24,22 +24,19 @@ import android.content.Context
import android.graphics.Color
import android.graphics.Rect
import android.os.Looper
import android.service.dreams.IDreamManager
import android.util.Log
import android.util.MathUtils
import android.view.GhostView
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.ViewTreeObserver.OnPreDrawListener
import android.view.WindowInsets
import android.view.WindowManager
import android.view.WindowManager.LayoutParams.FLAG_LAYOUT_INSET_DECOR
import android.view.WindowManager.LayoutParams.FLAG_LAYOUT_IN_SCREEN
import android.view.WindowManagerPolicyConstants
import android.widget.FrameLayout
import kotlin.math.roundToInt
private const val TAG = "DialogLaunchAnimator"
private val DIALOG_CONTENT_PARENT_ID = R.id.dialog_content_parent
/**
* A class that allows dialogs to be started in a seamless way from a view that is transforming
@@ -48,7 +45,7 @@ private val DIALOG_CONTENT_PARENT_ID = R.id.dialog_content_parent
class DialogLaunchAnimator(
private val context: Context,
private val launchAnimator: LaunchAnimator,
private val hostDialogProvider: HostDialogProvider
private val dreamManager: IDreamManager
) {
private companion object {
private val TAG_LAUNCH_ANIMATION_RUNNING = R.id.launch_animation_running
@@ -62,41 +59,38 @@ class DialogLaunchAnimator(
private val openedDialogs = hashSetOf<AnimatedDialog>()
/**
* Show [dialog] by expanding it from [view]. If [animateBackgroundBoundsChange] is true, then
* the background of the dialog will be animated when the dialog bounds change.
* Show [dialog] by expanding it from [view]. If [view] is a view inside another dialog that was
* shown using this method, then we will animate from that dialog instead.
*
* Caveats: When calling this function, the dialog content view will actually be stolen and
* attached to a different dialog (and thus a different window) which means that the actual
* dialog window will never be drawn. Moreover, unless [dialog] is a [ListenableDialog], you
* must call dismiss(), hide() and show() on the [Dialog] returned by this function to actually
* dismiss, hide or show the dialog.
* If [animateBackgroundBoundsChange] is true, then the background of the dialog will be
* animated when the dialog bounds change.
*
* Caveats: When calling this function and [dialog] is not a fullscreen dialog, then it will be
* made fullscreen and 2 views will be inserted between the dialog DecorView and its children.
*/
@JvmOverloads
fun showFromView(
dialog: Dialog,
view: View,
animateBackgroundBoundsChange: Boolean = false
): Dialog {
) {
if (Looper.myLooper() != Looper.getMainLooper()) {
throw IllegalStateException(
"showFromView must be called from the main thread and dialog must be created in " +
"the main thread")
}
// If the parent of the view we are launching from is the background of some other animated
// dialog, then this means the caller intent is to launch a dialog from another dialog. In
// this case, we also animate the parent (which is the dialog background).
val animatedParent = openedDialogs.firstOrNull {
it.dialogContentWithBackground == view || it.dialogContentWithBackground == view.parent
}
val dialogContentWithBackground = animatedParent?.dialogContentWithBackground
val animateFrom = dialogContentWithBackground ?: view
// If the view we are launching from belongs to another dialog, then this means the caller
// intent is to launch a dialog from another dialog.
val animatedParent = openedDialogs
.firstOrNull { it.dialog.window.decorView.viewRootImpl == view.viewRootImpl }
val animateFrom = animatedParent?.dialogContentWithBackground ?: view
// Make sure we don't run the launch animation from the same view twice at the same time.
if (animateFrom.getTag(TAG_LAUNCH_ANIMATION_RUNNING) != null) {
Log.e(TAG, "Not running dialog launch animation as there is already one running")
dialog.show()
return dialog
return
}
animateFrom.setTag(TAG_LAUNCH_ANIMATION_RUNNING, true)
@@ -104,82 +98,36 @@ class DialogLaunchAnimator(
val animatedDialog = AnimatedDialog(
context,
launchAnimator,
hostDialogProvider,
dreamManager,
animateFrom,
onDialogDismissed = { openedDialogs.remove(it) },
originalDialog = dialog,
dialog = dialog,
animateBackgroundBoundsChange,
animatedParent
)
val hostDialog = animatedDialog.hostDialog
openedDialogs.add(animatedDialog)
// If the dialog is dismissed/hidden/shown, then we should actually dismiss/hide/show the
// host dialog.
if (dialog is ListenableDialog) {
dialog.addListener(object : DialogListener {
override fun onDismiss(reason: DialogListener.DismissReason) {
dialog.removeListener(this)
// We disable the exit animation if we are dismissing the dialog because the
// device is being locked, otherwise the animation looks bad if AOD is enabled.
// If AOD is disabled the screen will directly becomes black and we won't see
// the animation anyways.
if (reason == DialogListener.DismissReason.DEVICE_LOCKED) {
animatedDialog.exitAnimationDisabled = true
}
hostDialog.dismiss()
}
override fun onHide() {
if (animatedDialog.ignoreNextCallToHide) {
animatedDialog.ignoreNextCallToHide = false
return
}
hostDialog.hide()
}
override fun onShow() {
hostDialog.show()
// We don't actually want to show the original dialog, so hide it.
animatedDialog.ignoreNextCallToHide = true
dialog.hide()
}
override fun onSizeChanged() {
animatedDialog.onOriginalDialogSizeChanged()
}
override fun prepareForStackDismiss() {
animatedDialog.touchSurface = animatedDialog.prepareForStackDismiss()
}
})
}
animatedDialog.start()
return hostDialog
}
/**
* Launch [dialog] from a [parentHostDialog] as returned by [showFromView]. This will allow
* for dismissing the whole stack.
* Launch [dialog] from [another dialog][animateFrom] that was shown using [showFromView]. This
* will allow for dismissing the whole stack.
*
* This will return a new host dialog, with the same caveat as [showFromView].
*
* @see DialogListener.prepareForStackDismiss
* @see dismissStack
*/
fun showFromDialog(
dialog: Dialog,
parentHostDialog: Dialog,
animateFrom: Dialog,
animateBackgroundBoundsChange: Boolean = false
): Dialog {
val view = parentHostDialog.findViewById<ViewGroup>(DIALOG_CONTENT_PARENT_ID)
?.getChildAt(0)
?: throw IllegalStateException("No dialog content parent found in host dialog")
return showFromView(dialog, view, animateBackgroundBoundsChange)
) {
val view = openedDialogs
.firstOrNull { it.dialog == animateFrom }
?.dialogContentWithBackground
?: throw IllegalStateException(
"The animateFrom dialog was not animated using " +
"DialogLaunchAnimator.showFrom(View|Dialog)")
showFromView(dialog, view, animateBackgroundBoundsChange)
}
/**
@@ -195,69 +143,23 @@ class DialogLaunchAnimator(
fun disableAllCurrentDialogsExitAnimations() {
openedDialogs.forEach { it.exitAnimationDisabled = true }
}
}
interface HostDialogProvider {
/**
* Create a host dialog that will be used to host a launch animation. This host dialog must:
* 1. call [onCreateCallback] in its onCreate() method, e.g. right after calling
* super.onCreate().
* 2. call [dismissOverride] instead of doing any dismissing logic. The actual dismissing
* logic should instead be done inside the lambda passed to [dismissOverride], which will
* be called after the exit animation.
* 3. Be full screen, i.e. have a window matching its parent size.
*
* See SystemUIHostDialogProvider for an example of implementation.
* Dismiss [dialog]. If it was launched from another dialog using [showFromView], also dismiss
* the stack of dialogs, animating back to the original touchSurface.
*/
fun createHostDialog(
context: Context,
theme: Int,
onCreateCallback: () -> Unit,
dismissOverride: (() -> Unit) -> Unit
): Dialog
}
/** A dialog to/from which we can add/remove listeners. */
interface ListenableDialog {
/** Add [listener] to the listeners. */
fun addListener(listener: DialogListener)
/** Remove [listener] from the listeners. */
fun removeListener(listener: DialogListener)
}
interface DialogListener {
/** The reason why a dialog was dismissed. */
enum class DismissReason {
UNKNOWN,
/** The device was locked, which dismissed this dialog. */
DEVICE_LOCKED,
fun dismissStack(dialog: Dialog) {
openedDialogs
.firstOrNull { it.dialog == dialog }
?.let { it.touchSurface = it.prepareForStackDismiss() }
dialog.dismiss()
}
/** Called when this dialog dismiss() is called. */
fun onDismiss(reason: DismissReason)
/** Called when this dialog hide() is called. */
fun onHide()
/** Called when this dialog show() is called. */
fun onShow()
/**
* Call before dismissing a stack of dialogs (dialogs launched from dialogs), so the topmost
* can animate directly into the original `touchSurface`.
*/
fun prepareForStackDismiss()
/** Called when this dialog size might have changed, e.g. because of configuration changes. */
fun onSizeChanged()
}
private class AnimatedDialog(
private val context: Context,
private val launchAnimator: LaunchAnimator,
hostDialogProvider: HostDialogProvider,
private val dreamManager: IDreamManager,
/** The view that triggered the dialog after being tapped. */
var touchSurface: View,
@@ -268,36 +170,33 @@ private class AnimatedDialog(
*/
private val onDialogDismissed: (AnimatedDialog) -> Unit,
/** The original dialog whose content will be shown and animate in/out in [hostDialog]. */
private val originalDialog: Dialog,
/** The dialog to show and animate. */
val dialog: Dialog,
/** Whether we should animate the dialog background when its bounds change. */
private val animateBackgroundBoundsChange: Boolean,
/** Launch animation corresponding to the parent [hostDialog]. */
/** Launch animation corresponding to the parent [AnimatedDialog]. */
private val parentAnimatedDialog: AnimatedDialog? = null
) {
/**
* The fullscreen dialog to which we will add the content view [originalDialogView] of
* [originalDialog].
*/
val hostDialog = hostDialogProvider.createHostDialog(
context, R.style.HostDialogTheme, this::onHostDialogCreated, this::onHostDialogDismissed)
/** The root content view of [hostDialog]. */
private val hostDialogRoot = FrameLayout(context)
* The DecorView of this dialog window.
*
* Note that we access this DecorView lazily to avoid accessing it before the dialog is created,
* which can sometimes cause crashes (e.g. with the Cast dialog).
*/
private val decorView by lazy { dialog.window!!.decorView as ViewGroup }
/**
* The dialog content with its background. When animating a fullscreen dialog, this is just the
* first ViewGroup of the dialog that has a background. When animating a normal (not fullscreen)
* dialog, this is an additional view that serves as a fake window that will have the same size
* as the original dialog window and to which we will set the original dialog window background.
* as the dialog window initially had and to which we will set the dialog window background.
*/
var dialogContentWithBackground: ViewGroup? = null
/**
* The background color of [originalDialogView], taking into consideration the [originalDialog]
* window background color.
* The background color of [dialog], taking into consideration its window background color.
*/
private var originalDialogBackgroundColor = Color.BLACK
@@ -310,75 +209,182 @@ private class AnimatedDialog(
private var isDismissing = false
private var dismissRequested = false
var ignoreNextCallToHide = false
var exitAnimationDisabled = false
private var isTouchSurfaceGhostDrawn = false
private var isOriginalDialogViewLaidOut = false
private var backgroundLayoutListener = if (animateBackgroundBoundsChange) {
/** A layout listener to animate the dialog height change. */
private val backgroundLayoutListener = if (animateBackgroundBoundsChange) {
AnimatedBoundsLayoutListener()
} else {
null
}
/*
* A layout listener in case the dialog (window) size changes (for instance because of a
* configuration change) to ensure that the dialog stays full width.
*/
private var decorViewLayoutListener: View.OnLayoutChangeListener? = null
fun start() {
// Show the host (fullscreen) dialog, to which we will add the stolen dialog view.
hostDialog.show()
// Create the dialog so that its onCreate() method is called, which usually sets the dialog
// content.
dialog.create()
// Steal the dialog view. We do that by showing it but preventing it from drawing, then
// hiding it as soon as its content is available.
stealOriginalDialogContentView(then = this::showDialogFromView)
}
val window = dialog.window!!
val isWindowFullScreen =
window.attributes.width == MATCH_PARENT && window.attributes.height == MATCH_PARENT
val dialogContentWithBackground = if (isWindowFullScreen) {
// If the dialog window is already fullscreen, then we look for the first ViewGroup that
// has a background (and is not the DecorView, which always has a background) and
// animate towards that ViewGroup given that this is probably what represents the actual
// dialog view.
var viewGroupWithBackground: ViewGroup? = null
for (i in 0 until decorView.childCount) {
viewGroupWithBackground = findFirstViewGroupWithBackground(decorView.getChildAt(i))
if (viewGroupWithBackground != null) {
break
}
}
private fun onHostDialogCreated() {
// Make the dialog fullscreen with a transparent background.
hostDialog.setContentView(
hostDialogRoot,
ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
// Animate that view with the background. Throw if we didn't find one, because otherwise
// it's not clear what we should animate.
viewGroupWithBackground
?: throw IllegalStateException("Unable to find ViewGroup with background")
} else {
// We will make the dialog window (and therefore its DecorView) fullscreen to make it
// possible to animate outside its bounds.
//
// Before that, we add a new View as a child of the DecorView with the same size and
// gravity as that DecorView, then we add all original children of the DecorView to that
// new View. Finally we remove the background of the DecorView and add it to the new
// View, then we make the DecorView fullscreen. This new View now acts as a fake (non
// fullscreen) window.
//
// On top of that, we also add a fullscreen transparent background between the DecorView
// and the view that we added so that we can dismiss the dialog when this view is
// clicked. This is necessary because DecorView overrides onTouchEvent and therefore we
// can't set the click listener directly on the (now fullscreen) DecorView.
val fullscreenTransparentBackground = FrameLayout(context)
decorView.addView(
fullscreenTransparentBackground,
0 /* index */,
FrameLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
)
)
val window = hostDialog.window
?: throw IllegalStateException("There is no window associated to the host dialog")
window.setBackgroundDrawableResource(android.R.color.transparent)
val dialogContentWithBackground = FrameLayout(context)
dialogContentWithBackground.background = decorView.background
// If we are using gesture navigation, then we can overlay the navigation/task bars with
// the host dialog.
val navigationMode = context.resources.getInteger(
com.android.internal.R.integer.config_navBarInteractionMode)
if (navigationMode == WindowManagerPolicyConstants.NAV_BAR_MODE_GESTURAL) {
window.attributes.fitInsetsTypes = window.attributes.fitInsetsTypes and
WindowInsets.Type.navigationBars().inv()
window.addFlags(FLAG_LAYOUT_IN_SCREEN or FLAG_LAYOUT_INSET_DECOR)
window.setDecorFitsSystemWindows(false)
// Make the window background transparent. Note that setting the window (or DecorView)
// background drawable to null leads to issues with background color (not being
// transparent) or with insets that are not refreshed. Therefore we need to set it to
// something not null, hence we are using android.R.color.transparent here.
window.setBackgroundDrawableResource(android.R.color.transparent)
// Close the dialog when clicking outside of it.
fullscreenTransparentBackground.setOnClickListener { dialog.dismiss() }
dialogContentWithBackground.isClickable = true
fullscreenTransparentBackground.addView(
dialogContentWithBackground,
FrameLayout.LayoutParams(
window.attributes.width,
window.attributes.height,
window.attributes.gravity
)
)
// Move all original children of the DecorView to the new View we just added.
for (i in 1 until decorView.childCount) {
val view = decorView.getChildAt(1)
decorView.removeViewAt(1)
dialogContentWithBackground.addView(view)
}
// Make the window fullscreen and add a layout listener to ensure it stays fullscreen.
window.setLayout(MATCH_PARENT, MATCH_PARENT)
decorViewLayoutListener = View.OnLayoutChangeListener {
v, left, top, right, bottom, oldLeft, oldTop, oldRight, oldBottom ->
if (window.attributes.width != MATCH_PARENT ||
window.attributes.height != MATCH_PARENT) {
// The dialog size changed, copy its size to dialogContentWithBackground and
// make the dialog window full screen again.
val layoutParams = dialogContentWithBackground.layoutParams
layoutParams.width = window.attributes.width
layoutParams.height = window.attributes.height
dialogContentWithBackground.layoutParams = layoutParams
window.setLayout(MATCH_PARENT, MATCH_PARENT)
}
}
decorView.addOnLayoutChangeListener(decorViewLayoutListener)
dialogContentWithBackground
}
this.dialogContentWithBackground = dialogContentWithBackground
val background = dialogContentWithBackground.background
originalDialogBackgroundColor =
GhostedViewLaunchAnimatorController.findGradientDrawable(background)
?.color
?.defaultColor ?: Color.BLACK
// Make the background view invisible until we start the animation.
dialogContentWithBackground.visibility = View.INVISIBLE
// Make sure the dialog is visible instantly and does not do any window animation.
window.attributes.windowAnimations = R.style.Animation_LaunchAnimation
// Start the animation once the background view is properly laid out.
dialogContentWithBackground.addOnLayoutChangeListener(object : View.OnLayoutChangeListener {
override fun onLayoutChange(
v: View,
left: Int,
top: Int,
right: Int,
bottom: Int,
oldLeft: Int,
oldTop: Int,
oldRight: Int,
oldBottom: Int
) {
dialogContentWithBackground.removeOnLayoutChangeListener(this)
isOriginalDialogViewLaidOut = true
maybeStartLaunchAnimation()
}
})
// Disable the dim. We will enable it once we start the animation.
window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
// Override the dialog dismiss() so that we can animate the exit before actually dismissing
// the dialog.
dialog.setDismissOverride(this::onDialogDismissed)
// Show the dialog.
dialog.show()
// Add a temporary touch surface ghost as soon as the window is ready to draw. This
// temporary ghost will be drawn together with the touch surface, but in the host dialog
// temporary ghost will be drawn together with the touch surface, but in the dialog
// window. Once it is drawn, we will make the touch surface invisible, and then start the
// animation. We do all this synchronization to avoid flicker that would occur if we made
// the touch surface invisible too early (before its ghost is drawn), leading to one or more
// frames with a hole instead of the touch surface (or its ghost).
hostDialogRoot.viewTreeObserver.addOnPreDrawListener(object : OnPreDrawListener {
decorView.viewTreeObserver.addOnPreDrawListener(object : OnPreDrawListener {
override fun onPreDraw(): Boolean {
hostDialogRoot.viewTreeObserver.removeOnPreDrawListener(this)
decorView.viewTreeObserver.removeOnPreDrawListener(this)
addTemporaryTouchSurfaceGhost()
return true
}
})
hostDialogRoot.invalidate()
decorView.invalidate()
}
private fun addTemporaryTouchSurfaceGhost() {
// Create a ghost of the touch surface (which will make the touch surface invisible) and add
// it to the host dialog. We will wait for this ghost to be drawn before starting the
// animation.
val ghost = GhostView.addGhost(touchSurface, hostDialogRoot)
// it to the dialog. We will wait for this ghost to be drawn before starting the animation.
val ghost = GhostView.addGhost(touchSurface, decorView)
// The ghost of the touch surface was just created, so the touch surface was made invisible.
// We make it visible again until the ghost is actually drawn.
@@ -414,142 +420,6 @@ private class AnimatedDialog(
touchSurface.invalidate()
}
/** Get the content view of [originalDialog] and pass it to [then]. */
private fun stealOriginalDialogContentView(then: (View) -> Unit) {
// The original dialog content view will be attached to android.R.id.content when the dialog
// is shown, so we show the dialog and add an observer to get the view but also prevents the
// original dialog from being drawn.
val androidContent = originalDialog.findViewById<ViewGroup>(android.R.id.content)
?: throw IllegalStateException("Dialog does not have any android.R.id.content view")
androidContent.viewTreeObserver.addOnPreDrawListener(
object : OnPreDrawListener {
override fun onPreDraw(): Boolean {
if (androidContent.childCount == 1) {
androidContent.viewTreeObserver.removeOnPreDrawListener(this)
// Hide the animated dialog. Because of the dialog listener set up
// earlier, this would also hide the host dialog, but in this case we
// need to keep the host dialog visible.
ignoreNextCallToHide = true
originalDialog.hide()
then(androidContent.getChildAt(0))
return false
}
// Never draw the original dialog content.
return false
}
})
originalDialog.show()
}
private fun showDialogFromView(dialogView: View) {
// Close the dialog when clicking outside of it.
hostDialogRoot.setOnClickListener { hostDialog.dismiss() }
dialogView.isClickable = true
// Remove the original dialog view from its parent.
(dialogView.parent as? ViewGroup)?.removeView(dialogView)
val originalDialogWindow = originalDialog.window!!
val isOriginalWindowFullScreen =
originalDialogWindow.attributes.width == ViewGroup.LayoutParams.MATCH_PARENT &&
originalDialogWindow.attributes.height == ViewGroup.LayoutParams.MATCH_PARENT
if (isOriginalWindowFullScreen) {
// If the original dialog window is fullscreen, then we look for the first ViewGroup
// that has a background and animate towards that ViewGroup given that this is probably
// what represents the actual dialog view.
dialogContentWithBackground = findFirstViewGroupWithBackground(dialogView)
?: throw IllegalStateException("Unable to find ViewGroup with background")
hostDialogRoot.addView(
dialogView,
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
)
} else {
// Add a parent view to the original dialog view to which we will set the original
// dialog window background. This View serves as a fake window with background, so that
// we are sure that we don't override the original dialog content view paddings with the
// window background that usually has insets.
dialogContentWithBackground = FrameLayout(context).apply {
id = DIALOG_CONTENT_PARENT_ID
// TODO(b/193634619): Support dialog windows without background.
background = originalDialogWindow.decorView?.background
?: throw IllegalStateException(
"Dialogs with no backgrounds on window are not supported")
addView(
dialogView,
// It should match its parent size, which is sized the same as the original
// dialog window.
FrameLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
)
}
// Add the parent (that has the background) to the host window.
hostDialogRoot.addView(
dialogContentWithBackground,
// We give it the size and gravity of its original dialog window.
FrameLayout.LayoutParams(
originalDialogWindow.attributes.width,
originalDialogWindow.attributes.height,
originalDialogWindow.attributes.gravity
)
)
}
val dialogContentWithBackground = this.dialogContentWithBackground!!
// Make the dialog and its background invisible for now, to make sure it's not drawn yet.
dialogContentWithBackground.visibility = View.INVISIBLE
val background = dialogContentWithBackground.background!!
originalDialogBackgroundColor =
GhostedViewLaunchAnimatorController.findGradientDrawable(background)
?.color
?.defaultColor ?: Color.BLACK
if (isOriginalWindowFullScreen) {
// If the original window is full screen, the ViewGroup with background might already be
// correctly laid out. Make sure we relayout and that the layout listener below is still
// called.
dialogContentWithBackground.layout(0, 0, 0, 0)
dialogContentWithBackground.requestLayout()
}
// Start the animation when the dialog is laid out in the center of the host dialog.
dialogContentWithBackground.addOnLayoutChangeListener(object : View.OnLayoutChangeListener {
override fun onLayoutChange(
view: View,
left: Int,
top: Int,
right: Int,
bottom: Int,
oldLeft: Int,
oldTop: Int,
oldRight: Int,
oldBottom: Int
) {
dialogContentWithBackground.removeOnLayoutChangeListener(this)
isOriginalDialogViewLaidOut = true
maybeStartLaunchAnimation()
}
})
}
private fun findFirstViewGroupWithBackground(view: View): ViewGroup? {
if (view !is ViewGroup) {
return null
@@ -569,26 +439,13 @@ private class AnimatedDialog(
return null
}
fun onOriginalDialogSizeChanged() {
// The dialog is the single child of the root.
if (hostDialogRoot.childCount != 1) {
return
}
val dialogView = hostDialogRoot.getChildAt(0)
val layoutParams = dialogView.layoutParams as? FrameLayout.LayoutParams ?: return
layoutParams.width = originalDialog.window.attributes.width
layoutParams.height = originalDialog.window.attributes.height
dialogView.layoutParams = layoutParams
}
private fun maybeStartLaunchAnimation() {
if (!isTouchSurfaceGhostDrawn || !isOriginalDialogViewLaidOut) {
return
}
// Show the background dim.
hostDialog.window.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
dialog.window.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
startAnimation(
isLaunching = true,
@@ -610,7 +467,7 @@ private class AnimatedDialog(
// dismiss was called during the animation, dismiss again now to actually
// dismiss.
if (dismissRequested) {
hostDialog.dismiss()
dialog.dismiss()
}
// If necessary, we animate the dialog background when its bounds change. We do it
@@ -624,9 +481,9 @@ private class AnimatedDialog(
)
}
private fun onHostDialogDismissed(actualDismiss: () -> Unit) {
private fun onDialogDismissed() {
if (Looper.myLooper() != Looper.getMainLooper()) {
context.mainExecutor.execute { onHostDialogDismissed(actualDismiss) }
context.mainExecutor.execute { onDialogDismissed() }
return
}
@@ -641,23 +498,29 @@ private class AnimatedDialog(
}
isDismissing = true
hideDialogIntoView { instantDismiss: Boolean ->
if (instantDismiss) {
originalDialog.hide()
hostDialog.hide()
hideDialogIntoView { animationRan: Boolean ->
if (animationRan) {
// Instantly dismiss the dialog if we ran the animation into view. If it was
// skipped, dismiss() will run the window animation (which fades out the dialog).
dialog.hide()
}
originalDialog.dismiss()
actualDismiss()
dialog.setDismissOverride(null)
dialog.dismiss()
}
}
/**
* Hide the dialog into the touch surface and call [dismissDialogs] when the animation is done
* (passing instantDismiss=true) or if it's skipped (passing instantDismiss=false) to actually
* dismiss the dialogs.
* Hide the dialog into the touch surface and call [onAnimationFinished] when the animation is
* done (passing animationRan=true) or if it's skipped (passing animationRan=false) to actually
* dismiss the dialog.
*/
private fun hideDialogIntoView(dismissDialogs: (Boolean) -> Unit) {
private fun hideDialogIntoView(onAnimationFinished: (Boolean) -> Unit) {
// Remove the layout change listener we have added to the DecorView earlier.
if (decorViewLayoutListener != null) {
decorView.removeOnLayoutChangeListener(decorViewLayoutListener)
}
if (!shouldAnimateDialogIntoView()) {
Log.i(TAG, "Skipping animation of dialog into the touch surface")
@@ -669,7 +532,7 @@ private class AnimatedDialog(
touchSurface.visibility = View.VISIBLE
}
dismissDialogs(false /* instantDismiss */)
onAnimationFinished(false /* instantDismiss */)
onDialogDismissed(this@AnimatedDialog)
return
}
@@ -678,7 +541,7 @@ private class AnimatedDialog(
isLaunching = false,
onLaunchAnimationStart = {
// Remove the dim background as soon as we start the animation.
hostDialog.window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
dialog.window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND)
},
onLaunchAnimationEnd = {
// Make sure we allow the touch surface to change its visibility again.
@@ -696,7 +559,7 @@ private class AnimatedDialog(
// The animated ghost was just removed. We create a temporary ghost that will be
// removed only once we draw the touch surface, to avoid flickering that would
// happen when removing the ghost too early (before the touch surface is drawn).
GhostView.addGhost(touchSurface, hostDialogRoot)
GhostView.addGhost(touchSurface, decorView)
touchSurface.viewTreeObserver.addOnPreDrawListener(object : OnPreDrawListener {
override fun onPreDraw(): Boolean {
@@ -705,7 +568,7 @@ private class AnimatedDialog(
// Now that the touch surface was drawn, we can remove the temporary ghost
// and instantly dismiss the dialog.
GhostView.removeGhost(touchSurface)
dismissDialogs(true /* instantDismiss */)
onAnimationFinished(true /* instantDismiss */)
onDialogDismissed(this@AnimatedDialog)
return true
@@ -721,14 +584,14 @@ private class AnimatedDialog(
onLaunchAnimationStart: () -> Unit = {},
onLaunchAnimationEnd: () -> Unit = {}
) {
// Create 2 ghost controllers to animate both the dialog and the touch surface in the host
// Create 2 ghost controllers to animate both the dialog and the touch surface in the
// dialog.
val startView = if (isLaunching) touchSurface else dialogContentWithBackground!!
val endView = if (isLaunching) dialogContentWithBackground!! else touchSurface
val startViewController = GhostedViewLaunchAnimatorController(startView)
val endViewController = GhostedViewLaunchAnimatorController(endView)
startViewController.launchContainer = hostDialogRoot
endViewController.launchContainer = hostDialogRoot
startViewController.launchContainer = decorView
endViewController.launchContainer = decorView
val endState = endViewController.createAnimatorState()
val controller = object : LaunchAnimator.Controller {
@@ -785,9 +648,15 @@ private class AnimatedDialog(
}
private fun shouldAnimateDialogIntoView(): Boolean {
// Don't animate if the dialog was previously hidden using hide() (either on the host dialog
// or on the original dialog) or if we disabled the exit animation.
if (exitAnimationDisabled || !hostDialog.isShowing) {
// Don't animate if the dialog was previously hidden using hide() or if we disabled the exit
// animation.
if (exitAnimationDisabled || !dialog.isShowing) {
return false
}
// If we are dreaming, the dialog was probably closed because of that so we don't animate
// into the touchSurface.
if (dreamManager.isDreaming) {
return false
}
@@ -888,9 +757,9 @@ private class AnimatedDialog(
return touchSurface
}
parentAnimatedDialog.exitAnimationDisabled = true
parentAnimatedDialog.originalDialog.hide()
parentAnimatedDialog.dialog.hide()
val view = parentAnimatedDialog.prepareForStackDismiss()
parentAnimatedDialog.originalDialog.dismiss()
parentAnimatedDialog.dialog.dismiss()
// Make the touch surface invisible, so we end up animating to it when we actually
// dismiss the stack
view.visibility = View.INVISIBLE

View File

@@ -217,14 +217,6 @@ public abstract class MediaOutputBaseDialog extends SystemUIDialog implements
dismiss();
}
@Override
public void onWindowFocusChanged(boolean hasFocus) {
super.onWindowFocusChanged(hasFocus);
if (!hasFocus && isShowing()) {
dismiss();
}
}
void onHeaderIconClick() {
}

View File

@@ -190,14 +190,10 @@ public class DndTile extends QSTileImpl<BooleanState> {
case Settings.Secure.ZEN_DURATION_PROMPT:
mUiHandler.post(() -> {
Dialog dialog = makeZenModeDialog();
SystemUIDialog.registerDismissListener(dialog);
if (view != null) {
final Dialog hostDialog =
mDialogLaunchAnimator.showFromView(dialog, view, false);
setDialogListeners(dialog, hostDialog);
mDialogLaunchAnimator.showFromView(dialog, view, false);
} else {
// If we are not launching with animator, register default
// dismiss listener
SystemUIDialog.registerDismissListener(dialog);
dialog.show();
}
});
@@ -222,12 +218,6 @@ public class DndTile extends QSTileImpl<BooleanState> {
return dialog;
}
private void setDialogListeners(Dialog zenModeDialog, Dialog hostDialog) {
// Zen mode dialog is never hidden.
SystemUIDialog.registerDismissListener(zenModeDialog, hostDialog::dismiss);
zenModeDialog.setOnCancelListener(dialog -> hostDialog.cancel());
}
@Override
protected void handleSecondaryClick(@Nullable View view) {
if (mController.isVolumeRestricted()) {

View File

@@ -94,24 +94,24 @@ class UserSwitchDialogController @VisibleForTesting constructor(
adapter.linkToViewGroup(gridFrame.findViewById(R.id.grid))
val hostDialog = dialogLaunchAnimator.showFromView(this, view)
adapter.injectDialogShower(DialogShowerImpl(hostDialog, dialogLaunchAnimator))
dialogLaunchAnimator.showFromView(this, view)
adapter.injectDialogShower(DialogShowerImpl(this, dialogLaunchAnimator))
}
}
private class DialogShowerImpl(
private val hostDialog: Dialog,
private val animateFrom: Dialog,
private val dialogLaunchAnimator: DialogLaunchAnimator
) : DialogInterface by hostDialog, DialogShower {
override fun showDialog(dialog: Dialog): Dialog {
return dialogLaunchAnimator.showFromDialog(
) : DialogInterface by animateFrom, DialogShower {
override fun showDialog(dialog: Dialog) {
dialogLaunchAnimator.showFromDialog(
dialog,
parentHostDialog = hostDialog
animateFrom = animateFrom
)
}
}
interface DialogShower : DialogInterface {
fun showDialog(dialog: Dialog): Dialog
fun showDialog(dialog: Dialog)
}
}

View File

@@ -20,6 +20,7 @@ import android.app.IActivityManager;
import android.app.NotificationManager;
import android.content.Context;
import android.os.Handler;
import android.service.dreams.IDreamManager;
import com.android.internal.statusbar.IStatusBarService;
import com.android.systemui.animation.ActivityLaunchAnimator;
@@ -67,13 +68,10 @@ import com.android.systemui.statusbar.phone.StatusBar;
import com.android.systemui.statusbar.phone.StatusBarIconController;
import com.android.systemui.statusbar.phone.StatusBarIconControllerImpl;
import com.android.systemui.statusbar.phone.StatusBarRemoteInputCallback;
import com.android.systemui.statusbar.phone.SystemUIHostDialogProvider;
import com.android.systemui.statusbar.phone.ongoingcall.OngoingCallController;
import com.android.systemui.statusbar.phone.ongoingcall.OngoingCallLogger;
import com.android.systemui.statusbar.policy.RemoteInputUriController;
import com.android.systemui.statusbar.window.StatusBarWindowController;
import com.android.systemui.statusbar.window.StatusBarWindowModule;
import com.android.systemui.statusbar.window.StatusBarWindowView;
import com.android.systemui.tracing.ProtoTracer;
import com.android.systemui.util.concurrency.DelayableExecutor;
import com.android.systemui.util.time.SystemClock;
@@ -320,7 +318,7 @@ public interface StatusBarDependenciesModule {
@Provides
@SysUISingleton
static DialogLaunchAnimator provideDialogLaunchAnimator(Context context,
LaunchAnimator launchAnimator) {
return new DialogLaunchAnimator(context, launchAnimator, new SystemUIHostDialogProvider());
LaunchAnimator launchAnimator, IDreamManager dreamManager) {
return new DialogLaunchAnimator(context, launchAnimator, dreamManager);
}
}

View File

@@ -40,30 +40,21 @@ import androidx.annotation.Nullable;
import com.android.systemui.Dependency;
import com.android.systemui.R;
import com.android.systemui.animation.DialogListener;
import com.android.systemui.animation.DialogListener.DismissReason;
import com.android.systemui.animation.ListenableDialog;
import com.android.systemui.broadcast.BroadcastDispatcher;
import com.android.systemui.statusbar.policy.KeyguardStateController;
import java.util.LinkedHashSet;
import java.util.Set;
/**
* Base class for dialogs that should appear over panels and keyguard.
* The SystemUIDialog registers a listener for the screen off / close system dialogs broadcast,
* and dismisses itself when it receives the broadcast.
*/
public class SystemUIDialog extends AlertDialog implements ListenableDialog,
ViewRootImpl.ConfigChangedCallback {
public class SystemUIDialog extends AlertDialog implements ViewRootImpl.ConfigChangedCallback {
// TODO(b/203389579): Remove this once the dialog width on large screens has been agreed on.
private static final String FLAG_TABLET_DIALOG_WIDTH =
"persist.systemui.flag_tablet_dialog_width";
private final Context mContext;
private final DismissReceiver mDismissReceiver;
private final Set<DialogListener> mDialogListeners = new LinkedHashSet<>();
private final Handler mHandler = new Handler();
private int mLastWidth = Integer.MIN_VALUE;
@@ -117,10 +108,6 @@ public class SystemUIDialog extends AlertDialog implements ListenableDialog,
mLastWidth = width;
mLastHeight = height;
getWindow().setLayout(width, height);
for (DialogListener listener : new LinkedHashSet<>(mDialogListeners)) {
listener.onSizeChanged();
}
}
@Override
@@ -197,60 +184,6 @@ public class SystemUIDialog extends AlertDialog implements ListenableDialog,
ViewRootImpl.removeConfigCallback(this);
}
@Override
public void addListener(DialogListener listener) {
mDialogListeners.add(listener);
}
@Override
public void removeListener(DialogListener listener) {
mDialogListeners.remove(listener);
}
@Override
public void dismiss() {
dismiss(DismissReason.UNKNOWN);
}
private void dismiss(DismissReason reason) {
super.dismiss();
for (DialogListener listener : new LinkedHashSet<>(mDialogListeners)) {
listener.onDismiss(reason);
}
}
/**
* Dismiss this dialog. If it was launched from another dialog using
* {@link com.android.systemui.animation.DialogLaunchAnimator#showFromView} with a
* non-{@code null} {@code parentHostDialog} parameter, also dismisses the stack of dialogs,
* animating back to the original touchSurface.
*/
public void dismissStack() {
for (DialogListener listener : new LinkedHashSet<>(mDialogListeners)) {
listener.prepareForStackDismiss();
}
dismiss();
}
@Override
public void hide() {
super.hide();
for (DialogListener listener : new LinkedHashSet<>(mDialogListeners)) {
listener.onHide();
}
}
@Override
public void show() {
super.show();
for (DialogListener listener : new LinkedHashSet<>(mDialogListeners)) {
listener.onShow();
}
}
public void setShowForAllUsers(boolean show) {
setShowForAllUsers(this, show);
}
@@ -364,11 +297,7 @@ public class SystemUIDialog extends AlertDialog implements ListenableDialog,
@Override
public void onReceive(Context context, Intent intent) {
if (mDialog instanceof SystemUIDialog) {
((SystemUIDialog) mDialog).dismiss(DismissReason.DEVICE_LOCKED);
} else {
mDialog.dismiss();
}
mDialog.dismiss();
}
}
}

View File

@@ -1,51 +0,0 @@
package com.android.systemui.statusbar.phone
import android.app.Dialog
import android.content.Context
import android.os.Bundle
import android.view.ViewGroup
import com.android.systemui.animation.HostDialogProvider
/** An implementation of [HostDialogProvider] to be used when animating SysUI dialogs. */
class SystemUIHostDialogProvider : HostDialogProvider {
override fun createHostDialog(
context: Context,
theme: Int,
onCreateCallback: () -> Unit,
dismissOverride: (() -> Unit) -> Unit
): Dialog {
return SystemUIHostDialog(context, theme, onCreateCallback, dismissOverride)
}
/**
* This host dialog is a SystemUIDialog so that it's displayed above all SystemUI windows. Note
* that it is not automatically dismissed when the device is locked, but only when the hosted
* (original) dialog is dismissed. That way, the behavior of the dialog (dismissed when locking
* or not) is consistent with when the dialog is shown with or without the dialog animator.
*/
private class SystemUIHostDialog(
context: Context,
theme: Int,
private val onCreateCallback: () -> Unit,
private val dismissOverride: (() -> Unit) -> Unit
) : SystemUIDialog(context, theme, false /* dismissOnDeviceLock */) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
onCreateCallback()
}
override fun dismiss() {
dismissOverride {
super.dismiss()
}
}
override fun getWidth(): Int {
return ViewGroup.LayoutParams.MATCH_PARENT
}
override fun getHeight(): Int {
return ViewGroup.LayoutParams.MATCH_PARENT
}
}
}

View File

@@ -69,6 +69,7 @@ import com.android.systemui.Prefs;
import com.android.systemui.Prefs.Key;
import com.android.systemui.R;
import com.android.systemui.SystemUISecondaryUserService;
import com.android.systemui.animation.DialogLaunchAnimator;
import com.android.systemui.broadcast.BroadcastDispatcher;
import com.android.systemui.dagger.SysUISingleton;
import com.android.systemui.dagger.qualifiers.Background;
@@ -133,6 +134,7 @@ public class UserSwitcherController implements Dumpable {
private final IActivityTaskManager mActivityTaskManager;
private final InteractionJankMonitor mInteractionJankMonitor;
private final LatencyTracker mLatencyTracker;
private final DialogLaunchAnimator mDialogLaunchAnimator;
private ArrayList<UserRecord> mUsers = new ArrayList<>();
@VisibleForTesting
@@ -180,7 +182,8 @@ public class UserSwitcherController implements Dumpable {
@Background Executor bgExecutor,
InteractionJankMonitor interactionJankMonitor,
LatencyTracker latencyTracker,
DumpManager dumpManager) {
DumpManager dumpManager,
DialogLaunchAnimator dialogLaunchAnimator) {
mContext = context;
mActivityManager = activityManager;
mUserTracker = userTracker;
@@ -208,6 +211,8 @@ public class UserSwitcherController implements Dumpable {
mHandler = handler;
mActivityStarter = activityStarter;
mUserManager = userManager;
mDialogLaunchAnimator = dialogLaunchAnimator;
IntentFilter filter = new IntentFilter();
filter.addAction(Intent.ACTION_USER_ADDED);
filter.addAction(Intent.ACTION_USER_REMOVED);
@@ -1179,7 +1184,7 @@ public class UserSwitcherController implements Dumpable {
cancel();
} else {
mUiEventLogger.log(QSUserSwitcherEvent.QS_USER_GUEST_REMOVE);
dismissStack();
mDialogLaunchAnimator.dismissStack(this);
removeGuestUser(mGuestId, mTargetId);
}
}
@@ -1210,7 +1215,7 @@ public class UserSwitcherController implements Dumpable {
if (which == BUTTON_NEGATIVE) {
cancel();
} else {
dismissStack();
mDialogLaunchAnimator.dismissStack(this);
if (ActivityManager.isUserAMonkey()) {
return;
}

View File

@@ -2,35 +2,49 @@ package com.android.systemui.animation
import android.app.Dialog
import android.content.Context
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.service.dreams.IDreamManager
import android.testing.AndroidTestingRunner
import android.testing.TestableLooper
import android.testing.ViewUtils
import android.view.View
import android.view.ViewGroup
import android.view.ViewGroup.LayoutParams.MATCH_PARENT
import android.view.WindowManager
import android.widget.LinearLayout
import androidx.test.filters.SmallTest
import com.android.internal.policy.DecorView
import com.android.systemui.SysuiTestCase
import com.android.systemui.animation.DialogListener.DismissReason
import junit.framework.Assert.assertEquals
import junit.framework.Assert.assertFalse
import junit.framework.Assert.assertNotNull
import junit.framework.Assert.assertTrue
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.junit.MockitoJUnit
@SmallTest
@RunWith(AndroidTestingRunner::class)
@TestableLooper.RunWithLooper
class DialogLaunchAnimatorTest : SysuiTestCase() {
private val launchAnimator = LaunchAnimator(context, isForTesting = true)
private val hostDialogprovider = TestHostDialogProvider()
private val dialogLaunchAnimator =
DialogLaunchAnimator(context, launchAnimator, hostDialogprovider)
private lateinit var dialogLaunchAnimator: DialogLaunchAnimator
private val attachedViews = mutableSetOf<View>()
@Mock lateinit var dreamManager: IDreamManager
@get:Rule val rule = MockitoJUnit.rule()
@Before
fun setUp() {
dialogLaunchAnimator = DialogLaunchAnimator(context, launchAnimator, dreamManager)
}
@After
fun tearDown() {
runOnMainThreadAndWaitForIdleSync {
@@ -44,76 +58,66 @@ class DialogLaunchAnimatorTest : SysuiTestCase() {
fun testShowDialogFromView() {
// Show the dialog. showFromView() must be called on the main thread with a dialog created
// on the main thread too.
val (dialog, hostDialog) = createDialogAndHostDialog()
val dialog = createAndShowDialog()
// Only the host dialog is actually showing.
assertTrue(hostDialog.isShowing)
assertFalse(dialog.isShowing)
assertTrue(dialog.isShowing)
// The dialog onStart() method was called but not onStop().
assertTrue(dialog.onStartCalled)
assertFalse(dialog.onStopCalled)
// The dialog is now fullscreen.
val window = dialog.window
val decorView = window.decorView as DecorView
assertEquals(MATCH_PARENT, window.attributes.width)
assertEquals(MATCH_PARENT, window.attributes.height)
assertEquals(MATCH_PARENT, decorView.layoutParams.width)
assertEquals(MATCH_PARENT, decorView.layoutParams.height)
// The dialog content has been stolen and is shown inside the host dialog.
val hostDialogContent = hostDialog.findViewById<ViewGroup>(android.R.id.content)
assertEquals(0, dialog.findViewById<ViewGroup>(android.R.id.content).childCount)
assertEquals(1, hostDialogContent.childCount)
// The single DecorView child is a transparent fullscreen view that will dismiss the dialog
// when clicked.
assertEquals(1, decorView.childCount)
val transparentBackground = decorView.getChildAt(0) as ViewGroup
assertEquals(MATCH_PARENT, transparentBackground.layoutParams.width)
assertEquals(MATCH_PARENT, transparentBackground.layoutParams.height)
// The original dialog content is added to another view that is the same size as the
// original dialog window.
val hostDialogRoot = hostDialogContent.getChildAt(0) as ViewGroup
assertEquals(1, hostDialogRoot.childCount)
// The single transparent background child is a fake window with the same size and
// background as the dialog initially had.
assertEquals(1, transparentBackground.childCount)
val dialogContentWithBackground = transparentBackground.getChildAt(0) as ViewGroup
assertEquals(TestDialog.DIALOG_WIDTH, dialogContentWithBackground.layoutParams.width)
assertEquals(TestDialog.DIALOG_HEIGHT, dialogContentWithBackground.layoutParams.height)
assertEquals(dialog.windowBackground, dialogContentWithBackground.background)
val dialogContentParent = hostDialogRoot.getChildAt(0) as ViewGroup
assertEquals(1, dialogContentParent.childCount)
assertEquals(TestDialog.DIALOG_WIDTH, dialogContentParent.layoutParams.width)
assertEquals(TestDialog.DIALOG_HEIGHT, dialogContentParent.layoutParams.height)
// The dialog content is inside this fake window view.
assertNotNull(
dialogContentWithBackground.findViewByPredicate { it === dialog.contentView })
val dialogContent = dialogContentParent.getChildAt(0)
assertEquals(dialog.contentView, dialogContent)
assertEquals(ViewGroup.LayoutParams.MATCH_PARENT, dialogContent.layoutParams.width)
assertEquals(ViewGroup.LayoutParams.MATCH_PARENT, dialogContent.layoutParams.height)
// Hiding/showing/dismissing the dialog should hide/show/dismiss the host dialog given that
// it's a ListenableDialog.
runOnMainThreadAndWaitForIdleSync { dialog.hide() }
assertFalse(hostDialog.isShowing)
assertFalse(dialog.isShowing)
runOnMainThreadAndWaitForIdleSync { dialog.show() }
assertTrue(hostDialog.isShowing)
assertFalse(dialog.isShowing)
assertFalse(dialog.onStopCalled)
// Clicking the transparent background should dismiss the dialog.
runOnMainThreadAndWaitForIdleSync {
// TODO(b/204561691): Remove this call to disableAllCurrentDialogsExitAnimations() and
// make sure that the test still pass on git_master/cf_x86_64_phone-userdebug in
// Forrest.
dialogLaunchAnimator.disableAllCurrentDialogsExitAnimations()
dialog.dismiss()
transparentBackground.performClick()
}
assertFalse(hostDialog.isShowing)
assertFalse(dialog.isShowing)
assertTrue(hostDialog.wasDismissed)
assertTrue(dialog.onStopCalled)
}
@Test
fun testStackedDialogsDismissesAll() {
val (_, hostDialogFirst) = createDialogAndHostDialog()
val (dialogSecond, hostDialogSecond) = createDialogAndHostDialogFromDialog(hostDialogFirst)
val firstDialog = createAndShowDialog()
val secondDialog = createDialogAndShowFromDialog(firstDialog)
assertTrue(firstDialog.isShowing)
assertTrue(secondDialog.isShowing)
runOnMainThreadAndWaitForIdleSync {
dialogLaunchAnimator.disableAllCurrentDialogsExitAnimations()
dialogSecond.dismissStack()
dialogLaunchAnimator.dismissStack(secondDialog)
}
assertTrue(hostDialogSecond.wasDismissed)
assertTrue(hostDialogFirst.wasDismissed)
assertFalse(firstDialog.isShowing)
assertFalse(secondDialog.isShowing)
}
private fun createDialogAndHostDialog(): Pair<TestDialog, TestHostDialog> {
private fun createAndShowDialog(): TestDialog {
return runOnMainThreadAndWaitForIdleSync {
val touchSurfaceRoot = LinearLayout(context)
val touchSurface = View(context)
@@ -125,22 +129,16 @@ class DialogLaunchAnimatorTest : SysuiTestCase() {
attachedViews.add(touchSurfaceRoot)
val dialog = TestDialog(context)
val hostDialog =
dialogLaunchAnimator.showFromView(dialog, touchSurface) as TestHostDialog
dialog to hostDialog
dialogLaunchAnimator.showFromView(dialog, touchSurface)
dialog
}
}
private fun createDialogAndHostDialogFromDialog(
hostParent: Dialog
): Pair<TestDialog, TestHostDialog> {
private fun createDialogAndShowFromDialog(animateFrom: Dialog): TestDialog {
return runOnMainThreadAndWaitForIdleSync {
val dialog = TestDialog(context)
val hostDialog = dialogLaunchAnimator.showFromDialog(
dialog,
hostParent
) as TestHostDialog
dialog to hostDialog
dialogLaunchAnimator.showFromDialog(dialog, animateFrom)
dialog
}
}
@@ -153,50 +151,14 @@ class DialogLaunchAnimatorTest : SysuiTestCase() {
return result
}
private class TestHostDialogProvider : HostDialogProvider {
override fun createHostDialog(
context: Context,
theme: Int,
onCreateCallback: () -> Unit,
dismissOverride: (() -> Unit) -> Unit
): Dialog = TestHostDialog(context, onCreateCallback, dismissOverride)
}
private class TestHostDialog(
context: Context,
private val onCreateCallback: () -> Unit,
private val dismissOverride: (() -> Unit) -> Unit
) : Dialog(context) {
var wasDismissed = false
init {
// We need to set the window type for dialogs shown by SysUI, otherwise WM will throw.
window.setType(WindowManager.LayoutParams.TYPE_STATUS_BAR_SUB_PANEL)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
onCreateCallback()
}
override fun dismiss() {
dismissOverride {
super.dismiss()
wasDismissed = true
}
}
}
private class TestDialog(context: Context) : Dialog(context), ListenableDialog {
private class TestDialog(context: Context) : Dialog(context) {
companion object {
const val DIALOG_WIDTH = 100
const val DIALOG_HEIGHT = 200
}
private val listeners = hashSetOf<DialogListener>()
val contentView = View(context)
var onStartCalled = false
var onStopCalled = false
val windowBackground = ColorDrawable(Color.RED)
init {
// We need to set the window type for dialogs shown by SysUI, otherwise WM will throw.
@@ -205,52 +167,10 @@ class DialogLaunchAnimatorTest : SysuiTestCase() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.setLayout(DIALOG_WIDTH, DIALOG_HEIGHT)
setContentView(contentView)
}
override fun onStart() {
super.onStart()
onStartCalled = true
}
override fun onStop() {
super.onStart()
onStopCalled = true
}
override fun addListener(listener: DialogListener) {
listeners.add(listener)
}
override fun removeListener(listener: DialogListener) {
listeners.remove(listener)
}
override fun dismiss() {
super.dismiss()
notifyListeners { onDismiss(DismissReason.UNKNOWN) }
}
override fun hide() {
super.hide()
notifyListeners { onHide() }
}
override fun show() {
super.show()
notifyListeners { onShow() }
}
fun dismissStack() {
notifyListeners { prepareForStackDismiss() }
dismiss()
}
private fun notifyListeners(notify: DialogListener.() -> Unit) {
for (listener in HashSet(listeners)) {
listener.notify()
}
window.setLayout(DIALOG_WIDTH, DIALOG_HEIGHT)
window.setBackgroundDrawable(windowBackground)
}
}
}

View File

@@ -16,7 +16,6 @@
package com.android.systemui.qs.user
import android.app.Dialog
import android.content.DialogInterface
import android.content.Intent
import android.provider.Settings
@@ -31,7 +30,6 @@ import com.android.systemui.qs.PseudoGridView
import com.android.systemui.qs.tiles.UserDetailView
import com.android.systemui.statusbar.phone.SystemUIDialog
import com.android.systemui.util.mockito.any
import com.android.systemui.util.mockito.argumentCaptor
import com.android.systemui.util.mockito.capture
import com.android.systemui.util.mockito.eq
import org.junit.Before
@@ -42,7 +40,6 @@ import org.mockito.ArgumentMatcher
import org.mockito.Captor
import org.mockito.Mock
import org.mockito.Mockito.`when`
import org.mockito.Mockito.anyBoolean
import org.mockito.Mockito.anyInt
import org.mockito.Mockito.argThat
import org.mockito.Mockito.never
@@ -65,8 +62,6 @@ class UserSwitchDialogControllerTest : SysuiTestCase() {
private lateinit var launchView: View
@Mock
private lateinit var dialogLaunchAnimator: DialogLaunchAnimator
@Mock
private lateinit var hostDialog: Dialog
@Captor
private lateinit var clickCaptor: ArgumentCaptor<DialogInterface.OnClickListener>
@@ -78,8 +73,6 @@ class UserSwitchDialogControllerTest : SysuiTestCase() {
`when`(launchView.context).thenReturn(mContext)
`when`(dialog.context).thenReturn(mContext)
`when`(dialogLaunchAnimator.showFromView(any(), any(), anyBoolean()))
.thenReturn(hostDialog)
controller = UserSwitchDialogController(
{ userDetailViewAdapter },
@@ -151,18 +144,6 @@ class UserSwitchDialogControllerTest : SysuiTestCase() {
verify(activityStarter, never()).postStartActivityDismissingKeyguard(any(), anyInt())
}
@Test
fun callbackFromDialogShower_dismissesDialog() {
val captor = argumentCaptor<UserSwitchDialogController.DialogShower>()
controller.showDialog(launchView)
verify(userDetailViewAdapter).injectDialogShower(capture(captor))
captor.value.dismiss()
verify(hostDialog).dismiss()
}
private class IntentMatcher(private val action: String) : ArgumentMatcher<Intent> {
override fun matches(argument: Intent?): Boolean {
return argument?.action == action

View File

@@ -40,6 +40,7 @@ import com.android.internal.util.UserIcons
import com.android.systemui.GuestResumeSessionReceiver
import com.android.systemui.R
import com.android.systemui.SysuiTestCase
import com.android.systemui.animation.DialogLaunchAnimator
import com.android.systemui.broadcast.BroadcastDispatcher
import com.android.systemui.dump.DumpManager
import com.android.systemui.plugins.ActivityStarter
@@ -94,6 +95,7 @@ class UserSwitcherControllerTest : SysuiTestCase() {
@Mock private lateinit var dialogShower: UserSwitchDialogController.DialogShower
@Mock private lateinit var notificationShadeWindowView: NotificationShadeWindowView
@Mock private lateinit var threadedRenderer: ThreadedRenderer
@Mock private lateinit var dialogLaunchAnimator: DialogLaunchAnimator
private lateinit var testableLooper: TestableLooper
private lateinit var uiBgExecutor: FakeExecutor
private lateinit var uiEventLogger: UiEventLoggerFake
@@ -147,7 +149,8 @@ class UserSwitcherControllerTest : SysuiTestCase() {
uiBgExecutor,
interactionJankMonitor,
latencyTracker,
dumpManager)
dumpManager,
dialogLaunchAnimator)
userSwitcherController.mPauseRefreshUsers = true
// Since userSwitcherController involves InteractionJankMonitor.