diff --git a/packages/SystemUI/animation/res/anim/launch_host_dialog_enter.xml b/packages/SystemUI/animation/res/anim/launch_host_dialog_enter.xml new file mode 100644 index 0000000000000..c6b87d38f7da1 --- /dev/null +++ b/packages/SystemUI/animation/res/anim/launch_host_dialog_enter.xml @@ -0,0 +1,23 @@ + + + + + + \ No newline at end of file diff --git a/packages/SystemUI/animation/res/anim/launch_host_dialog_exit.xml b/packages/SystemUI/animation/res/anim/launch_host_dialog_exit.xml new file mode 100644 index 0000000000000..a0f441eaeed48 --- /dev/null +++ b/packages/SystemUI/animation/res/anim/launch_host_dialog_exit.xml @@ -0,0 +1,22 @@ + + + \ No newline at end of file diff --git a/packages/SystemUI/animation/res/values/ids.xml b/packages/SystemUI/animation/res/values/ids.xml new file mode 100644 index 0000000000000..ef60a248f79ad --- /dev/null +++ b/packages/SystemUI/animation/res/values/ids.xml @@ -0,0 +1,19 @@ + + + + + \ No newline at end of file diff --git a/packages/SystemUI/animation/res/values/styles.xml b/packages/SystemUI/animation/res/values/styles.xml new file mode 100644 index 0000000000000..89f93b81fa994 --- /dev/null +++ b/packages/SystemUI/animation/res/values/styles.xml @@ -0,0 +1,22 @@ + + + + + \ No newline at end of file diff --git a/packages/SystemUI/animation/src/com/android/systemui/animation/DialogLaunchAnimator.kt b/packages/SystemUI/animation/src/com/android/systemui/animation/DialogLaunchAnimator.kt new file mode 100644 index 0000000000000..fbf466e8cebe4 --- /dev/null +++ b/packages/SystemUI/animation/src/com/android/systemui/animation/DialogLaunchAnimator.kt @@ -0,0 +1,523 @@ +/* + * Copyright (C) 2021 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.animation + +import android.app.Dialog +import android.content.Context +import android.graphics.Color +import android.os.Looper +import android.util.Log +import android.view.Gravity +import android.view.View +import android.view.ViewGroup +import android.view.ViewTreeObserver +import android.view.WindowManager +import android.widget.FrameLayout + +private const val TAG = "DialogLaunchAnimator" + +/** + * A class that allows dialogs to be started in a seamless way from a view that is transforming + * nicely into the starting dialog. + * + * Important: Don't forget to call [DialogLaunchAnimator.onDozeAmountChanged] when the doze amount + * changes to gracefully handle dialogs fading out when the device is dozing. + */ +class DialogLaunchAnimator( + private val context: Context, + private val launchAnimator: LaunchAnimator, + private val hostDialogProvider: HostDialogProvider +) { + private companion object { + private val TAG_LAUNCH_ANIMATION_RUNNING = R.id.launch_animation_running + } + + // TODO(b/201264644): Remove this set. + private val currentAnimations = hashSetOf() + + /** + * Show [dialog] by expanding it from [view]. + * + * 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. + */ + fun showFromView(dialog: Dialog, view: View): 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") + } + + // Make sure we don't run the launch animation from the same view twice at the same time. + if (view.getTag(TAG_LAUNCH_ANIMATION_RUNNING) != null) { + Log.e(TAG, "Not running dialog launch animation as there is already one running") + dialog.show() + return dialog + } + + view.setTag(TAG_LAUNCH_ANIMATION_RUNNING, true) + + val launchAnimation = DialogLaunchAnimation( + context, launchAnimator, hostDialogProvider, view, + onDialogDismissed = { currentAnimations.remove(it) }, originalDialog = dialog) + val hostDialog = launchAnimation.hostDialog + currentAnimations.add(launchAnimation) + + // 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() { + dialog.removeListener(this) + hostDialog.dismiss() + } + + override fun onHide() { + if (launchAnimation.ignoreNextCallToHide) { + launchAnimation.ignoreNextCallToHide = false + return + } + + hostDialog.hide() + } + + override fun onShow() { + hostDialog.show() + + // We don't actually want to show the original dialog, so hide it. + launchAnimation.ignoreNextCallToHide = true + dialog.hide() + } + }) + } + + launchAnimation.start() + return hostDialog + } + + /** Notify the current doze amount, to ensure that dialogs fade out when dozing. */ + // TODO(b/193634619): Replace this by some mandatory constructor parameter to make sure that we + // don't forget to call this when the doze amount changes. + fun onDozeAmountChanged(amount: Float) { + currentAnimations.forEach { it.onDozeAmountChanged(amount) } + } +} + +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. + * + * See SystemUIHostDialogProvider for an example of implementation. + */ + fun createHostDialog( + context: Context, + 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 { + /** Called when this dialog dismiss() is called. */ + fun onDismiss() + + /** Called when this dialog hide() is called. */ + fun onHide() + + /** Called when this dialog show() is called. */ + fun onShow() +} + +private class DialogLaunchAnimation( + private val context: Context, + private val launchAnimator: LaunchAnimator, + hostDialogProvider: HostDialogProvider, + + /** The view that triggered the dialog after being tapped. */ + private val touchSurface: View, + + /** + * A callback that will be called with this [DialogLaunchAnimation] after the dialog was + * dismissed and the exit animation is done. + */ + private val onDialogDismissed: (DialogLaunchAnimation) -> Unit, + + /** The original dialog whose content will be shown and animate in/out in [hostDialog]. */ + private val originalDialog: Dialog +) { + /** + * The fullscreen dialog to which we will add the content view [originalDialogView] of + * [originalDialog]. + */ + val hostDialog = hostDialogProvider.createHostDialog( + context, this::onHostDialogCreated, this::onHostDialogDismissed) + + /** The root content view of [hostDialog]. */ + private val hostDialogRoot = FrameLayout(context) + + /** + * The content view of [originalDialog], which will be stolen from that dialog and added to + * [hostDialogRoot]. + */ + private var originalDialogView: View? = null + + /** + * The background color of [originalDialogView], taking into consideration the [originalDialog] + * window background color. + */ + private var originalDialogBackgroundColor = Color.BLACK + + /** + * Whether we are currently launching/showing the dialog by animating it from [touchSurface]. + */ + private var isLaunching = true + + /** Whether we are currently dismissing/hiding the dialog by animating into [touchSurface]. */ + private var isDismissing = false + + private var dismissRequested = false + private var drawHostDialog = false + var ignoreNextCallToHide = false + + fun start() { + // Show the host (fullscreen) dialog, to which we will add the stolen dialog view. + hostDialog.show() + + // 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) + } + + private fun onHostDialogCreated() { + // Make the dialog fullscreen with a transparent background. + hostDialog.setContentView( + hostDialogRoot, + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT + ) + ) + + val window = hostDialog.window + ?: throw IllegalStateException("There is no window associated to the host dialog") + window.setBackgroundDrawableResource(android.R.color.transparent) + window.setLayout( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.MATCH_PARENT + ) + + // The host dialog animation is a translation of 0px so that it is shown directly. The + // translation lasts X ms, so that the scrim fades in during that amount of time. + window.attributes.windowAnimations = R.style.Animation_LaunchHostDialog + + // Prevent the host dialog from drawing until the animation starts. + hostDialogRoot.viewTreeObserver.addOnPreDrawListener( + object : ViewTreeObserver.OnPreDrawListener { + override fun onPreDraw(): Boolean { + if (drawHostDialog) { + hostDialogRoot.viewTreeObserver.removeOnPreDrawListener(this) + return true + } + + return false + } + } + ) + } + + /** 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(android.R.id.content) + ?: throw IllegalStateException("Dialog does not have any android.R.id.content view") + + androidContent.viewTreeObserver.addOnPreDrawListener( + object : ViewTreeObserver.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) { + // Save the dialog view for later as we will need it for the close animation. + this.originalDialogView = dialogView + + // Close the dialog when clicking outside of it. + hostDialogRoot.setOnClickListener { hostDialog.dismiss() } + dialogView.isClickable = true + + // Set the background of the window dialog to the dialog itself. + // TODO(b/193634619): Support dialog windows without background. + // TODO(b/193634619): Support dialog whose background comes from the content view instead of + // the window. + val typedArray = + originalDialog.context.obtainStyledAttributes(com.android.internal.R.styleable.Window) + val backgroundRes = + typedArray.getResourceId(com.android.internal.R.styleable.Window_windowBackground, 0) + typedArray.recycle() + if (backgroundRes == 0) { + throw IllegalStateException("Dialogs with no backgrounds on window are not supported") + } + + dialogView.setBackgroundResource(backgroundRes) + originalDialogBackgroundColor = + GhostedViewLaunchAnimatorController.findGradientDrawable(dialogView.background!!) + ?.color + ?.defaultColor ?: Color.BLACK + + // Add the dialog view to the host (fullscreen) dialog and make it invisible to make sure + // it's not drawn yet. + (dialogView.parent as? ViewGroup)?.removeView(dialogView) + hostDialogRoot.addView( + dialogView, + FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT, + Gravity.CENTER + ) + ) + dialogView.visibility = View.INVISIBLE + + // Start the animation when the dialog is laid out in the center of the host dialog. + dialogView.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 + ) { + dialogView.removeOnLayoutChangeListener(this) + startAnimation( + isLaunching = true, + onLaunchAnimationStart = { drawHostDialog = true }, + onLaunchAnimationEnd = { + touchSurface.setTag(R.id.launch_animation_running, null) + + // We hide the touch surface when the dialog is showing. We will make this + // view visible again when dismissing the dialog. + // TODO(b/193634619): Provide an easy way for views to check if they should + // be hidden because of a dialog launch so that they don't override this + // visibility when updating/refreshing itself. + touchSurface.visibility = View.INVISIBLE + + isLaunching = false + + // dismiss was called during the animation, dismiss again now to actually + // dismiss. + if (dismissRequested) { + hostDialog.dismiss() + } + } + ) + } + }) + } + + private fun onHostDialogDismissed(actualDismiss: () -> Unit) { + if (Looper.myLooper() != Looper.getMainLooper()) { + context.mainExecutor.execute { onHostDialogDismissed(actualDismiss) } + return + } + + // TODO(b/193634619): Support interrupting the launch animation in the middle. + if (isLaunching) { + dismissRequested = true + return + } + + if (isDismissing) { + return + } + + isDismissing = true + hideDialogIntoView { instantDismiss: Boolean -> + if (instantDismiss) { + originalDialog.hide() + hostDialog.hide() + } + + originalDialog.dismiss() + actualDismiss() + } + } + + /** + * 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. + */ + private fun hideDialogIntoView(dismissDialogs: (Boolean) -> Unit) { + if (!shouldAnimateDialogIntoView()) { + Log.i(TAG, "Skipping animation of dialog into the touch surface") + + // If the view is invisible it's probably because of us, so we make it visible again. + if (touchSurface.visibility == View.INVISIBLE) { + touchSurface.visibility = View.VISIBLE + } + + dismissDialogs(false /* instantDismiss */) + onDialogDismissed(this@DialogLaunchAnimation) + return + } + + startAnimation( + isLaunching = false, + onLaunchAnimationStart = { + // Remove the dim background as soon as we start the animation. + hostDialog.window.clearFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND) + }, + onLaunchAnimationEnd = { + touchSurface.visibility = View.VISIBLE + originalDialogView!!.visibility = View.INVISIBLE + dismissDialogs(true /* instantDismiss */) + onDialogDismissed(this@DialogLaunchAnimation) + } + ) + } + + private fun startAnimation( + isLaunching: Boolean, + onLaunchAnimationStart: () -> Unit = {}, + onLaunchAnimationEnd: () -> Unit = {} + ) { + val dialogView = this.originalDialogView!! + + // Create 2 ghost controllers to animate both the dialog and the touch surface in the host + // dialog. + val startView = if (isLaunching) touchSurface else dialogView + val endView = if (isLaunching) dialogView else touchSurface + val startViewController = GhostedViewLaunchAnimatorController(startView) + val endViewController = GhostedViewLaunchAnimatorController(endView) + startViewController.launchContainer = hostDialogRoot + endViewController.launchContainer = hostDialogRoot + + val endState = endViewController.createAnimatorState() + val controller = object : LaunchAnimator.Controller { + override var launchContainer: ViewGroup + get() = startViewController.launchContainer + set(value) { + startViewController.launchContainer = value + endViewController.launchContainer = value + } + + override fun createAnimatorState(): LaunchAnimator.State { + return startViewController.createAnimatorState() + } + + override fun onLaunchAnimationStart(isExpandingFullyAbove: Boolean) { + startViewController.onLaunchAnimationStart(isExpandingFullyAbove) + endViewController.onLaunchAnimationStart(isExpandingFullyAbove) + + onLaunchAnimationStart() + } + + override fun onLaunchAnimationEnd(isExpandingFullyAbove: Boolean) { + startViewController.onLaunchAnimationEnd(isExpandingFullyAbove) + endViewController.onLaunchAnimationEnd(isExpandingFullyAbove) + + onLaunchAnimationEnd() + } + + override fun onLaunchAnimationProgress( + state: LaunchAnimator.State, + progress: Float, + linearProgress: Float + ) { + startViewController.onLaunchAnimationProgress(state, progress, linearProgress) + + // The end view is visible only iff the starting view is not visible. + state.visible = !state.visible + endViewController.onLaunchAnimationProgress(state, progress, linearProgress) + + // If the dialog content is complex, its dimension might change during the launch + // animation. The animation end position might also change during the exit + // animation, for instance when locking the phone when the dialog is open. Therefore + // we update the end state to the new position/size. Usually the dialog dimension or + // position will change in the early frames, so changing the end state shouldn't + // really be noticeable. + endViewController.fillGhostedViewState(endState) + } + } + + launchAnimator.startAnimation(controller, endState, originalDialogBackgroundColor) + } + + private fun shouldAnimateDialogIntoView(): Boolean { + // The touch surface should be invisible by now, if it's not then something else changed its + // visibility and we probably don't want to run the animation. + if (touchSurface.visibility != View.INVISIBLE) { + return false + } + + // If the touch surface is not attached or one of its ancestors is not visible, then we + // don't run the animation either. + if (!touchSurface.isAttachedToWindow) { + return false + } + + return (touchSurface.parent as? View)?.isShown ?: true + } + + internal fun onDozeAmountChanged(amount: Float) { + val alpha = Interpolators.ALPHA_OUT.getInterpolation(1 - amount) + val decorView = this.hostDialog.window?.decorView ?: return + if (decorView.hasOverlappingRendering() && alpha > 0.0f && + alpha < 1.0f && decorView.layerType != View.LAYER_TYPE_HARDWARE) { + decorView.setLayerType(View.LAYER_TYPE_HARDWARE, null) + } + decorView.alpha = alpha + } +} diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/dagger/StatusBarDependenciesModule.java b/packages/SystemUI/src/com/android/systemui/statusbar/dagger/StatusBarDependenciesModule.java index 3a2364039f096..ea00d920c9b09 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/dagger/StatusBarDependenciesModule.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/dagger/StatusBarDependenciesModule.java @@ -23,6 +23,7 @@ import android.os.Handler; import com.android.internal.statusbar.IStatusBarService; import com.android.systemui.animation.ActivityLaunchAnimator; +import com.android.systemui.animation.DialogLaunchAnimator; import com.android.systemui.animation.LaunchAnimator; import com.android.systemui.dagger.SysUISingleton; import com.android.systemui.dagger.qualifiers.Main; @@ -64,6 +65,7 @@ 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; @@ -279,4 +281,13 @@ public interface StatusBarDependenciesModule { static ActivityLaunchAnimator provideActivityLaunchAnimator(LaunchAnimator launchAnimator) { return new ActivityLaunchAnimator(launchAnimator); } + + /** + */ + @Provides + @SysUISingleton + static DialogLaunchAnimator provideDialogLaunchAnimator(Context context, + LaunchAnimator launchAnimator) { + return new DialogLaunchAnimator(context, launchAnimator, new SystemUIHostDialogProvider()); + } } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBar.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBar.java index 9da34be890c1d..81abe38b97e39 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBar.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/StatusBar.java @@ -135,6 +135,7 @@ import com.android.systemui.R; import com.android.systemui.SystemUI; import com.android.systemui.animation.ActivityLaunchAnimator; import com.android.systemui.animation.DelegateLaunchAnimatorController; +import com.android.systemui.animation.DialogLaunchAnimator; import com.android.systemui.assist.AssistManager; import com.android.systemui.battery.BatteryMeterViewController; import com.android.systemui.biometrics.AuthRippleController; @@ -664,6 +665,7 @@ public class StatusBar extends SystemUI implements private HeadsUpAppearanceController mHeadsUpAppearanceController; private final ActivityLaunchAnimator mActivityLaunchAnimator; + private final DialogLaunchAnimator mDialogLaunchAnimator; private NotificationLaunchAnimatorControllerProvider mNotificationAnimationProvider; protected StatusBarNotificationPresenter mPresenter; private NotificationActivityStarter mNotificationActivityStarter; @@ -783,7 +785,8 @@ public class StatusBar extends SystemUI implements Optional startingSurfaceOptional, TunerService tunerService, DumpManager dumpManager, - ActivityLaunchAnimator activityLaunchAnimator) { + ActivityLaunchAnimator activityLaunchAnimator, + DialogLaunchAnimator dialogLaunchAnimator) { super(context); mNotificationsController = notificationsController; mLightBarController = lightBarController; @@ -888,6 +891,7 @@ public class StatusBar extends SystemUI implements mActivityIntentHelper = new ActivityIntentHelper(mContext); mActivityLaunchAnimator = activityLaunchAnimator; + mDialogLaunchAnimator = dialogLaunchAnimator; // TODO(b/190746471): Find a better home for this. DateTimeView.setReceiverHandler(timeTickHandler); @@ -4392,6 +4396,8 @@ public class StatusBar extends SystemUI implements && !(mLightRevealScrim.getRevealEffect() instanceof CircleReveal)) { mLightRevealScrim.setRevealAmount(1f - linear); } + + mDialogLaunchAnimator.onDozeAmountChanged(linear); } @Override diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/SystemUIDialog.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/SystemUIDialog.java index 1e98c75f26168..9415d5082d10b 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/SystemUIDialog.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/SystemUIDialog.java @@ -30,19 +30,24 @@ import android.view.WindowManager.LayoutParams; import com.android.systemui.Dependency; import com.android.systemui.R; +import com.android.systemui.animation.DialogListener; +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 { - +public class SystemUIDialog extends AlertDialog implements ListenableDialog { private final Context mContext; private final DismissReceiver mDismissReceiver; + private final Set mDialogListeners = new LinkedHashSet<>(); public SystemUIDialog(Context context) { this(context, R.style.Theme_SystemUI_Dialog); @@ -72,6 +77,43 @@ public class SystemUIDialog extends AlertDialog { mDismissReceiver.unregister(); } + @Override + public void addListener(DialogListener listener) { + mDialogListeners.add(listener); + } + + @Override + public void removeListener(DialogListener listener) { + mDialogListeners.remove(listener); + } + + @Override + public void dismiss() { + super.dismiss(); + + for (DialogListener listener : new LinkedHashSet<>(mDialogListeners)) { + listener.onDismiss(); + } + } + + @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); } diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/SystemUIHostDialogProvider.kt b/packages/SystemUI/src/com/android/systemui/statusbar/phone/SystemUIHostDialogProvider.kt new file mode 100644 index 0000000000000..17ecfa26f96d3 --- /dev/null +++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/SystemUIHostDialogProvider.kt @@ -0,0 +1,34 @@ +package com.android.systemui.statusbar.phone + +import android.app.Dialog +import android.content.Context +import android.os.Bundle +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, + onCreateCallback: () -> Unit, + dismissOverride: (() -> Unit) -> Unit + ): Dialog { + return SystemUIHostDialog(context, onCreateCallback, dismissOverride) + } + + private class SystemUIHostDialog( + context: Context, + private val onCreateCallback: () -> Unit, + private val dismissOverride: (() -> Unit) -> Unit + ) : SystemUIDialog(context) { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + onCreateCallback() + } + + override fun dismiss() { + dismissOverride { + super.dismiss() + } + } + } +} \ No newline at end of file diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/phone/dagger/StatusBarPhoneModule.java b/packages/SystemUI/src/com/android/systemui/statusbar/phone/dagger/StatusBarPhoneModule.java index a5cf1136ece9a..4b3add294b570 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/phone/dagger/StatusBarPhoneModule.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/phone/dagger/StatusBarPhoneModule.java @@ -29,6 +29,7 @@ import com.android.keyguard.KeyguardUpdateMonitor; import com.android.keyguard.ViewMediatorCallback; import com.android.systemui.InitController; import com.android.systemui.animation.ActivityLaunchAnimator; +import com.android.systemui.animation.DialogLaunchAnimator; import com.android.systemui.assist.AssistManager; import com.android.systemui.broadcast.BroadcastDispatcher; import com.android.systemui.classifier.FalsingCollector; @@ -229,7 +230,8 @@ public interface StatusBarPhoneModule { Optional startingSurfaceOptional, TunerService tunerService, DumpManager dumpManager, - ActivityLaunchAnimator activityLaunchAnimator) { + ActivityLaunchAnimator activityLaunchAnimator, + DialogLaunchAnimator dialogLaunchAnimator) { return new StatusBar( context, notificationsController, @@ -324,6 +326,7 @@ public interface StatusBarPhoneModule { startingSurfaceOptional, tunerService, dumpManager, - activityLaunchAnimator); + activityLaunchAnimator, + dialogLaunchAnimator); } } diff --git a/packages/SystemUI/tests/src/com/android/systemui/animation/DialogLaunchAnimatorTest.kt b/packages/SystemUI/tests/src/com/android/systemui/animation/DialogLaunchAnimatorTest.kt new file mode 100644 index 0000000000000..25ce072c92ebd --- /dev/null +++ b/packages/SystemUI/tests/src/com/android/systemui/animation/DialogLaunchAnimatorTest.kt @@ -0,0 +1,185 @@ +package com.android.systemui.animation + +import android.app.Dialog +import android.content.Context +import android.os.Bundle +import android.testing.AndroidTestingRunner +import android.testing.TestableLooper +import android.testing.ViewUtils +import android.view.View +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.LinearLayout +import androidx.test.filters.SmallTest +import com.android.systemui.SysuiTestCase +import junit.framework.Assert.assertEquals +import junit.framework.Assert.assertFalse +import junit.framework.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith + +@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) + + @Test + 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) = runOnMainThreadAndWaitForIdleSync { + val touchSurfaceRoot = LinearLayout(context) + val touchSurface = View(context) + touchSurfaceRoot.addView(touchSurface) + + // We need to attach the root to the window manager otherwise the exit animation will + // be skipped + ViewUtils.attachView(touchSurfaceRoot) + + val dialog = TestDialog(context) + val hostDialog = + dialogLaunchAnimator.showFromView(dialog, touchSurface) as TestHostDialog + dialog to hostDialog + } + + // Only the host dialog is actually showing. + assertTrue(hostDialog.isShowing) + assertFalse(dialog.isShowing) + + // The dialog onStart() method was called but not onStop(). + assertTrue(dialog.onStartCalled) + assertFalse(dialog.onStopCalled) + + // The dialog content has been stolen and is shown inside the host dialog. + val hostDialogContent = hostDialog.findViewById(android.R.id.content) + assertEquals(0, dialog.findViewById(android.R.id.content).childCount) + assertEquals(1, hostDialogContent.childCount) + + val hostDialogRoot = hostDialogContent.getChildAt(0) as ViewGroup + assertEquals(1, hostDialogRoot.childCount) + assertEquals(dialog.contentView, hostDialogRoot.getChildAt(0)) + + // If we are dozing, the host dialog window also fades out. + runOnMainThreadAndWaitForIdleSync { dialogLaunchAnimator.onDozeAmountChanged(0.5f) } + assertTrue(hostDialog.window!!.decorView.alpha < 1f) + + // 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) + runOnMainThreadAndWaitForIdleSync { dialog.dismiss() } + assertFalse(hostDialog.isShowing) + assertFalse(dialog.isShowing) + assertTrue(hostDialog.wasDismissed) + assertTrue(dialog.onStopCalled) + } + + private fun runOnMainThreadAndWaitForIdleSync(f: () -> T): T { + lateinit var result: T + context.mainExecutor.execute { + result = f() + } + waitForIdleSync() + return result + } + + private class TestHostDialogProvider : HostDialogProvider { + override fun createHostDialog( + context: Context, + 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 val listeners = hashSetOf() + val contentView = View(context) + var onStartCalled = false + var onStopCalled = 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) + 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() } + } + + override fun hide() { + super.hide() + notifyListeners { onHide() } + } + + override fun show() { + super.show() + notifyListeners { onShow() } + } + + private fun notifyListeners(notify: DialogListener.() -> Unit) { + for (listener in HashSet(listeners)) { + listener.notify() + } + } + } +} \ No newline at end of file diff --git a/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/StatusBarTest.java b/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/StatusBarTest.java index 73b97e15b5197..9a8236dde0cdf 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/StatusBarTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/statusbar/phone/StatusBarTest.java @@ -78,6 +78,7 @@ import com.android.systemui.InitController; import com.android.systemui.R; import com.android.systemui.SysuiTestCase; import com.android.systemui.animation.ActivityLaunchAnimator; +import com.android.systemui.animation.DialogLaunchAnimator; import com.android.systemui.assist.AssistManager; import com.android.systemui.broadcast.BroadcastDispatcher; import com.android.systemui.classifier.FalsingCollectorFake; @@ -270,6 +271,7 @@ public class StatusBarTest extends SysuiTestCase { @Mock private OperatorNameViewController mOperatorNameViewController; @Mock private OperatorNameViewController.Factory mOperatorNameViewControllerFactory; @Mock private ActivityLaunchAnimator mActivityLaunchAnimator; + @Mock private DialogLaunchAnimator mDialogLaunchAnimator; private ShadeController mShadeController; private final FakeSystemClock mFakeSystemClock = new FakeSystemClock(); private FakeExecutor mMainExecutor = new FakeExecutor(mFakeSystemClock); @@ -445,7 +447,8 @@ public class StatusBarTest extends SysuiTestCase { Optional.of(mStartingSurface), mTunerService, mock(DumpManager.class), - mActivityLaunchAnimator); + mActivityLaunchAnimator, + mDialogLaunchAnimator); when(mKeyguardViewMediator.registerStatusBar(any(StatusBar.class), any(ViewGroup.class), any(NotificationPanelViewController.class), any(BiometricUnlockController.class), any(ViewGroup.class), any(KeyguardBypassController.class)))