diff options
72 files changed, 2001 insertions, 238 deletions
diff --git a/core/java/android/app/Notification.java b/core/java/android/app/Notification.java index eeb1ebb69b03..8ffea237c71d 100644 --- a/core/java/android/app/Notification.java +++ b/core/java/android/app/Notification.java @@ -6453,6 +6453,13 @@ public class Notification implements Parcelable big.setColorStateList(R.id.snooze_button, "setImageTintList", actionColor); big.setColorStateList(R.id.bubble_button, "setImageTintList", actionColor); + if (Flags.notificationsRedesignTemplates()) { + int margin = getContentMarginTop(mContext, + R.dimen.notification_2025_content_margin_top); + big.setViewLayoutMargin(R.id.notification_main_column, RemoteViews.MARGIN_TOP, + margin, TypedValue.COMPLEX_UNIT_PX); + } + boolean validRemoteInput = false; // In the UI, contextual actions appear separately from the standard actions, so we @@ -6549,6 +6556,30 @@ public class Notification implements Parcelable return big; } + /** + * Calculate the top margin for the content in px, to allow enough space for the top line + * above, using the given resource ID for the desired spacing. + * + * @hide + */ + public static int getContentMarginTop(Context context, @DimenRes int spacingRes) { + final Resources resources = context.getResources(); + // The margin above the text, at the top of the notification (originally in dp) + int notifMargin = resources.getDimensionPixelSize(R.dimen.notification_2025_margin); + // Spacing between the text lines, scaling with the font size (originally in sp) + int spacing = resources.getDimensionPixelSize(spacingRes); + + // Size of the text in the notification top line (originally in sp) + int[] textSizeAttr = new int[] { android.R.attr.textSize }; + TypedArray typedArray = context.obtainStyledAttributes( + R.style.TextAppearance_DeviceDefault_Notification_Info, textSizeAttr); + int textSize = typedArray.getDimensionPixelSize(0 /* index */, -1 /* default */); + typedArray.recycle(); + + // Adding up all the values as pixels + return notifMargin + spacing + textSize; + } + private boolean hasValidRemoteInput(Action action) { if (TextUtils.isEmpty(action.title) || action.actionIntent == null) { // Weird actions diff --git a/core/java/android/app/admin/DevicePolicyManager.java b/core/java/android/app/admin/DevicePolicyManager.java index 39c27a165588..84d67415a4b4 100644 --- a/core/java/android/app/admin/DevicePolicyManager.java +++ b/core/java/android/app/admin/DevicePolicyManager.java @@ -17430,12 +17430,17 @@ public class DevicePolicyManager { } /** - * Removes a manged profile from the device only when called from a managed profile's context + * Removes a managed profile from the device. + * + * <p> + * Removes the managed profile which is specified by the context user + * ({@code Context.createContextAsUser()}). + * <p> * - * @param user UserHandle of the profile to be removed * @return {@code true} when removal of managed profile was successful, {@code false} when - * removal was unsuccessful or throws IllegalArgumentException when provided user was not a + * removal was unsuccessful or throws IllegalArgumentException when specified user was not a * managed profile + * * @hide */ @SystemApi diff --git a/core/java/android/os/UserManager.java b/core/java/android/os/UserManager.java index 507bcb8c2717..08f68f1874e7 100644 --- a/core/java/android/os/UserManager.java +++ b/core/java/android/os/UserManager.java @@ -4206,12 +4206,16 @@ public class UserManager { private boolean getUserRestrictionFromQuery(@NonNull Pair<String, Integer> restrictionPerUser) { return UserManagerCache.getUserRestrictionFromQuery( (Pair<String, Integer> q) -> mService.hasUserRestriction(q.first, q.second), + // bypass cache if the flag is disabled + (Pair<String, Integer> q) -> !android.multiuser.Flags.cacheUserRestrictionsReadOnly(), restrictionPerUser); } /** @hide */ public static final void invalidateUserRestriction() { - UserManagerCache.invalidateUserRestrictionFromQuery(); + if (android.multiuser.Flags.cacheUserRestrictionsReadOnly()) { + UserManagerCache.invalidateUserRestrictionFromQuery(); + } } /** diff --git a/core/java/com/android/internal/widget/NotificationExpandButton.java b/core/java/com/android/internal/widget/NotificationExpandButton.java index d4dd1e705653..751cfde70164 100644 --- a/core/java/com/android/internal/widget/NotificationExpandButton.java +++ b/core/java/com/android/internal/widget/NotificationExpandButton.java @@ -16,6 +16,8 @@ package com.android.internal.widget; +import static android.app.Flags.notificationsRedesignTemplates; + import android.annotation.ColorInt; import android.annotation.Nullable; import android.content.Context; @@ -130,10 +132,18 @@ public class NotificationExpandButton extends FrameLayout { int drawableId; int contentDescriptionId; if (mExpanded) { - drawableId = R.drawable.ic_collapse_notification; + if (notificationsRedesignTemplates()) { + drawableId = R.drawable.ic_notification_2025_collapse; + } else { + drawableId = R.drawable.ic_collapse_notification; + } contentDescriptionId = R.string.expand_button_content_description_expanded; } else { - drawableId = R.drawable.ic_expand_notification; + if (notificationsRedesignTemplates()) { + drawableId = R.drawable.ic_notification_2025_expand; + } else { + drawableId = R.drawable.ic_expand_notification; + } contentDescriptionId = R.string.expand_button_content_description_collapsed; } setContentDescription(mContext.getText(contentDescriptionId)); diff --git a/core/res/res/drawable/ic_notification_2025_collapse.xml b/core/res/res/drawable/ic_notification_2025_collapse.xml new file mode 100644 index 000000000000..1b40c555f84d --- /dev/null +++ b/core/res/res/drawable/ic_notification_2025_collapse.xml @@ -0,0 +1,25 @@ +<!-- + ~ Copyright (C) 2024 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. + --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M480,432L296,616L240,560L480,320L720,560L664,616L480,432Z"/> +</vector> diff --git a/core/res/res/drawable/ic_notification_2025_expand.xml b/core/res/res/drawable/ic_notification_2025_expand.xml new file mode 100644 index 000000000000..ea5e0f09d2ef --- /dev/null +++ b/core/res/res/drawable/ic_notification_2025_expand.xml @@ -0,0 +1,25 @@ +<!-- + ~ Copyright (C) 2024 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. + --> +<vector xmlns:android="http://schemas.android.com/apk/res/android" + android:width="24dp" + android:height="24dp" + android:viewportWidth="960" + android:viewportHeight="960" + android:tint="?attr/colorControlNormal"> + <path + android:fillColor="@android:color/white" + android:pathData="M480,616L240,376L296,320L480,504L664,320L720,376L480,616Z"/> +</vector> diff --git a/core/res/res/drawable/notification_2025_expand_button_pill_bg.xml b/core/res/res/drawable/notification_2025_expand_button_pill_bg.xml new file mode 100644 index 000000000000..74f697a176f3 --- /dev/null +++ b/core/res/res/drawable/notification_2025_expand_button_pill_bg.xml @@ -0,0 +1,29 @@ +<!-- + ~ Copyright (C) 2024 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. + --> +<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> + <item android:id="@+id/expand_button_pill_colorized_layer"> + <shape xmlns:android="http://schemas.android.com/apk/res/android"> + <corners android:radius="@dimen/notification_2025_expand_button_pill_height" /> + <solid android:color="@android:color/white" /> + </shape> + </item> + <item> + <shape xmlns:android="http://schemas.android.com/apk/res/android"> + <corners android:radius="@dimen/notification_2025_expand_button_pill_height" /> + <solid android:color="@color/notification_expand_button_state_tint" /> + </shape> + </item> +</layer-list> diff --git a/core/res/res/layout/notification_2025_expand_button.xml b/core/res/res/layout/notification_2025_expand_button.xml new file mode 100644 index 000000000000..c8263c26f38f --- /dev/null +++ b/core/res/res/layout/notification_2025_expand_button.xml @@ -0,0 +1,61 @@ +<?xml version="1.0" encoding="utf-8"?><!-- + ~ Copyright (C) 2024 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 + --> + +<!-- extends FrameLayout --> +<com.android.internal.widget.NotificationExpandButton + xmlns:android="http://schemas.android.com/apk/res/android" + android:id="@+id/expand_button" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_gravity="top|end" + android:contentDescription="@string/expand_button_content_description_collapsed" + > + + <LinearLayout + android:id="@+id/expand_button_pill" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:minHeight="@dimen/notification_2025_expand_button_pill_height" + android:minWidth="@dimen/notification_2025_expand_button_pill_width" + android:paddingVertical="@dimen/notification_2025_expand_button_vertical_icon_padding" + android:paddingHorizontal="@dimen/notification_2025_expand_button_horizontal_icon_padding" + android:orientation="horizontal" + android:background="@drawable/notification_2025_expand_button_pill_bg" + android:gravity="center" + android:layout_gravity="center_vertical" + android:duplicateParentState="true" + > + + <TextView + android:id="@+id/expand_button_number" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:textAppearance="@style/TextAppearance.DeviceDefault.Notification.Info" + android:gravity="center_vertical" + android:visibility="gone" + /> + + <ImageView + android:id="@+id/expand_button_icon" + android:layout_width="@dimen/notification_2025_expand_button_icon_size" + android:layout_height="@dimen/notification_2025_expand_button_icon_size" + android:scaleType="fitCenter" + android:importantForAccessibility="no" + /> + + </LinearLayout> + +</com.android.internal.widget.NotificationExpandButton> diff --git a/core/res/res/layout/notification_2025_template_collapsed_base.xml b/core/res/res/layout/notification_2025_template_collapsed_base.xml index e91e1115ac1c..c827dcb16584 100644 --- a/core/res/res/layout/notification_2025_template_collapsed_base.xml +++ b/core/res/res/layout/notification_2025_template_collapsed_base.xml @@ -164,10 +164,11 @@ android:minWidth="@dimen/notification_content_margin_end" > - <include layout="@layout/notification_expand_button" + <include layout="@layout/notification_2025_expand_button" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_gravity="center_vertical|end" + android:layout_gravity="top|end" + android:layout_margin="@dimen/notification_2025_margin" /> </FrameLayout> diff --git a/core/res/res/layout/notification_2025_template_collapsed_call.xml b/core/res/res/layout/notification_2025_template_collapsed_call.xml index c4bca1142ece..ce38c1645fb1 100644 --- a/core/res/res/layout/notification_2025_template_collapsed_call.xml +++ b/core/res/res/layout/notification_2025_template_collapsed_call.xml @@ -66,10 +66,11 @@ > <include - layout="@layout/notification_expand_button" + layout="@layout/notification_2025_expand_button" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_gravity="center_vertical" + android:layout_gravity="top|end" + android:layout_margin="@dimen/notification_2025_margin" /> </FrameLayout> diff --git a/core/res/res/layout/notification_2025_template_collapsed_media.xml b/core/res/res/layout/notification_2025_template_collapsed_media.xml index 2d367337bb6f..0021b8384847 100644 --- a/core/res/res/layout/notification_2025_template_collapsed_media.xml +++ b/core/res/res/layout/notification_2025_template_collapsed_media.xml @@ -185,10 +185,11 @@ android:minWidth="@dimen/notification_content_margin_end" > - <include layout="@layout/notification_expand_button" + <include layout="@layout/notification_2025_expand_button" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_gravity="center_vertical|end" + android:layout_gravity="top|end" + android:layout_margin="@dimen/notification_2025_margin" /> </FrameLayout> diff --git a/core/res/res/layout/notification_2025_template_collapsed_messaging.xml b/core/res/res/layout/notification_2025_template_collapsed_messaging.xml index fbecb8c30b9c..f3e4ce13ff4b 100644 --- a/core/res/res/layout/notification_2025_template_collapsed_messaging.xml +++ b/core/res/res/layout/notification_2025_template_collapsed_messaging.xml @@ -189,10 +189,11 @@ android:minWidth="@dimen/notification_content_margin_end" > - <include layout="@layout/notification_expand_button" + <include layout="@layout/notification_2025_expand_button" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_gravity="center_vertical|end" + android:layout_gravity="top|end" + android:layout_margin="@dimen/notification_2025_margin" /> </FrameLayout> diff --git a/core/res/res/layout/notification_2025_template_conversation.xml b/core/res/res/layout/notification_2025_template_conversation.xml index f31f65e90950..6be5a1cee7f9 100644 --- a/core/res/res/layout/notification_2025_template_conversation.xml +++ b/core/res/res/layout/notification_2025_template_conversation.xml @@ -148,10 +148,11 @@ android:clipToPadding="false" android:clipChildren="false" /> - <include layout="@layout/notification_expand_button" + <include layout="@layout/notification_2025_expand_button" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_gravity="center" + android:layout_gravity="top|end" + android:layout_margin="@dimen/notification_2025_margin" /> </LinearLayout> </LinearLayout> diff --git a/core/res/res/layout/notification_2025_template_expanded_base.xml b/core/res/res/layout/notification_2025_template_expanded_base.xml index e480fe5eef1e..d364c659d0db 100644 --- a/core/res/res/layout/notification_2025_template_expanded_base.xml +++ b/core/res/res/layout/notification_2025_template_expanded_base.xml @@ -40,13 +40,14 @@ <include layout="@layout/notification_2025_template_header" /> + <!-- Note: the top margin is being set in code based on the estimated space needed for + the header text. --> <LinearLayout android:id="@+id/notification_main_column" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginStart="@dimen/notification_2025_content_margin_start" android:layout_marginEnd="@dimen/notification_content_margin_end" - android:layout_marginTop="@dimen/notification_content_margin_top" android:orientation="vertical" > diff --git a/core/res/res/layout/notification_2025_template_expanded_big_picture.xml b/core/res/res/layout/notification_2025_template_expanded_big_picture.xml index 18bafe068fcb..12e11728f608 100644 --- a/core/res/res/layout/notification_2025_template_expanded_big_picture.xml +++ b/core/res/res/layout/notification_2025_template_expanded_big_picture.xml @@ -32,11 +32,12 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:layout_gravity="top" - android:layout_marginTop="@dimen/notification_content_margin_top" android:clipToPadding="false" android:orientation="vertical" > + <!-- Note: the top margin is being set in code based on the estimated space needed for + the header text. --> <LinearLayout android:id="@+id/notification_main_column" android:layout_width="match_parent" diff --git a/core/res/res/layout/notification_2025_template_expanded_big_text.xml b/core/res/res/layout/notification_2025_template_expanded_big_text.xml index 9ff141b7c946..c9dd868795de 100644 --- a/core/res/res/layout/notification_2025_template_expanded_big_text.xml +++ b/core/res/res/layout/notification_2025_template_expanded_big_text.xml @@ -30,12 +30,13 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" - android:layout_marginTop="@dimen/notification_content_margin_top" android:layout_marginBottom="@dimen/notification_content_margin" android:clipToPadding="false" android:orientation="vertical" > + <!-- Note: the top margin is being set in code based on the estimated space needed for + the header text. --> <com.android.internal.widget.RemeasuringLinearLayout android:id="@+id/notification_main_column" android:layout_width="match_parent" diff --git a/core/res/res/layout/notification_2025_template_expanded_call.xml b/core/res/res/layout/notification_2025_template_expanded_call.xml index 2af0ec2972df..0be61253c917 100644 --- a/core/res/res/layout/notification_2025_template_expanded_call.xml +++ b/core/res/res/layout/notification_2025_template_expanded_call.xml @@ -48,8 +48,8 @@ android:id="@+id/notification_main_column" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_weight="1" android:layout_marginStart="@dimen/notification_2025_content_margin_start" + android:layout_weight="1" android:orientation="vertical" android:minHeight="68dp" > diff --git a/core/res/res/layout/notification_2025_template_expanded_inbox.xml b/core/res/res/layout/notification_2025_template_expanded_inbox.xml index 9fb44ccccfa0..8434b3644f81 100644 --- a/core/res/res/layout/notification_2025_template_expanded_inbox.xml +++ b/core/res/res/layout/notification_2025_template_expanded_inbox.xml @@ -28,10 +28,11 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:layout_gravity="top" - android:layout_marginTop="@dimen/notification_content_margin_top" android:clipToPadding="false" android:orientation="vertical"> + <!-- Note: the top margin is being set in code based on the estimated space needed for + the header text. --> <LinearLayout android:id="@+id/notification_main_column" android:layout_width="match_parent" diff --git a/core/res/res/layout/notification_2025_template_expanded_media.xml b/core/res/res/layout/notification_2025_template_expanded_media.xml index 578a0b2b6d0b..e90ab792581f 100644 --- a/core/res/res/layout/notification_2025_template_expanded_media.xml +++ b/core/res/res/layout/notification_2025_template_expanded_media.xml @@ -34,11 +34,12 @@ android:id="@+id/notification_media_content" > + <!-- Note: the top margin is being set in code based on the estimated space needed for + the header text. --> <LinearLayout android:id="@+id/notification_main_column" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginTop="@dimen/notification_content_margin_top" android:layout_marginStart="@dimen/notification_2025_content_margin_start" android:layout_marginEnd="@dimen/notification_content_margin_end" android:orientation="vertical" diff --git a/core/res/res/layout/notification_2025_template_expanded_messaging.xml b/core/res/res/layout/notification_2025_template_expanded_messaging.xml index e3c201465eb0..7f5a36b5f865 100644 --- a/core/res/res/layout/notification_2025_template_expanded_messaging.xml +++ b/core/res/res/layout/notification_2025_template_expanded_messaging.xml @@ -33,10 +33,11 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_gravity="top" - android:layout_marginTop="@dimen/notification_2025_header_height" android:clipChildren="false" android:orientation="vertical"> + <!-- Note: the top margin is being set in code based on the estimated space needed for + the header text. --> <com.android.internal.widget.RemeasuringLinearLayout android:id="@+id/notification_main_column" android:layout_width="match_parent" diff --git a/core/res/res/layout/notification_2025_template_expanded_progress.xml b/core/res/res/layout/notification_2025_template_expanded_progress.xml index afa4bc6dd7f8..5d4fc4c87fac 100644 --- a/core/res/res/layout/notification_2025_template_expanded_progress.xml +++ b/core/res/res/layout/notification_2025_template_expanded_progress.xml @@ -41,13 +41,14 @@ <include layout="@layout/notification_2025_template_header" /> + <!-- Note: the top margin is being set in code based on the estimated space needed for + the header text. --> <LinearLayout android:id="@+id/notification_main_column" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginStart="@dimen/notification_2025_content_margin_start" android:layout_marginEnd="@dimen/notification_content_margin_end" - android:layout_marginTop="@dimen/notification_content_margin_top" android:orientation="vertical" > diff --git a/core/res/res/layout/notification_2025_template_header.xml b/core/res/res/layout/notification_2025_template_header.xml index 2d30d8a8bbb6..3f34eb3cbf0c 100644 --- a/core/res/res/layout/notification_2025_template_header.xml +++ b/core/res/res/layout/notification_2025_template_header.xml @@ -57,11 +57,11 @@ xmlns:android="http://schemas.android.com/apk/res/android" android:id="@+id/notification_top_line" android:layout_width="wrap_content" - android:layout_height="match_parent" + android:layout_height="wrap_content" android:layout_alignParentStart="true" - android:layout_centerVertical="true" android:layout_toStartOf="@id/expand_button" android:layout_alignWithParentIfMissing="true" + android:layout_marginVertical="@dimen/notification_2025_margin" android:clipChildren="false" android:gravity="center_vertical" android:paddingStart="@dimen/notification_2025_content_margin_start" @@ -81,10 +81,11 @@ android:focusable="false" /> - <include layout="@layout/notification_expand_button" + <include layout="@layout/notification_2025_expand_button" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_centerVertical="true" + android:layout_gravity="top|end" + android:layout_margin="@dimen/notification_2025_margin" android:layout_alignParentEnd="true" /> <include layout="@layout/notification_close_button" diff --git a/core/res/res/values/dimens.xml b/core/res/res/values/dimens.xml index fb21c7532ea3..a4735fe6c7af 100644 --- a/core/res/res/values/dimens.xml +++ b/core/res/res/values/dimens.xml @@ -310,9 +310,15 @@ <!-- Size of the stroke with for the emphasized notification button style --> <dimen name="emphasized_button_stroke_width">1dp</dimen> - <!-- height of the content margin to accomodate for the header --> + <!-- height of the content margin to accommodate for the header --> <dimen name="notification_content_margin_top">50dp</dimen> + <!-- The spacing between the content and the header text above it, scaling with text size. + This value is chosen so that, taking into account the text spacing for both the text in the + top line and the text in the content, the distance between them is 4dp with the default + screen configuration (and will grow accordingly for larger font sizes) --> + <dimen name="notification_2025_content_margin_top">10sp</dimen> + <!-- height of the content margin that is applied at the end of the notification content --> <dimen name="notification_content_margin">20dp</dimen> @@ -384,9 +390,24 @@ <!-- the height of the expand button pill --> <dimen name="notification_expand_button_pill_height">24dp</dimen> + <!-- the height of the expand button pill (2025 redesign version) --> + <dimen name="notification_2025_expand_button_pill_height">20dp</dimen> + + <!-- the width of the expand button pill (2025 redesign version) --> + <dimen name="notification_2025_expand_button_pill_width">28dp</dimen> + + <!-- the size of the expand arrow (2025 redesign version) --> + <dimen name="notification_2025_expand_button_icon_size">16sp</dimen> + <!-- the padding of the expand icon in the notification header --> <dimen name="notification_expand_button_icon_padding">2dp</dimen> + <!-- the padding of the expand icon in the notification header --> + <dimen name="notification_2025_expand_button_vertical_icon_padding">2dp</dimen> + + <!-- the padding of the expand icon in the notification header --> + <dimen name="notification_2025_expand_button_horizontal_icon_padding">6dp</dimen> + <!-- the size of the notification close button --> <dimen name="notification_close_button_size">16dp</dimen> diff --git a/core/res/res/values/symbols.xml b/core/res/res/values/symbols.xml index e0c92d9231b0..6ee2839788af 100644 --- a/core/res/res/values/symbols.xml +++ b/core/res/res/values/symbols.xml @@ -3230,6 +3230,8 @@ <java-symbol type="id" name="header_text_divider" /> <java-symbol type="id" name="header_text_secondary_divider" /> <java-symbol type="drawable" name="ic_expand_notification" /> + <java-symbol type="drawable" name="ic_notification_2025_collapse" /> + <java-symbol type="drawable" name="ic_notification_2025_expand" /> <java-symbol type="drawable" name="ic_collapse_notification" /> <java-symbol type="drawable" name="notification_close_button_icon" /> <java-symbol type="drawable" name="ic_expand_bundle" /> @@ -3242,6 +3244,8 @@ <java-symbol type="dimen" name="notification_heading_margin_end" /> <java-symbol type="dimen" name="notification_content_margin_top" /> <java-symbol type="dimen" name="notification_content_margin" /> + <java-symbol type="dimen" name="notification_2025_margin" /> + <java-symbol type="dimen" name="notification_2025_content_margin_top" /> <java-symbol type="dimen" name="notification_progress_margin_horizontal" /> <java-symbol type="dimen" name="notification_header_background_height" /> <java-symbol type="dimen" name="notification_header_touchable_height" /> diff --git a/libs/WindowManager/Shell/multivalentTests/AndroidManifest.xml b/libs/WindowManager/Shell/multivalentTests/AndroidManifest.xml index f8f8338e5f04..fd578a959e3b 100644 --- a/libs/WindowManager/Shell/multivalentTests/AndroidManifest.xml +++ b/libs/WindowManager/Shell/multivalentTests/AndroidManifest.xml @@ -3,6 +3,8 @@ <application android:debuggable="true" android:supportsRtl="true" > <uses-library android:name="android.test.runner" /> + <activity android:name="com.android.wm.shell.bubbles.bar.BubbleBarAnimationHelperTest$TestActivity" + android:exported="true"/> </application> <instrumentation diff --git a/libs/WindowManager/Shell/multivalentTests/AndroidManifestRobolectric.xml b/libs/WindowManager/Shell/multivalentTests/AndroidManifestRobolectric.xml index ffcd7d46fbae..bb111dbeffff 100644 --- a/libs/WindowManager/Shell/multivalentTests/AndroidManifestRobolectric.xml +++ b/libs/WindowManager/Shell/multivalentTests/AndroidManifestRobolectric.xml @@ -1,3 +1,8 @@ <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.android.wm.shell.multivalenttests"> + <application> + <activity android:name="com.android.wm.shell.bubbles.bar.BubbleBarAnimationHelperTest$TestActivity" + android:exported="true"/> + </application> </manifest> + diff --git a/libs/WindowManager/Shell/multivalentTests/src/com/android/wm/shell/bubbles/BubbleControllerBubbleBarTest.kt b/libs/WindowManager/Shell/multivalentTests/src/com/android/wm/shell/bubbles/BubbleControllerBubbleBarTest.kt index 2b4e5417f188..c62d2a06bad5 100644 --- a/libs/WindowManager/Shell/multivalentTests/src/com/android/wm/shell/bubbles/BubbleControllerBubbleBarTest.kt +++ b/libs/WindowManager/Shell/multivalentTests/src/com/android/wm/shell/bubbles/BubbleControllerBubbleBarTest.kt @@ -35,11 +35,11 @@ import com.android.internal.statusbar.IStatusBarService import com.android.wm.shell.Flags import com.android.wm.shell.ShellTaskOrganizer import com.android.wm.shell.TestShellExecutor -import com.android.wm.shell.WindowManagerShellWrapper import com.android.wm.shell.bubbles.Bubbles.SysuiProxy import com.android.wm.shell.bubbles.properties.ProdBubbleProperties import com.android.wm.shell.bubbles.storage.BubblePersistentRepository import com.android.wm.shell.common.DisplayController +import com.android.wm.shell.common.DisplayImeController import com.android.wm.shell.common.DisplayInsetsController import com.android.wm.shell.common.FloatingContentCoordinator import com.android.wm.shell.common.SyncTransactionQueue @@ -268,7 +268,8 @@ class BubbleControllerBubbleBarTest { bubbleDataRepository, mock<IStatusBarService>(), mock<WindowManager>(), - WindowManagerShellWrapper(mainExecutor), + mock<DisplayInsetsController>(), + mock<DisplayImeController>(), mock<UserManager>(), mock<LauncherApps>(), bubbleLogger, diff --git a/libs/WindowManager/Shell/multivalentTests/src/com/android/wm/shell/bubbles/BubbleViewInfoTaskTest.kt b/libs/WindowManager/Shell/multivalentTests/src/com/android/wm/shell/bubbles/BubbleViewInfoTaskTest.kt index 680d015dfd2f..3043e2bcb0be 100644 --- a/libs/WindowManager/Shell/multivalentTests/src/com/android/wm/shell/bubbles/BubbleViewInfoTaskTest.kt +++ b/libs/WindowManager/Shell/multivalentTests/src/com/android/wm/shell/bubbles/BubbleViewInfoTaskTest.kt @@ -36,10 +36,10 @@ import com.android.internal.statusbar.IStatusBarService import com.android.launcher3.icons.BubbleIconFactory import com.android.wm.shell.ShellTaskOrganizer import com.android.wm.shell.TestShellExecutor -import com.android.wm.shell.WindowManagerShellWrapper import com.android.wm.shell.bubbles.properties.BubbleProperties import com.android.wm.shell.bubbles.storage.BubblePersistentRepository import com.android.wm.shell.common.DisplayController +import com.android.wm.shell.common.DisplayImeController import com.android.wm.shell.common.DisplayInsetsController import com.android.wm.shell.common.FloatingContentCoordinator import com.android.wm.shell.common.SyncTransactionQueue @@ -141,7 +141,8 @@ class BubbleViewInfoTaskTest { bubbleDataRepository, mock<IStatusBarService>(), windowManager, - WindowManagerShellWrapper(mainExecutor), + mock<DisplayInsetsController>(), + mock<DisplayImeController>(), mock<UserManager>(), mock<LauncherApps>(), bubbleLogger, diff --git a/libs/WindowManager/Shell/multivalentTests/src/com/android/wm/shell/bubbles/bar/BubbleBarAnimationHelperTest.kt b/libs/WindowManager/Shell/multivalentTests/src/com/android/wm/shell/bubbles/bar/BubbleBarAnimationHelperTest.kt index 3e01256fd67c..957f0ca502a1 100644 --- a/libs/WindowManager/Shell/multivalentTests/src/com/android/wm/shell/bubbles/bar/BubbleBarAnimationHelperTest.kt +++ b/libs/WindowManager/Shell/multivalentTests/src/com/android/wm/shell/bubbles/bar/BubbleBarAnimationHelperTest.kt @@ -17,14 +17,19 @@ package com.android.wm.shell.bubbles.bar import android.animation.AnimatorTestRule +import android.app.Activity import android.app.ActivityManager import android.content.Context import android.graphics.Insets +import android.graphics.Outline import android.graphics.Rect +import android.os.Bundle import android.view.View import android.view.ViewGroup import android.view.WindowManager import android.widget.FrameLayout +import android.widget.FrameLayout.LayoutParams +import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest @@ -63,6 +68,7 @@ import org.mockito.kotlin.whenever class BubbleBarAnimationHelperTest { @get:Rule val animatorTestRule: AnimatorTestRule = AnimatorTestRule(this) + private lateinit var activityScenario: ActivityScenario<TestActivity> companion object { const val SCREEN_WIDTH = 2000 @@ -83,6 +89,8 @@ class BubbleBarAnimationHelperTest { fun setUp() { ProtoLog.REQUIRE_PROTOLOGTOOL = false ProtoLog.init() + activityScenario = ActivityScenario.launch(TestActivity::class.java) + activityScenario.onActivity { activity -> container = activity.container } val windowManager = context.getSystemService(WindowManager::class.java) bubblePositioner = BubblePositioner(context, windowManager) bubblePositioner.setShowingInBubbleBar(true) @@ -102,8 +110,6 @@ class BubbleBarAnimationHelperTest { mainExecutor = TestShellExecutor() bgExecutor = TestShellExecutor() - container = FrameLayout(context) - animationHelper = BubbleBarAnimationHelper(context, bubblePositioner) } @@ -121,7 +127,7 @@ class BubbleBarAnimationHelperTest { val semaphore = Semaphore(0) val after = Runnable { semaphore.release() } - getInstrumentation().runOnMainSync { + activityScenario.onActivity { animationHelper.animateSwitch(fromBubble, toBubble, after) animatorTestRule.advanceTimeBy(1000) } @@ -145,7 +151,7 @@ class BubbleBarAnimationHelperTest { .updateHandleColor(/* isRegionDark= */ true, /* animated= */ false) val toBubble = createBubble(key = "to").initialize(container) - getInstrumentation().runOnMainSync { + activityScenario.onActivity { animationHelper.animateSwitch(fromBubble, toBubble, /* afterAnimation= */ null) animatorTestRule.advanceTimeBy(1000) } @@ -161,7 +167,7 @@ class BubbleBarAnimationHelperTest { val toBubbleTaskController = mock<TaskViewTaskController>() val toBubble = createBubble("to", toBubbleTaskController).initialize(container) - getInstrumentation().runOnMainSync { + activityScenario.onActivity { animationHelper.animateSwitch(fromBubble, toBubble) {} // Start the animation, but don't finish animatorTestRule.advanceTimeBy(100) @@ -183,7 +189,7 @@ class BubbleBarAnimationHelperTest { val semaphore = Semaphore(0) val after = Runnable { semaphore.release() } - getInstrumentation().runOnMainSync { + activityScenario.onActivity { animationHelper.animateSwitch(fromBubble, overflow, after) animatorTestRule.advanceTimeBy(1000) } @@ -206,7 +212,7 @@ class BubbleBarAnimationHelperTest { val semaphore = Semaphore(0) val after = Runnable { semaphore.release() } - getInstrumentation().runOnMainSync { + activityScenario.onActivity { animationHelper.animateSwitch(overflow, toBubble, after) animatorTestRule.advanceTimeBy(1000) } @@ -226,7 +232,7 @@ class BubbleBarAnimationHelperTest { val taskController = mock<TaskViewTaskController>() val bubble = createBubble("key", taskController).initialize(container) - getInstrumentation().runOnMainSync { + activityScenario.onActivity { animationHelper.animateExpansion(bubble) {} animatorTestRule.advanceTimeBy(1000) } @@ -243,6 +249,80 @@ class BubbleBarAnimationHelperTest { verify(taskController).setWindowBounds(any()) } + @Test + fun animateExpansion() { + val bubble = createBubble(key = "b1").initialize(container) + val bbev = bubble.bubbleBarExpandedView!! + + val semaphore = Semaphore(0) + val after = Runnable { semaphore.release() } + + activityScenario.onActivity { + bbev.onTaskCreated() + animationHelper.animateExpansion(bubble, after) + animatorTestRule.advanceTimeBy(1000) + } + getInstrumentation().waitForIdleSync() + + assertThat(semaphore.tryAcquire(5, TimeUnit.SECONDS)).isTrue() + assertThat(bbev.alpha).isEqualTo(1) + } + + @Test + fun onImeTopChanged_noOverlap() { + val bubble = createBubble(key = "b1").initialize(container) + val bbev = bubble.bubbleBarExpandedView!! + + val semaphore = Semaphore(0) + val after = Runnable { semaphore.release() } + + activityScenario.onActivity { + bbev.onTaskCreated() + animationHelper.animateExpansion(bubble, after) + animatorTestRule.advanceTimeBy(1000) + } + getInstrumentation().waitForIdleSync() + + assertThat(semaphore.tryAcquire(5, TimeUnit.SECONDS)).isTrue() + + val bbevBottom = bbev.contentBottomOnScreen + bubblePositioner.insets.top + activityScenario.onActivity { + // notify that the IME top coordinate is greater than the bottom of the expanded view. + // there's no overlap so it should not be clipped. + animationHelper.onImeTopChanged(bbevBottom * 2) + } + val outline = Outline() + bbev.outlineProvider.getOutline(bbev, outline) + assertThat(outline.mRect.bottom).isEqualTo(bbev.height) + } + + @Test + fun onImeTopChanged_overlapsWithExpandedView() { + val bubble = createBubble(key = "b1").initialize(container) + val bbev = bubble.bubbleBarExpandedView!! + + val semaphore = Semaphore(0) + val after = Runnable { semaphore.release() } + + activityScenario.onActivity { + bbev.onTaskCreated() + animationHelper.animateExpansion(bubble, after) + animatorTestRule.advanceTimeBy(1000) + } + getInstrumentation().waitForIdleSync() + + assertThat(semaphore.tryAcquire(5, TimeUnit.SECONDS)).isTrue() + + activityScenario.onActivity { + // notify that the IME top coordinate is less than the bottom of the expanded view, + // meaning it overlaps with it so we should be clipping the expanded view. + animationHelper.onImeTopChanged(bbev.contentBottomOnScreen - 10) + } + val outline = Outline() + bbev.outlineProvider.getOutline(bbev, outline) + assertThat(outline.mRect.bottom).isEqualTo(bbev.height - 10) + } + private fun createBubble( key: String, taskViewTaskController: TaskViewTaskController = mock<TaskViewTaskController>(), @@ -273,14 +353,24 @@ class BubbleBarAnimationHelperTest { } private fun Bubble.initialize(container: ViewGroup): Bubble { - getInstrumentation().runOnMainSync { container.addView(bubbleBarExpandedView) } + activityScenario.onActivity { container.addView(bubbleBarExpandedView) } // Mark taskView's visible bubbleBarExpandedView!!.onContentVisibilityChanged(true) return this } private fun BubbleOverflow.initialize(container: ViewGroup): BubbleOverflow { - getInstrumentation().runOnMainSync { container.addView(bubbleBarExpandedView) } + activityScenario.onActivity { container.addView(bubbleBarExpandedView) } return this } + + class TestActivity : Activity() { + lateinit var container: FrameLayout + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + container = FrameLayout(applicationContext) + container.layoutParams = LayoutParams(50, 50) + setContentView(container) + } + } } diff --git a/libs/WindowManager/Shell/multivalentTests/src/com/android/wm/shell/bubbles/bar/BubbleBarLayerViewTest.kt b/libs/WindowManager/Shell/multivalentTests/src/com/android/wm/shell/bubbles/bar/BubbleBarLayerViewTest.kt index 5c86b321b60f..9b1645e9534c 100644 --- a/libs/WindowManager/Shell/multivalentTests/src/com/android/wm/shell/bubbles/bar/BubbleBarLayerViewTest.kt +++ b/libs/WindowManager/Shell/multivalentTests/src/com/android/wm/shell/bubbles/bar/BubbleBarLayerViewTest.kt @@ -37,7 +37,6 @@ import com.android.internal.statusbar.IStatusBarService import com.android.wm.shell.R import com.android.wm.shell.ShellTaskOrganizer import com.android.wm.shell.TestShellExecutor -import com.android.wm.shell.WindowManagerShellWrapper import com.android.wm.shell.bubbles.Bubble import com.android.wm.shell.bubbles.BubbleController import com.android.wm.shell.bubbles.BubbleData @@ -54,6 +53,7 @@ import com.android.wm.shell.bubbles.animation.AnimatableScaleMatrix import com.android.wm.shell.bubbles.properties.BubbleProperties import com.android.wm.shell.bubbles.storage.BubblePersistentRepository import com.android.wm.shell.common.DisplayController +import com.android.wm.shell.common.DisplayImeController import com.android.wm.shell.common.DisplayInsetsController import com.android.wm.shell.common.FloatingContentCoordinator import com.android.wm.shell.common.SyncTransactionQueue @@ -180,7 +180,8 @@ class BubbleBarLayerViewTest { bubbleDataRepository, mock<IStatusBarService>(), windowManager, - WindowManagerShellWrapper(mainExecutor), + mock<DisplayInsetsController>(), + mock<DisplayImeController>(), mock<UserManager>(), mock<LauncherApps>(), bubbleLogger, diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleController.java index 9aba3aaa3268..348f13a493b1 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleController.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubbleController.java @@ -90,13 +90,15 @@ import com.android.launcher3.icons.BubbleIconFactory; import com.android.wm.shell.Flags; import com.android.wm.shell.R; import com.android.wm.shell.ShellTaskOrganizer; -import com.android.wm.shell.WindowManagerShellWrapper; import com.android.wm.shell.bubbles.bar.BubbleBarLayerView; import com.android.wm.shell.bubbles.properties.BubbleProperties; import com.android.wm.shell.bubbles.shortcut.BubbleShortcutHelper; import com.android.wm.shell.common.DisplayController; +import com.android.wm.shell.common.DisplayImeController; +import com.android.wm.shell.common.DisplayInsetsController; import com.android.wm.shell.common.ExternalInterfaceBinder; import com.android.wm.shell.common.FloatingContentCoordinator; +import com.android.wm.shell.common.ImeListener; import com.android.wm.shell.common.RemoteCallable; import com.android.wm.shell.common.ShellExecutor; import com.android.wm.shell.common.SingleInstanceRemoteListener; @@ -106,7 +108,6 @@ import com.android.wm.shell.common.TaskStackListenerImpl; import com.android.wm.shell.draganddrop.DragAndDropController; import com.android.wm.shell.onehanded.OneHandedController; import com.android.wm.shell.onehanded.OneHandedTransitionCallback; -import com.android.wm.shell.pip.PinnedStackListenerForwarder; import com.android.wm.shell.shared.annotations.ShellBackgroundThread; import com.android.wm.shell.shared.annotations.ShellMainThread; import com.android.wm.shell.shared.bubbles.BubbleBarLocation; @@ -182,7 +183,8 @@ public class BubbleController implements ConfigurationChangeListener, @Nullable private final BubbleStackView.SurfaceSynchronizer mSurfaceSynchronizer; private final FloatingContentCoordinator mFloatingContentCoordinator; private final BubbleDataRepository mDataRepository; - private final WindowManagerShellWrapper mWindowManagerShellWrapper; + private final DisplayInsetsController mDisplayInsetsController; + private final DisplayImeController mDisplayImeController; private final UserManager mUserManager; private final LauncherApps mLauncherApps; private final IStatusBarService mBarService; @@ -291,7 +293,8 @@ public class BubbleController implements ConfigurationChangeListener, BubbleDataRepository dataRepository, @Nullable IStatusBarService statusBarService, WindowManager windowManager, - WindowManagerShellWrapper windowManagerShellWrapper, + DisplayInsetsController displayInsetsController, + DisplayImeController displayImeController, UserManager userManager, LauncherApps launcherApps, BubbleLogger bubbleLogger, @@ -318,7 +321,8 @@ public class BubbleController implements ConfigurationChangeListener, ServiceManager.getService(Context.STATUS_BAR_SERVICE)) : statusBarService; mWindowManager = windowManager; - mWindowManagerShellWrapper = windowManagerShellWrapper; + mDisplayInsetsController = displayInsetsController; + mDisplayImeController = displayImeController; mUserManager = userManager; mFloatingContentCoordinator = floatingContentCoordinator; mDataRepository = dataRepository; @@ -403,11 +407,15 @@ public class BubbleController implements ConfigurationChangeListener, mMainExecutor.execute(() -> removeBubble(bubble.getKey(), DISMISS_INVALID_INTENT)); }); - try { - mWindowManagerShellWrapper.addPinnedStackListener(new BubblesImeListener()); - } catch (RemoteException e) { - e.printStackTrace(); - } + BubblesImeListener bubblesImeListener = + new BubblesImeListener(mDisplayController, mContext.getDisplayId()); + // the insets controller is notified whenever the IME visibility changes whether the IME is + // requested by a bubbled task or non-bubbled task. in the latter case, we need to update + // the position of the stack to avoid overlapping with the IME. + mDisplayInsetsController.addInsetsChangedListener(mContext.getDisplayId(), + bubblesImeListener); + // the ime controller is notified when the IME is requested only by a bubbled task. + mDisplayImeController.addPositionProcessor(bubblesImeListener); mBubbleData.setCurrentUserId(mCurrentUserId); @@ -2515,16 +2523,57 @@ public class BubbleController implements ConfigurationChangeListener, return contextForUser.getPackageManager(); } - /** PinnedStackListener that dispatches IME visibility updates to the stack. */ - //TODO(b/170442945): Better way to do this / insets listener? - private class BubblesImeListener extends PinnedStackListenerForwarder.PinnedTaskListener { + /** {@link ImeListener} that dispatches IME visibility updates to the stack. */ + private class BubblesImeListener extends ImeListener implements + DisplayImeController.ImePositionProcessor { + + BubblesImeListener(DisplayController displayController, int displayId) { + super(displayController, displayId); + } + @Override - public void onImeVisibilityChanged(boolean imeVisible, int imeHeight) { - mBubblePositioner.setImeVisible(imeVisible, imeHeight); + protected void onImeVisibilityChanged(boolean imeVisible, int imeHeight) { + if (getDisplayId() != mContext.getDisplayId()) { + return; + } + // the imeHeight here is actually the ime inset; it only includes the part of the ime + // that overlaps with the Bubbles window. adjust it to include the bottom screen inset, + // so we have the total height of the ime. + int totalImeHeight = imeHeight + mBubblePositioner.getInsets().bottom; + mBubblePositioner.setImeVisible(imeVisible, totalImeHeight); if (mStackView != null) { mStackView.setImeVisible(imeVisible); } } + + @Override + public int onImeStartPositioning(int displayId, int hiddenTop, int shownTop, + boolean showing, boolean isFloating, SurfaceControl.Transaction t) { + if (mContext.getDisplayId() != displayId) { + return IME_ANIMATION_DEFAULT; + } + + if (showing) { + mBubblePositioner.setImeVisible(true, hiddenTop - shownTop); + } else { + mBubblePositioner.setImeVisible(false, 0); + } + if (mStackView != null) { + mStackView.setImeVisible(showing); + } + + return IME_ANIMATION_DEFAULT; + } + + @Override + public void onImePositionChanged(int displayId, int imeTop, SurfaceControl.Transaction t) { + if (mContext.getDisplayId() != displayId) { + return; + } + if (mLayerView != null) { + mLayerView.onImeTopChanged(imeTop); + } + } } /** diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubblePositioner.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubblePositioner.java index de85d9af127d..1a61793eab87 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubblePositioner.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/BubblePositioner.java @@ -67,6 +67,11 @@ public class BubblePositioner { private @Surface.Rotation int mRotation = Surface.ROTATION_0; private Insets mInsets; private boolean mImeVisible; + /** + * The height of the IME excluding the bottom inset. If the IME is 100 pixels tall and we have + * 20 pixels bottom inset, the IME height is adjusted to 80 to represent the overlap with the + * Bubbles window. + */ private int mImeHeight; private Rect mPositionRect; private int mDefaultMaxBubbles; @@ -336,10 +341,16 @@ public class BubblePositioner { return mImeVisible; } - /** Sets whether the IME is visible. **/ + /** + * Sets whether the IME is visible and its height. + * + * @param visible whether the IME is visible + * @param height the total height of the IME from the bottom of the physical screen + **/ public void setImeVisible(boolean visible, int height) { mImeVisible = visible; - mImeHeight = height; + // adjust the IME to account for the height as seen by the Bubbles window + mImeHeight = visible ? Math.max(height - getInsets().bottom, 0) : 0; } private int getExpandedViewLargeScreenInsetFurthestEdge(boolean isOverflow) { diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarAnimationHelper.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarAnimationHelper.java index 3188e5b9c6d2..de6d1f6c8852 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarAnimationHelper.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarAnimationHelper.java @@ -30,6 +30,8 @@ import static com.android.wm.shell.bubbles.bar.BubbleBarExpandedView.TASK_VIEW_A import static com.android.wm.shell.shared.animation.Interpolators.EMPHASIZED; import static com.android.wm.shell.shared.animation.Interpolators.EMPHASIZED_DECELERATE; +import static java.lang.Math.max; + import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; @@ -375,7 +377,6 @@ public class BubbleBarAnimationHelper { return animator; } - /** * Animate the expanded bubble when it is being dragged */ @@ -586,6 +587,18 @@ public class BubbleBarAnimationHelper { } } + /** Handles IME position changes. */ + public void onImeTopChanged(int imeTop) { + BubbleBarExpandedView bbev = getExpandedView(); + if (bbev == null) { + Log.w(TAG, "Bubble bar expanded view was null when IME top changed"); + return; + } + int bbevBottom = bbev.getContentBottomOnScreen(); + int clip = max(bbevBottom - imeTop, 0); + bbev.updateBottomClip(clip); + } + private @Nullable BubbleBarExpandedView getExpandedView() { BubbleViewProvider bubble = mExpandedBubble; if (bubble != null) { diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarExpandedView.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarExpandedView.java index 65c929ab6fb4..e073b02dc630 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarExpandedView.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarExpandedView.java @@ -137,6 +137,7 @@ public class BubbleBarExpandedView extends FrameLayout implements BubbleTaskView private Executor mBackgroundExecutor; private final Rect mSampleRect = new Rect(); private final int[] mLoc = new int[2]; + private final Rect mTempBounds = new Rect(); /** Height of the caption inset at the top of the TaskView */ private int mCaptionHeight; @@ -161,6 +162,9 @@ public class BubbleBarExpandedView extends FrameLayout implements BubbleTaskView private boolean mIsAnimating; private boolean mIsDragging; + private boolean mIsClipping = false; + private int mBottomClip = 0; + /** An enum value that tracks the visibility state of the task view */ private enum TaskViewVisibilityState { /** The task view is going away, and we're waiting for the surface to be destroyed. */ @@ -203,7 +207,8 @@ public class BubbleBarExpandedView extends FrameLayout implements BubbleTaskView setOutlineProvider(new ViewOutlineProvider() { @Override public void getOutline(View view, Outline outline) { - outline.setRoundRect(0, 0, view.getWidth(), view.getHeight(), mCurrentCornerRadius); + outline.setRoundRect(0, 0, view.getWidth(), view.getHeight() - mBottomClip, + mCurrentCornerRadius); } }); // Set a touch sink to ensure that clicks on the caption area do not propagate to the parent @@ -661,6 +666,52 @@ public class BubbleBarExpandedView extends FrameLayout implements BubbleTaskView } } + /** The y coordinate of the bottom of the expanded view. */ + public int getContentBottomOnScreen() { + if (mOverflowView != null) { + mOverflowView.getBoundsOnScreen(mTempBounds); + } + if (mTaskView != null) { + mTaskView.getBoundsOnScreen(mTempBounds); + } + // return the bottom of the content rect, adjusted for insets so the result is in screen + // coordinate + return mTempBounds.bottom + mPositioner.getInsets().top; + } + + /** Update the amount by which to clip the expanded view at the bottom. */ + public void updateBottomClip(int bottomClip) { + mBottomClip = bottomClip; + onClipUpdate(); + } + + private void onClipUpdate() { + if (mBottomClip == 0) { + if (mIsClipping) { + mIsClipping = false; + if (mTaskView != null) { + mTaskView.setClipBounds(null); + mTaskView.setEnableSurfaceClipping(false); + } + invalidateOutline(); + } + } else { + if (!mIsClipping) { + mIsClipping = true; + if (mTaskView != null) { + mTaskView.setEnableSurfaceClipping(true); + } + } + invalidateOutline(); + if (mTaskView != null) { + Rect clipBounds = new Rect(0, 0, + mTaskView.getWidth(), + mTaskView.getHeight() - mBottomClip); + mTaskView.setClipBounds(clipBounds); + } + } + } + private void recreateRegionSamplingHelper() { if (mRegionSamplingHelper != null) { mRegionSamplingHelper.stopAndDestroy(); diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarLayerView.java b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarLayerView.java index 88f34f3043e1..eaa0bd250fc4 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarLayerView.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/bubbles/bar/BubbleBarLayerView.java @@ -424,6 +424,13 @@ public class BubbleBarLayerView extends FrameLayout } } + /** Handles IME position changes. */ + public void onImeTopChanged(int imeTop) { + if (mIsExpanded) { + mAnimationHelper.onImeTopChanged(imeTop); + } + } + /** * Log the event only if {@link #mExpandedBubble} is a {@link Bubble}. * <p> diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/common/DisplayImeController.java b/libs/WindowManager/Shell/src/com/android/wm/shell/common/DisplayImeController.java index 9ebb7f5aa270..eb1e72790a25 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/common/DisplayImeController.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/common/DisplayImeController.java @@ -419,8 +419,12 @@ public class DisplayImeController implements DisplayController.OnDisplaysChanged // already (e.g., when focussing an editText in activity B, while and editText in // activity A is focussed), we will not get a call of #insetsControlChanged, and // therefore have to start the show animation from here - startAnimation(mImeRequestedVisible /* show */, false /* forceRestart */, - statsToken); + if (visible || mImeShowing) { + // only start the animation if we're either already showing or becoming visible. + // otherwise starting another hide animation causes flickers. + startAnimation(mImeRequestedVisible /* show */, false /* forceRestart */, + statsToken); + } // In case of a hide, the statsToken should not been send yet (as the animation // is still ongoing). It will be sent at the end of the animation @@ -723,6 +727,10 @@ public class DisplayImeController implements DisplayController.OnDisplaysChanged * Allows other things to synchronize with the ime position */ public interface ImePositionProcessor { + + /** Default animation flags. */ + int IME_ANIMATION_DEFAULT = 0; + /** * Indicates that ime shouldn't animate alpha. It will always be opaque. Used when stuff * behind the IME shouldn't be visible (for example during split-screen adjustment where @@ -732,6 +740,7 @@ public class DisplayImeController implements DisplayController.OnDisplaysChanged /** @hide */ @IntDef(prefix = {"IME_ANIMATION_"}, value = { + IME_ANIMATION_DEFAULT, IME_ANIMATION_NO_ALPHA, }) @interface ImeAnimationFlags { @@ -758,7 +767,7 @@ public class DisplayImeController implements DisplayController.OnDisplaysChanged @ImeAnimationFlags default int onImeStartPositioning(int displayId, int hiddenTop, int shownTop, boolean showing, boolean isFloating, SurfaceControl.Transaction t) { - return 0; + return IME_ANIMATION_DEFAULT; } /** diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/common/ImeListener.kt b/libs/WindowManager/Shell/src/com/android/wm/shell/common/ImeListener.kt index a34d7bed497b..8851b6a2107d 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/common/ImeListener.kt +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/common/ImeListener.kt @@ -22,8 +22,8 @@ import android.view.InsetsState import com.android.wm.shell.common.DisplayInsetsController.OnInsetsChangedListener abstract class ImeListener( - private val mDisplayController: DisplayController, - private val mDisplayId: Int + private val displayController: DisplayController, + val displayId: Int ) : OnInsetsChangedListener { // The last insets state private val mInsetsState = InsetsState() @@ -36,17 +36,11 @@ abstract class ImeListener( // Get the stable bounds that account for display cutout and system bars to calculate the // relative IME height - val layout = mDisplayController.getDisplayLayout(mDisplayId) - if (layout == null) { - return - } + val layout = displayController.getDisplayLayout(displayId) ?: return layout.getStableBounds(mTmpBounds) - val wasVisible = getImeVisibilityAndHeight(mInsetsState).first - val oldHeight = getImeVisibilityAndHeight(mInsetsState).second - - val isVisible = getImeVisibilityAndHeight(insetsState).first - val newHeight = getImeVisibilityAndHeight(insetsState).second + val (wasVisible, oldHeight) = getImeVisibilityAndHeight(mInsetsState) + val (isVisible, newHeight) = getImeVisibilityAndHeight(insetsState) mInsetsState.set(insetsState, true) if (wasVisible != isVisible || oldHeight != newHeight) { @@ -54,8 +48,7 @@ abstract class ImeListener( } } - private fun getImeVisibilityAndHeight( - insetsState: InsetsState): Pair<Boolean, Int> { + private fun getImeVisibilityAndHeight(insetsState: InsetsState): Pair<Boolean, Int> { val source = insetsState.peekSource(InsetsSource.ID_IME) val frame = if (source != null && source.isVisible) source.frame else null val height = if (frame != null) mTmpBounds.bottom - frame.top else 0 diff --git a/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellModule.java b/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellModule.java index d02c6b05e5b6..d8c7f4cbb698 100644 --- a/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellModule.java +++ b/libs/WindowManager/Shell/src/com/android/wm/shell/dagger/WMShellModule.java @@ -47,7 +47,6 @@ import com.android.launcher3.icons.IconProvider; import com.android.window.flags.Flags; import com.android.wm.shell.RootTaskDisplayAreaOrganizer; import com.android.wm.shell.ShellTaskOrganizer; -import com.android.wm.shell.WindowManagerShellWrapper; import com.android.wm.shell.activityembedding.ActivityEmbeddingController; import com.android.wm.shell.apptoweb.AppToWebGenericLinksParser; import com.android.wm.shell.apptoweb.AssistContentRequester; @@ -233,7 +232,8 @@ public abstract class WMShellModule { FloatingContentCoordinator floatingContentCoordinator, IStatusBarService statusBarService, WindowManager windowManager, - WindowManagerShellWrapper windowManagerShellWrapper, + DisplayInsetsController displayInsetsController, + DisplayImeController displayImeController, UserManager userManager, LauncherApps launcherApps, TaskStackListenerImpl taskStackListener, @@ -265,7 +265,8 @@ public abstract class WMShellModule { new BubblePersistentRepository(context)), statusBarService, windowManager, - windowManagerShellWrapper, + displayInsetsController, + displayImeController, userManager, launcherApps, logger, diff --git a/libs/WindowManager/Shell/tests/e2e/desktopmode/scenarios/src/com/android/wm/shell/functional/EnterDesktopViaMenuOfStaticOverviewTaskTest.kt b/libs/WindowManager/Shell/tests/e2e/desktopmode/scenarios/src/com/android/wm/shell/functional/EnterDesktopViaMenuOfStaticOverviewTaskTest.kt new file mode 100644 index 000000000000..fac749be40f1 --- /dev/null +++ b/libs/WindowManager/Shell/tests/e2e/desktopmode/scenarios/src/com/android/wm/shell/functional/EnterDesktopViaMenuOfStaticOverviewTaskTest.kt @@ -0,0 +1,27 @@ +/* + * Copyright (C) 2024 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.wm.shell.functional + +import android.platform.test.annotations.Postsubmit +import com.android.wm.shell.scenarios.EnterDesktopViaMenuOfStaticOverviewTask +import org.junit.runner.RunWith +import org.junit.runners.BlockJUnit4ClassRunner + +/* Functional test for [EnterDesktopViaMenuOfStaticOverviewTask]. */ +@RunWith(BlockJUnit4ClassRunner::class) +@Postsubmit +class EnterDesktopViaMenuOfStaticOverviewTaskTest : EnterDesktopViaMenuOfStaticOverviewTask() diff --git a/libs/WindowManager/Shell/tests/e2e/desktopmode/scenarios/src/com/android/wm/shell/scenarios/EnterDesktopViaMenuOfStaticOverviewTask.kt b/libs/WindowManager/Shell/tests/e2e/desktopmode/scenarios/src/com/android/wm/shell/scenarios/EnterDesktopViaMenuOfStaticOverviewTask.kt new file mode 100644 index 000000000000..57647d6aa5df --- /dev/null +++ b/libs/WindowManager/Shell/tests/e2e/desktopmode/scenarios/src/com/android/wm/shell/scenarios/EnterDesktopViaMenuOfStaticOverviewTask.kt @@ -0,0 +1,70 @@ +/* + * Copyright (C) 2024 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.wm.shell.scenarios + +import android.app.Instrumentation +import android.tools.NavBar +import android.tools.Rotation +import android.tools.traces.parsers.WindowManagerStateHelper +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import com.android.launcher3.tapl.LauncherInstrumentation +import com.android.server.wm.flicker.helpers.MailAppHelper +import com.android.window.flags.Flags +import com.android.wm.shell.Utils +import org.junit.After +import org.junit.Assume +import org.junit.Before +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test + +@Ignore("Base Test Class") +abstract class EnterDesktopViaMenuOfStaticOverviewTask constructor() { + + private val instrumentation: Instrumentation = InstrumentationRegistry.getInstrumentation() + private val tapl = LauncherInstrumentation() + private val wmHelper = WindowManagerStateHelper(instrumentation) + private val device = UiDevice.getInstance(instrumentation) + private val mailApp = MailAppHelper(instrumentation) + + @Rule + @JvmField + val testSetupRule = Utils.testSetupRule(NavBar.MODE_GESTURAL, Rotation.ROTATION_0) + + @Before + fun setup() { + Assume.assumeTrue(Flags.enableDesktopWindowingMode() && tapl.isTablet) + // Clear all tasks + val overview = tapl.goHome().switchToOverview() + if (overview.hasTasks()) { + overview.dismissAllTasks() + } + mailApp.open() + tapl.goHome().switchToOverview() + } + + @Test + open fun desktopViaMenuOfStaticOverviewTask() { + tapl.overview.getCurrentTask().tapMenu().tapDesktopMenuItem() + } + + @After + fun teardown() { + mailApp.exit() + } +} diff --git a/libs/WindowManager/Shell/tests/flicker/pip/Android.bp b/libs/WindowManager/Shell/tests/flicker/pip/Android.bp index f40edaebec5e..6e0dcdb21fa8 100644 --- a/libs/WindowManager/Shell/tests/flicker/pip/Android.bp +++ b/libs/WindowManager/Shell/tests/flicker/pip/Android.bp @@ -115,6 +115,11 @@ test_module_config { "com.android.wm.shell.flicker.pip.PipPinchInTest", "com.android.wm.shell.flicker.pip.SetRequestedOrientationWhilePinned", "com.android.wm.shell.flicker.pip.ShowPipAndRotateDisplay", + "com.android.wm.shell.flicker.pip.nonmatchparent.BottomHalfAutoEnterPipOnGoToHomeTest", + "com.android.wm.shell.flicker.pip.nonmatchparent.BottomHalfEnterPipOnUserLeaveHintTest", + "com.android.wm.shell.flicker.pip.nonmatchparent.BottomHalfEnterPipViaAppUiButtonTest", + "com.android.wm.shell.flicker.pip.nonmatchparent.BottomHalfExitPipToAppViaExpandButtonTest", + "com.android.wm.shell.flicker.pip.nonmatchparent.BottomHalfExitPipToAppViaIntentTest", ], test_suites: ["device-tests"], } @@ -266,12 +271,7 @@ test_module_config { test_suites: ["device-tests"], } -test_module_config { - name: "WMShellFlickerTestsPip-nonMatchParent", - base: "WMShellFlickerTestsPip", - include_filters: ["com.android.wm.shell.flicker.pip.nonmatchparent.*"], - test_suites: ["device-tests"], -} +// Not-match Parent test cases test_module_config { name: "WMShellFlickerTestsPip-BottomHalfExitPipToAppViaExpandButtonTest", @@ -287,5 +287,26 @@ test_module_config { test_suites: ["device-tests"], } +test_module_config { + name: "WMShellFlickerTestsPip-BottomHalfAutoEnterPipOnGoToHomeTest", + base: "WMShellFlickerTestsPip", + include_filters: ["com.android.wm.shell.flicker.pip.nonmatchparent.BottomHalfAutoEnterPipOnGoToHomeTest"], + test_suites: ["device-tests"], +} + +test_module_config { + name: "WMShellFlickerTestsPip-BottomHalfEnterPipOnUserLeaveHintTest", + base: "WMShellFlickerTestsPip", + include_filters: ["com.android.wm.shell.flicker.pip.nonmatchparent.BottomHalfEnterPipOnUserLeaveHintTest"], + test_suites: ["device-tests"], +} + +test_module_config { + name: "WMShellFlickerTestsPip-BottomHalfEnterPipViaAppUiButtonTest", + base: "WMShellFlickerTestsPip", + include_filters: ["com.android.wm.shell.flicker.pip.nonmatchparent.BottomHalfEnterPipViaAppUiButtonTest"], + test_suites: ["device-tests"], +} + // End breakdowns for WMShellFlickerTestsPip module //////////////////////////////////////////////////////////////////////////////// diff --git a/libs/WindowManager/Shell/tests/flicker/pip/src/com/android/wm/shell/flicker/pip/AutoEnterPipOnGoToHomeTest.kt b/libs/WindowManager/Shell/tests/flicker/pip/src/com/android/wm/shell/flicker/pip/AutoEnterPipOnGoToHomeTest.kt index 609a2849f915..84d53d59e7df 100644 --- a/libs/WindowManager/Shell/tests/flicker/pip/src/com/android/wm/shell/flicker/pip/AutoEnterPipOnGoToHomeTest.kt +++ b/libs/WindowManager/Shell/tests/flicker/pip/src/com/android/wm/shell/flicker/pip/AutoEnterPipOnGoToHomeTest.kt @@ -24,20 +24,16 @@ import android.platform.test.annotations.RequiresFlagsDisabled import android.tools.flicker.junit.FlickerParametersRunnerFactory import android.tools.flicker.legacy.FlickerBuilder import android.tools.flicker.legacy.LegacyFlickerTest -import android.tools.flicker.subject.exceptions.ExceptionMessageBuilder -import android.tools.flicker.subject.exceptions.IncorrectRegionException -import android.tools.flicker.subject.layers.LayerSubject import com.android.server.wm.flicker.helpers.PipAppHelper import com.android.wm.shell.Flags import com.android.wm.shell.flicker.pip.common.EnterPipTransition +import com.android.wm.shell.flicker.pip.common.widthNotSmallerThan import org.junit.Assume import org.junit.FixMethodOrder import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.MethodSorters import org.junit.runners.Parameterized -import kotlin.math.abs - /** * Test entering pip from an app via auto-enter property when navigating to home. @@ -77,22 +73,6 @@ open class AutoEnterPipOnGoToHomeTest(flicker: LegacyFlickerTest) : EnterPipTran } } - override val defaultTeardown: FlickerBuilder.() -> Unit = { teardown { pipApp.exit(wmHelper) } } - - private val widthNotSmallerThan: LayerSubject.(LayerSubject) -> Unit = { - val width = visibleRegion.region.bounds.width() - val otherWidth = it.visibleRegion.region.bounds.width() - if (width < otherWidth && abs(width - otherWidth) > EPSILON) { - val errorMsgBuilder = - ExceptionMessageBuilder() - .forSubject(this) - .forIncorrectRegion("width. $width smaller than $otherWidth") - .setExpected(width) - .setActual(otherWidth) - throw IncorrectRegionException(errorMsgBuilder) - } - } - @Postsubmit @Test override fun pipLayerReduces() { @@ -161,9 +141,4 @@ open class AutoEnterPipOnGoToHomeTest(flicker: LegacyFlickerTest) : EnterPipTran override fun visibleLayersShownMoreThanOneConsecutiveEntry() { super.visibleLayersShownMoreThanOneConsecutiveEntry() } - - companion object { - // TODO(b/363080056): A margin of error allowed on certain layer size calculations. - const val EPSILON = 1 - } } diff --git a/libs/WindowManager/Shell/tests/flicker/pip/src/com/android/wm/shell/flicker/pip/EnterPipOnUserLeaveHintTest.kt b/libs/WindowManager/Shell/tests/flicker/pip/src/com/android/wm/shell/flicker/pip/EnterPipOnUserLeaveHintTest.kt index 4399a237bcbb..38f37b4bc1d2 100644 --- a/libs/WindowManager/Shell/tests/flicker/pip/src/com/android/wm/shell/flicker/pip/EnterPipOnUserLeaveHintTest.kt +++ b/libs/WindowManager/Shell/tests/flicker/pip/src/com/android/wm/shell/flicker/pip/EnterPipOnUserLeaveHintTest.kt @@ -88,6 +88,7 @@ class EnterPipOnUserLeaveHintTest(flicker: LegacyFlickerTest) : EnterPipTransiti super.pipOverlayLayerAppearThenDisappear() } + // TODO(b/385086051): check if we can remove optional = true in the test. @Presubmit @Test fun pipAppWindowVisibleChanges() { diff --git a/libs/WindowManager/Shell/tests/flicker/pip/src/com/android/wm/shell/flicker/pip/common/PipUtils.kt b/libs/WindowManager/Shell/tests/flicker/pip/src/com/android/wm/shell/flicker/pip/common/PipUtils.kt new file mode 100644 index 000000000000..3b98d9b99130 --- /dev/null +++ b/libs/WindowManager/Shell/tests/flicker/pip/src/com/android/wm/shell/flicker/pip/common/PipUtils.kt @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2024 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.wm.shell.flicker.pip.common + +import android.tools.flicker.subject.exceptions.ExceptionMessageBuilder +import android.tools.flicker.subject.exceptions.IncorrectRegionException +import android.tools.flicker.subject.layers.LayerSubject +import kotlin.math.abs + +// TODO(b/363080056): A margin of error allowed on certain layer size calculations. +const val EPSILON = 1 + +internal val widthNotSmallerThan: LayerSubject.(LayerSubject) -> Unit = { + val width = visibleRegion.region.bounds.width() + val otherWidth = it.visibleRegion.region.bounds.width() + if (width < otherWidth && abs(width - otherWidth) > EPSILON) { + val errorMsgBuilder = + ExceptionMessageBuilder() + .forSubject(this) + .forIncorrectRegion("width. $width smaller than $otherWidth") + .setExpected(width) + .setActual(otherWidth) + throw IncorrectRegionException(errorMsgBuilder) + } +}
\ No newline at end of file diff --git a/libs/WindowManager/Shell/tests/flicker/pip/src/com/android/wm/shell/flicker/pip/nonmatchparent/BottomHalfAutoEnterPipOnGoToHomeTest.kt b/libs/WindowManager/Shell/tests/flicker/pip/src/com/android/wm/shell/flicker/pip/nonmatchparent/BottomHalfAutoEnterPipOnGoToHomeTest.kt new file mode 100644 index 000000000000..89f02266ce35 --- /dev/null +++ b/libs/WindowManager/Shell/tests/flicker/pip/src/com/android/wm/shell/flicker/pip/nonmatchparent/BottomHalfAutoEnterPipOnGoToHomeTest.kt @@ -0,0 +1,170 @@ +/* + * Copyright (C) 2024 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.wm.shell.flicker.pip.nonmatchparent + +import android.platform.test.annotations.Postsubmit +import android.platform.test.annotations.Presubmit +import android.platform.test.annotations.RequiresDevice +import android.platform.test.annotations.RequiresFlagsDisabled +import android.platform.test.annotations.RequiresFlagsEnabled +import android.tools.flicker.junit.FlickerParametersRunnerFactory +import android.tools.flicker.legacy.FlickerBuilder +import android.tools.flicker.legacy.LegacyFlickerTest +import androidx.test.filters.FlakyTest +import com.android.window.flags.Flags +import com.android.wm.shell.flicker.pip.common.widthNotSmallerThan +import org.junit.Assume +import org.junit.FixMethodOrder +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.MethodSorters +import org.junit.runners.Parameterized + +/** + * Test entering pip from an app with non-match parent layout via auto-enter property when + * navigating to home. + * + * To run this test: `atest WMShellFlickerTestsPip:BottomHalfAutoEnterPipOnGoToHomeTest` + * + * Actions: + * ``` + * Launch [BottomHalfPipLaunchingActivity] in full screen + * Launch [BottomHalfPipActivity] with bottom half layout + * Select "Auto-enter PiP" radio button + * Press Home button or swipe up to go Home and put [BottomHalfPipActivity] in pip mode + * ``` + * + * Notes: + * ``` + * 1. All assertions are inherited from [EnterPipTest] + * 2. Part of the test setup occurs automatically via + * [android.tools.flicker.legacy.runner.TransitionRunner], + * including configuring navigation mode, initial orientation and ensuring no + * apps are running before setup + * ``` + */ +// TODO(b/380796448): re-enable tests after the support of non-match parent PIP animation for PIP2. +@RequiresFlagsDisabled(com.android.wm.shell.Flags.FLAG_ENABLE_PIP2) +@RequiresFlagsEnabled(Flags.FLAG_BETTER_SUPPORT_NON_MATCH_PARENT_ACTIVITY) +@RequiresDevice +@RunWith(Parameterized::class) +@Parameterized.UseParametersRunnerFactory(FlickerParametersRunnerFactory::class) +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +class BottomHalfAutoEnterPipOnGoToHomeTest(flicker: LegacyFlickerTest) : + BottomHalfEnterPipTransition(flicker) { + + override val thisTransition: FlickerBuilder.() -> Unit = { transitions { + tapl.goHome() + pipApp.waitForPip(wmHelper) + } } + + override val defaultEnterPip: FlickerBuilder.() -> Unit = { + setup { + pipApp.launchViaIntent(wmHelper) + pipApp.enableAutoEnterForPipActivity() + } + } + + @FlakyTest(bugId = 289943985) + @Test + override fun visibleLayersShownMoreThanOneConsecutiveEntry() { + super.visibleLayersShownMoreThanOneConsecutiveEntry() + } + + /* Gestural Navigation */ + + /** + * Checks that [pipApp] window's width is first decreasing then increasing. + * + * In gestural navigation mode, auto entering PiP can initially make the layer smaller before it + * gets larger. + * This tests verifies the width of the PiP layer first decreases and then increases due to + * size and scale animations going to different directions. + * + * Note that we still allow a margin of error of 1px, since around the time + * of handoff between gesture nav task view simulator and + * SwipePipToHomeAnimator, crop can get a bit smaller and scale can get a + * bit larger if swiped aggressively - this can produce off-by-1 errors for + * width too. + */ + @Postsubmit + @Test + fun pipLayerWidthDecreasesThenIncreases() { + Assume.assumeTrue(flicker.scenario.isGesturalNavigation) + flicker.assertLayers { + val pipLayerList = this.layers { pipApp.layerMatchesAnyOf(it) && it.isVisible } + var previousLayer = pipLayerList[0] + var currentLayer = previousLayer + var i = 0 + invoke("layer area is decreasing") { + if (i < pipLayerList.size - 1) { + previousLayer = currentLayer + currentLayer = pipLayerList[++i] + previousLayer.widthNotSmallerThan(currentLayer) + } + }.then().invoke("layer are is increasing", true /* isOptional */) { + if (i < pipLayerList.size - 1) { + previousLayer = currentLayer + currentLayer = pipLayerList[++i] + currentLayer.widthNotSmallerThan(previousLayer) + } + } + } + } + + /* 3-button Navigation */ + + /** + * The PIP layer reduces continuously in 3-Button navigation mode. + */ + @Postsubmit + @Test + override fun pipLayerReduces() { + Assume.assumeFalse(flicker.scenario.isGesturalNavigation) + flicker.assertLayers { + val pipLayerList = this.layers { pipApp.layerMatchesAnyOf(it) && it.isVisible } + pipLayerList.zipWithNext { previous, current -> + current.visibleRegion.notBiggerThan(previous.visibleRegion.region) + } + } + } + + /** Checks that [pipApp] window is animated towards default position in right bottom corner */ + @FlakyTest(bugId = 255578530) + @Test + fun pipLayerMovesTowardsRightBottomCorner() { + // in gestural nav the swipe makes PiP first go upwards + Assume.assumeFalse(flicker.scenario.isGesturalNavigation) + flicker.assertLayers { + val pipLayerList = this.layers { pipApp.layerMatchesAnyOf(it) && it.isVisible } + // Pip animates towards the right bottom corner, but because it is being resized at the + // same time, it is possible it shrinks first quickly below the default position and get + // moved up after that in just few last frames + pipLayerList.zipWithNext { previous, current -> + current.visibleRegion.isToTheRightBottom(previous.visibleRegion.region, 3) + } + } + } + + @Presubmit + @Test + override fun focusChanges() { + // in gestural nav the focus goes to different activity on swipe up + Assume.assumeFalse(flicker.scenario.isGesturalNavigation) + super.focusChanges() + } +} diff --git a/libs/WindowManager/Shell/tests/flicker/pip/src/com/android/wm/shell/flicker/pip/nonmatchparent/BottomHalfEnterPipOnUserLeaveHintTest.kt b/libs/WindowManager/Shell/tests/flicker/pip/src/com/android/wm/shell/flicker/pip/nonmatchparent/BottomHalfEnterPipOnUserLeaveHintTest.kt new file mode 100644 index 000000000000..8642b6c779e4 --- /dev/null +++ b/libs/WindowManager/Shell/tests/flicker/pip/src/com/android/wm/shell/flicker/pip/nonmatchparent/BottomHalfEnterPipOnUserLeaveHintTest.kt @@ -0,0 +1,168 @@ +/* + * Copyright (C) 2024 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.wm.shell.flicker.pip.nonmatchparent + +import android.platform.test.annotations.Presubmit +import android.platform.test.annotations.RequiresDevice +import android.platform.test.annotations.RequiresFlagsDisabled +import android.platform.test.annotations.RequiresFlagsEnabled +import android.tools.flicker.junit.FlickerParametersRunnerFactory +import android.tools.flicker.legacy.FlickerBuilder +import android.tools.flicker.legacy.LegacyFlickerTest +import com.android.window.flags.Flags +import org.junit.Assume +import org.junit.FixMethodOrder +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.MethodSorters +import org.junit.runners.Parameterized + +/** + * Test entering pip from an app with bottom half layout via [onUserLeaveHint] and by navigating to + * home. + * + * To run this test: `atest WMShellFlickerTestsPip:BottomHalfEnterPipOnUserLeaveHintTest` + * + * Actions: + * ``` + * Launch [BottomHalfPipLaunchingActivity] in full screen + * Launch [BottomHalfPipActivity] with bottom half layout + * Select "Via code behind" radio button + * Press Home button or swipe up to go Home and put [pipApp] in pip mode + * ``` + * Notes: + * ``` + * 1. All assertions are inherited from [EnterPipTest] + * 2. Part of the test setup occurs automatically via + * [android.tools.flicker.legacy.runner.TransitionRunner], + * including configuring navigation mode, initial orientation and ensuring no + * apps are running before setup + * ``` + */ +// TODO(b/380796448): re-enable tests after the support of non-match parent PIP animation for PIP2. +@RequiresFlagsDisabled(com.android.wm.shell.Flags.FLAG_ENABLE_PIP2) +@RequiresFlagsEnabled(Flags.FLAG_BETTER_SUPPORT_NON_MATCH_PARENT_ACTIVITY) +@RequiresDevice +@RunWith(Parameterized::class) +@Parameterized.UseParametersRunnerFactory(FlickerParametersRunnerFactory::class) +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +class BottomHalfEnterPipOnUserLeaveHintTest(flicker: LegacyFlickerTest) : + BottomHalfEnterPipTransition(flicker) +{ + override val thisTransition: FlickerBuilder.() -> Unit = { transitions { + tapl.goHome() + pipApp.waitForPip(wmHelper) + } } + + override val defaultEnterPip: FlickerBuilder.() -> Unit = { + setup { + pipApp.launchViaIntent(wmHelper) + pipApp.enableEnterPipOnUserLeaveHint() + } + } + + // gestural navigation + + // TODO(b/385086051): check if we can remove optional = true in the test. + @Presubmit + @Test + fun pipAppWindowVisibleChanges() { + // pip layer in gesture nav will disappear during transition + Assume.assumeTrue(flicker.scenario.isGesturalNavigation) + flicker.assertWm { + this.isAppWindowVisible(pipApp) + .then() + .isAppWindowInvisible(pipApp, isOptional = true) + .then() + .isAppWindowVisible(pipApp, isOptional = true) + } + } + + @Presubmit + @Test + fun pipAppLayerVisibleChanges() { + Assume.assumeTrue(flicker.scenario.isGesturalNavigation) + // pip layer in gesture nav will disappear during transition + flicker.assertLayers { + this.isVisible(pipApp).then().isInvisible(pipApp).then().isVisible(pipApp) + } + } + + @Presubmit + @Test + fun pipLayerRemainInsideVisibleBounds() { + // pip layer in gesture nav will disappear during transition + Assume.assumeTrue(flicker.scenario.isGesturalNavigation) + // pip layer in gesture nav will disappear during transition + flicker.assertLayersStart { this.visibleRegion(pipApp).coversAtMost(displayBounds) } + flicker.assertLayersEnd { this.visibleRegion(pipApp).coversAtMost(displayBounds) } + } + + // 3-button navigation + + @Presubmit + @Test + override fun pipAppWindowAlwaysVisible() { + // In gestural nav the pip will first move behind home and then above home. The visual + // appearance visible->invisible->visible is asserted by pipAppLayerAlwaysVisible(). + // But the internal states of activity don't need to follow that, such as a temporary + // visibility state can be changed quickly outside a transaction so the test doesn't + // detect that. Hence, skip the case to avoid restricting the internal implementation. + Assume.assumeFalse(flicker.scenario.isGesturalNavigation) + super.pipAppWindowAlwaysVisible() + } + + @Presubmit + @Test + override fun pipAppLayerAlwaysVisible() { + // pip layer in gesture nav will disappear during transition + Assume.assumeFalse(flicker.scenario.isGesturalNavigation) + super.pipAppLayerAlwaysVisible() + } + + @Presubmit + @Test + override fun pipOverlayLayerAppearThenDisappear() { + // no overlay in gesture nav for non-auto enter PiP transition + Assume.assumeFalse(flicker.scenario.isGesturalNavigation) + super.pipOverlayLayerAppearThenDisappear() + } + + @Presubmit + @Test + override fun pipLayerReduces() { + // in gestural nav the pip enters through alpha animation + Assume.assumeFalse(flicker.scenario.isGesturalNavigation) + super.pipLayerReduces() + } + + @Presubmit + @Test + override fun focusChanges() { + // in gestural nav the focus goes to different activity on swipe up + Assume.assumeFalse(flicker.scenario.isGesturalNavigation) + super.focusChanges() + } + + @Presubmit + @Test + override fun pipLayerOrOverlayRemainInsideVisibleBounds() { + // pip layer in gesture nav will disappear during transition + Assume.assumeFalse(flicker.scenario.isGesturalNavigation) + super.pipLayerOrOverlayRemainInsideVisibleBounds() + } +}
\ No newline at end of file diff --git a/libs/WindowManager/Shell/tests/flicker/pip/src/com/android/wm/shell/flicker/pip/nonmatchparent/BottomHalfEnterPipTransition.kt b/libs/WindowManager/Shell/tests/flicker/pip/src/com/android/wm/shell/flicker/pip/nonmatchparent/BottomHalfEnterPipTransition.kt new file mode 100644 index 000000000000..a455de96b25d --- /dev/null +++ b/libs/WindowManager/Shell/tests/flicker/pip/src/com/android/wm/shell/flicker/pip/nonmatchparent/BottomHalfEnterPipTransition.kt @@ -0,0 +1,46 @@ +/* + * Copyright (C) 2024 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.wm.shell.flicker.pip.nonmatchparent + +import android.platform.test.annotations.Presubmit +import android.tools.flicker.legacy.LegacyFlickerTest +import com.android.server.wm.flicker.helpers.BottomHalfPipAppHelper +import com.android.server.wm.flicker.helpers.PipAppHelper +import com.android.wm.shell.flicker.pip.common.EnterPipTransition +import org.junit.Test + +/** + * The base class to test enter PIP animation on bottom half activity. + */ +abstract class BottomHalfEnterPipTransition(flicker: LegacyFlickerTest) : + EnterPipTransition(flicker) +{ + override val pipApp: PipAppHelper = BottomHalfPipAppHelper( + instrumentation, + useLaunchingActivity = true + ) + + @Presubmit + @Test + override fun pipWindowRemainInsideVisibleBounds() { + // We only verify the start and end because we update the layout when + // the BottomHalfPipActivity goes to PIP mode, which may lead to intermediate state that + // the window frame may be out of the display frame. + flicker.assertWmStart { visibleRegion(pipApp).coversAtMost(displayBounds) } + flicker.assertLayersEnd { visibleRegion(pipApp).coversAtMost(displayBounds) } + } +}
\ No newline at end of file diff --git a/libs/WindowManager/Shell/tests/flicker/pip/src/com/android/wm/shell/flicker/pip/nonmatchparent/BottomHalfEnterPipViaAppUiButtonTest.kt b/libs/WindowManager/Shell/tests/flicker/pip/src/com/android/wm/shell/flicker/pip/nonmatchparent/BottomHalfEnterPipViaAppUiButtonTest.kt new file mode 100644 index 000000000000..cfdd4e550504 --- /dev/null +++ b/libs/WindowManager/Shell/tests/flicker/pip/src/com/android/wm/shell/flicker/pip/nonmatchparent/BottomHalfEnterPipViaAppUiButtonTest.kt @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2024 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.wm.shell.flicker.pip.nonmatchparent + +import android.platform.test.annotations.Presubmit +import android.platform.test.annotations.RequiresDevice +import android.platform.test.annotations.RequiresFlagsDisabled +import android.platform.test.annotations.RequiresFlagsEnabled +import android.tools.flicker.junit.FlickerParametersRunnerFactory +import android.tools.flicker.legacy.FlickerBuilder +import android.tools.flicker.legacy.LegacyFlickerTest +import android.tools.traces.parsers.toFlickerComponent +import com.android.server.wm.flicker.testapp.ActivityOptions.BottomHalfPip.LAUNCHING_APP_COMPONENT +import com.android.window.flags.Flags +import org.junit.FixMethodOrder +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.MethodSorters +import org.junit.runners.Parameterized + +/** + * Test entering pip from an app by interacting with the app UI + * + * To run this test: `atest WMShellFlickerTestsPip:BottomHalfEnterPipViaAppUiButtonTest` + * + * Actions: + * ``` + * Launch [BottomHalfPipLaunchingActivity] in full screen + * Launch [BottomHalfPipActivity] with bottom half layout + * Press an "enter pip" button to put [pipApp] in pip mode + * ``` + * + * Notes: + * ``` + * 1. Some default assertions (e.g., nav bar, status bar and screen covered) + * are inherited from [PipTransition] + * 2. Part of the test setup occurs automatically via + * [android.tools.flicker.legacy.runner.TransitionRunner], + * including configuring navigation mode, initial orientation and ensuring no + * apps are running before setup + * ``` + */ +// TODO(b/380796448): re-enable tests after the support of non-match parent PIP animation for PIP2. +@RequiresFlagsDisabled(com.android.wm.shell.Flags.FLAG_ENABLE_PIP2) +@RequiresFlagsEnabled(Flags.FLAG_BETTER_SUPPORT_NON_MATCH_PARENT_ACTIVITY) +@RequiresDevice +@RunWith(Parameterized::class) +@Parameterized.UseParametersRunnerFactory(FlickerParametersRunnerFactory::class) +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +class BottomHalfEnterPipViaAppUiButtonTest(flicker: LegacyFlickerTest) : + BottomHalfEnterPipTransition(flicker) +{ + override val thisTransition: FlickerBuilder.() -> Unit = { + transitions { pipApp.clickEnterPipButton(wmHelper) } + } + + /** + * Checks if the focus changes to the launching activity behind when the bottom half [pipApp] + * goes to PIP mode. + */ + @Presubmit + @Test + override fun focusChanges() { + flicker.assertEventLog { + this.focusChanges( + pipApp.packageName, + LAUNCHING_APP_COMPONENT.packageName + ) + } + } + + @Presubmit + @Test + override fun launcherLayerBecomesVisible() { + // Disable the test since the background activity is BottomHalfPipLaunchingActivity. + } + + /** + * Checks if the launching activity behind the bottom half [pipApp] is always visible during + * the transition. + */ + @Presubmit + @Test + fun launchingAppLayerAlwaysVisible() { + flicker.assertLayers { isVisible(LAUNCHING_APP_COMPONENT.toFlickerComponent()) } + } +}
\ No newline at end of file diff --git a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/BubbleViewInfoTest.kt b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/BubbleViewInfoTest.kt index 1f2eaa6757e8..eeb83df48ab5 100644 --- a/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/BubbleViewInfoTest.kt +++ b/libs/WindowManager/Shell/tests/unittest/src/com/android/wm/shell/bubbles/BubbleViewInfoTest.kt @@ -33,10 +33,10 @@ import com.android.launcher3.icons.BubbleIconFactory import com.android.wm.shell.ShellTaskOrganizer import com.android.wm.shell.ShellTestCase import com.android.wm.shell.TestShellExecutor -import com.android.wm.shell.WindowManagerShellWrapper import com.android.wm.shell.bubbles.bar.BubbleBarLayerView import com.android.wm.shell.bubbles.properties.BubbleProperties import com.android.wm.shell.common.DisplayController +import com.android.wm.shell.common.DisplayImeController import com.android.wm.shell.common.DisplayInsetsController import com.android.wm.shell.common.FloatingContentCoordinator import com.android.wm.shell.common.ShellExecutor @@ -123,7 +123,8 @@ class BubbleViewInfoTest : ShellTestCase() { mock<BubbleDataRepository>(), mock<IStatusBarService>(), windowManager, - WindowManagerShellWrapper(mainExecutor), + mock<DisplayInsetsController>(), + mock<DisplayImeController>(), mock<UserManager>(), mock<LauncherApps>(), bubbleLogger, diff --git a/packages/SettingsLib/Preference/testutils/com/android/settingslib/preference/PreferenceBindingTestUtils.kt b/packages/SettingsLib/Preference/testutils/com/android/settingslib/preference/PreferenceBindingTestUtils.kt index 00bad5203f07..220614bf064f 100644 --- a/packages/SettingsLib/Preference/testutils/com/android/settingslib/preference/PreferenceBindingTestUtils.kt +++ b/packages/SettingsLib/Preference/testutils/com/android/settingslib/preference/PreferenceBindingTestUtils.kt @@ -19,18 +19,27 @@ package com.android.settingslib.preference import android.content.Context import androidx.annotation.VisibleForTesting import androidx.preference.Preference +import androidx.preference.PreferenceScreen import com.android.settingslib.metadata.PersistentPreference import com.android.settingslib.metadata.PreferenceMetadata /** Creates [Preference] widget and binds with metadata. */ +@Suppress("UNCHECKED_CAST") @VisibleForTesting -fun <P : Preference> PreferenceMetadata.createAndBindWidget(context: Context): P { +fun <P : Preference> PreferenceMetadata.createAndBindWidget( + context: Context, + preferenceScreen: PreferenceScreen? = null, +): P { val binding = PreferenceBindingFactory.defaultFactory.getPreferenceBinding(this)!! return (binding.createWidget(context) as P).also { if (this is PersistentPreference<*>) { - storage(context)?.let { keyValueStore -> + storage(context).let { keyValueStore -> it.preferenceDataStore = PreferenceDataStoreAdapter(keyValueStore) } + // Attach preference to preference screen, otherwise `Preference.performClick` does not + // interact with underlying datastore + (preferenceScreen ?: PreferenceScreenFactory(context).getOrCreatePreferenceScreen()) + .addPreference(it) } binding.bind(it, this) } diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/Notifications.kt b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/Notifications.kt index 957fdf7bcd8f..58bfd08dfdbc 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/Notifications.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/Notifications.kt @@ -340,11 +340,12 @@ fun ContentScope.NotificationScrollingStack( // set the bounds to null when the scrim disappears DisposableEffect(Unit) { onDispose { viewModel.onScrimBoundsChanged(null) } } - val minScrimTop = with(density) { ShadeHeader.Dimensions.CollapsedHeight.toPx() } + // Top position if the scrim, when it is fully expanded. + val minScrimTop = ShadeHeader.Dimensions.CollapsedHeight // The minimum offset for the scrim. The scrim is considered fully expanded when it // is at this offset. - val minScrimOffset: () -> Float = { minScrimTop - maxScrimTop() } + val minScrimOffset: () -> Float = { with(density) { minScrimTop.toPx() } - maxScrimTop() } // The height of the scrim visible on screen when it is in its resting (collapsed) state. val minVisibleScrimHeight: () -> Float = { @@ -563,6 +564,7 @@ fun ContentScope.NotificationScrollingStack( } .thenIf(shouldShowScrim) { Modifier.background(scrimBackgroundColor) } .thenIf(shouldFillMaxSize) { Modifier.fillMaxSize() } + .thenIf(supportNestedScrolling) { Modifier.padding(bottom = minScrimTop) } .debugBackground(viewModel, DEBUG_BOX_COLOR) ) { Column( diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/shade/ui/composable/ShadeScene.kt b/packages/SystemUI/compose/features/src/com/android/systemui/shade/ui/composable/ShadeScene.kt index a53c6b29338f..aefe83b781e0 100644 --- a/packages/SystemUI/compose/features/src/com/android/systemui/shade/ui/composable/ShadeScene.kt +++ b/packages/SystemUI/compose/features/src/com/android/systemui/shade/ui/composable/ShadeScene.kt @@ -239,7 +239,7 @@ private fun SceneScope.ShadeScene( modifier = modifier, shadeSession = shadeSession, ) - is ShadeMode.Dual -> error("Dual shade is not yet implemented!") + is ShadeMode.Dual -> error("Dual shade is implemented separately as an overlay.") } } diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateSharedAsState.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateSharedAsState.kt index 8777ff924bc1..495fdafad26d 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateSharedAsState.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/AnimateSharedAsState.kt @@ -434,7 +434,8 @@ private class AnimatedStateImpl<T, Delta>( if (element != null) { layoutImpl.elements[element]?.let { element -> elementState( - layoutImpl.state.transitionStates, + listOf(layoutImpl.state.transitionStates), + elementKey = element.key, isInContent = { it in element.stateByContent }, ) as? TransitionState.Transition diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Element.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Element.kt index 16b4322411ac..b0d9fcd4344b 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Element.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/Element.kt @@ -45,7 +45,11 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.round +import androidx.compose.ui.util.fastAll +import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastCoerceIn +import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.util.fastForEachIndexed import androidx.compose.ui.util.fastForEachReversed import androidx.compose.ui.util.lerp import com.android.compose.animation.scene.content.Content @@ -92,7 +96,17 @@ internal class Element(val key: ElementKey) { /** The last and target state of this element in a given content. */ @Stable - class State(val content: ContentKey) { + class State( + /** + * A list of contents where this element state finds itself in. The last content is the + * content of the STL which is actually responsible to compose and place this element. The + * other contents (if any) are the ancestors. The ancestors do not actually place this + * element but the element is part of the ancestors scene as part of a NestedSTL. The state + * can be accessed by ancestor transitions to read the properties of this element to compute + * transformations. + */ + val contents: List<ContentKey> + ) { /** * The *target* state of this element in this content, i.e. the state of this element when * we are idle on this content. @@ -158,7 +172,8 @@ internal fun Modifier.element( // layout/drawing. // TODO(b/341072461): Revert this and read the current transitions in ElementNode directly once // we can ensure that SceneTransitionLayoutImpl will compose new contents first. - val currentTransitionStates = layoutImpl.state.transitionStates + val currentTransitionStates = getAllNestedTransitionStates(layoutImpl) + return thenIf(layoutImpl.state.isElevationPossible(content.key, key)) { Modifier.maybeElevateInContent(layoutImpl, content, key, currentTransitionStates) } @@ -166,11 +181,26 @@ internal fun Modifier.element( .testTag(key.testTag) } +/** + * Returns the transition states of all ancestors + the transition state of the current STL. The + * last element is the transition state of the local STL (the one with the highest nestingDepth). + * + * @return Each transition state of a STL is a List and this is a list of all the states. + */ +internal fun getAllNestedTransitionStates( + layoutImpl: SceneTransitionLayoutImpl +): List<List<TransitionState>> { + return buildList { + layoutImpl.ancestors.fastForEach { add(it.layoutImpl.state.transitionStates) } + add(layoutImpl.state.transitionStates) + } +} + private fun Modifier.maybeElevateInContent( layoutImpl: SceneTransitionLayoutImpl, content: Content, key: ElementKey, - transitionStates: List<TransitionState>, + transitionStates: List<List<TransitionState>>, ): Modifier { fun isSharedElement( stateByContent: Map<ContentKey, Element.State>, @@ -192,12 +222,12 @@ private fun Modifier.maybeElevateInContent( content.containerState, enabled = { val stateByContent = layoutImpl.elements.getValue(key).stateByContent - val state = elementState(transitionStates, isInContent = { it in stateByContent }) + val state = elementState(transitionStates, key, isInContent = { it in stateByContent }) state is TransitionState.Transition && state.transformationSpec .transformations(key, content.key) - .shared + ?.shared ?.transformation ?.elevateInContent == content.key && isSharedElement(stateByContent, state) && @@ -218,7 +248,7 @@ private fun Modifier.maybeElevateInContent( */ internal data class ElementModifier( internal val layoutImpl: SceneTransitionLayoutImpl, - private val currentTransitionStates: List<TransitionState>, + private val currentTransitionStates: List<List<TransitionState>>, internal val content: Content, internal val key: ElementKey, ) : ModifierNodeElement<ElementNode>() { @@ -232,7 +262,7 @@ internal data class ElementModifier( internal class ElementNode( private var layoutImpl: SceneTransitionLayoutImpl, - private var currentTransitionStates: List<TransitionState>, + private var currentTransitionStates: List<List<TransitionState>>, private var content: Content, private var key: ElementKey, ) : Modifier.Node(), DrawModifierNode, ApproachLayoutModifierNode, TraversableNode { @@ -257,10 +287,15 @@ internal class ElementNode( _element = element addToRenderAuthority(element) if (!element.stateByContent.contains(content.key)) { - val elementState = Element.State(content.key) + val contents = buildList { + layoutImpl.ancestors.fastForEach { add(it.inContent) } + add(content.key) + } + + val elementState = Element.State(contents) element.stateByContent[content.key] = elementState - layoutImpl.ancestorContentKeys.forEach { element.stateByContent[it] = elementState } + layoutImpl.ancestors.fastForEach { element.stateByContent[it.inContent] = elementState } } } @@ -273,7 +308,7 @@ internal class ElementNode( // this element was composed multiple times in the same content. val nCodeLocations = stateInContent.nodes.size if (nCodeLocations != 1 || !stateInContent.nodes.contains(this@ElementNode)) { - error("$key was composed $nCodeLocations times in ${stateInContent.content}") + error("$key was composed $nCodeLocations times in ${stateInContent.contents}") } } } @@ -288,12 +323,12 @@ internal class ElementNode( } private fun addToRenderAuthority(element: Element) { - val nestingDepth = layoutImpl.ancestorContentKeys.size + val nestingDepth = layoutImpl.ancestors.size element.renderAuthority[nestingDepth] = content.key } private fun removeFromRenderAuthority() { - val nestingDepth = layoutImpl.ancestorContentKeys.size + val nestingDepth = layoutImpl.ancestors.size if (element.renderAuthority[nestingDepth] == content.key) { element.renderAuthority.remove(nestingDepth) } @@ -305,7 +340,7 @@ internal class ElementNode( fun update( layoutImpl: SceneTransitionLayoutImpl, - currentTransitionStates: List<TransitionState>, + currentTransitionStates: List<List<TransitionState>>, content: Content, key: ElementKey, ) { @@ -326,7 +361,7 @@ internal class ElementNode( override fun isMeasurementApproachInProgress(lookaheadSize: IntSize): Boolean { // TODO(b/324191441): Investigate whether making this check more complex (checking if this // element is shared or transformed) would lead to better performance. - return layoutImpl.state.isTransitioning() + return isAnyStateTransitioning() } override fun Placeable.PlacementScope.isPlacementApproachInProgress( @@ -334,7 +369,12 @@ internal class ElementNode( ): Boolean { // TODO(b/324191441): Investigate whether making this check more complex (checking if this // element is shared or transformed) would lead to better performance. - return layoutImpl.state.isTransitioning() + return isAnyStateTransitioning() + } + + private fun isAnyStateTransitioning(): Boolean { + return layoutImpl.state.isTransitioning() || + layoutImpl.ancestors.fastAny { it.layoutImpl.state.isTransitioning() } } @ExperimentalComposeUiApi @@ -372,7 +412,7 @@ internal class ElementNode( // This is the case if for example a transition between two overlays is ongoing where // sharedElement isn't part of either but the element is still rendered as part of // the underlying scene that is currently not being transitioned. - val currentState = currentTransitionStates.last() + val currentState = currentTransitionStates.last().last() val shouldPlaceInThisContent = elementContentWhenIdle( layoutImpl, @@ -448,7 +488,7 @@ internal class ElementNode( element, transition, contentValue = { it.targetOffset }, - transformation = { it.offset }, + transformation = { it?.offset }, currentValue = { currentOffset }, isSpecified = { it != Offset.Unspecified }, ::lerp, @@ -592,8 +632,7 @@ internal class ElementNode( } } - pruneForContent(stateInContent.content) - layoutImpl.ancestorContentKeys.forEach { content -> pruneForContent(content) } + stateInContent.contents.fastForEach { pruneForContent(it) } } } } @@ -602,9 +641,10 @@ internal class ElementNode( private fun elementState( layoutImpl: SceneTransitionLayoutImpl, element: Element, - transitionStates: List<TransitionState>, + transitionStates: List<List<TransitionState>>, ): TransitionState? { - val state = elementState(transitionStates, isInContent = { it in element.stateByContent }) + val state = + elementState(transitionStates, element.key, isInContent = { it in element.stateByContent }) val transition = state as? TransitionState.Transition val previousTransition = element.lastTransition @@ -625,23 +665,48 @@ private fun elementState( } internal inline fun elementState( - transitionStates: List<TransitionState>, + transitionStates: List<List<TransitionState>>, + elementKey: ElementKey, isInContent: (ContentKey) -> Boolean, ): TransitionState? { - val lastState = transitionStates.last() - if (lastState is TransitionState.Idle) { - check(transitionStates.size == 1) - return lastState - } + // transitionStates is a list of all ancestor transition states + transitionState of the local + // STL. By traversing the list in normal order we by default prioritize the transitionState of + // the highest ancestor if it is running and has a transformation for this element. + transitionStates.fastForEachIndexed { index, states -> + if (index < transitionStates.size - 1) { + // Check if any ancestor runs a transition that has a transformation for the element + states.fastForEachReversed { state -> + if ( + state is TransitionState.Transition && + (state.transformationSpec.hasTransformation( + elementKey, + state.fromContent, + ) || + state.transformationSpec.hasTransformation(elementKey, state.toContent)) + ) { + return state + } + } + } else { + // the last state of the list, is the state of the local STL + val lastState = states.last() + if (lastState is TransitionState.Idle) { + check(states.size == 1) + return lastState + } - // Find the last transition with a content that contains the element. - transitionStates.fastForEachReversed { state -> - val transition = state as TransitionState.Transition - if (isInContent(transition.fromContent) || isInContent(transition.toContent)) { - return transition + // Find the last transition with a content that contains the element. + states.fastForEachReversed { state -> + val transition = state as TransitionState.Transition + if (isInContent(transition.fromContent) || isInContent(transition.toContent)) { + return transition + } + } } } - + // We are running a transition where both from and to don't contain the element. The element + // may still be rendered as e.g. it can be part of a idle scene where two overlays are currently + // transitioning above it. return null } @@ -706,7 +771,7 @@ private fun prepareInterruption( stateInContent.alphaInterruptionDelta = 0f stateInContent.scaleInterruptionDelta = Scale.Zero - if (!shouldPlaceElement(layoutImpl, stateInContent.content, element, transition)) { + if (!shouldPlaceElement(layoutImpl, stateInContent.contents.last(), element, transition)) { stateInContent.offsetBeforeInterruption = Offset.Unspecified stateInContent.alphaBeforeInterruption = Element.AlphaUnspecified stateInContent.scaleBeforeInterruption = Scale.Unspecified @@ -720,7 +785,7 @@ private fun prepareInterruption( } /** - * Reconcile the state of [element] in the formContent and toContent of [transition] so that the + * Reconcile the state of [element] in the fromContent and toContent of [transition] so that the * values before interruption have their expected values, taking shared transitions into account. * * @return the unique state this element had during [transition], `null` if it had multiple @@ -878,7 +943,7 @@ private inline fun <T> setPlacementInterruptionDelta( // If the element is shared, also set the delta on the other content so that it is used by that // content if we start overscrolling it and change the content where the element is placed. val otherContent = - if (stateInContent.content == transition.fromContent) transition.toContent + if (stateInContent.contents.last() == transition.fromContent) transition.toContent else transition.fromContent val otherContentState = element.stateByContent[otherContent] ?: return if (isSharedElementEnabled(element.key, transition)) { @@ -916,7 +981,8 @@ private fun shouldPlaceElement( if ( content != transition.fromContent && content != transition.toContent && - (!isReplacingOverlay || content != transition.currentScene) + (!isReplacingOverlay || content != transition.currentScene) && + transitionDoesNotInvolveAncestorContent(layoutImpl, transition) ) { return false } @@ -938,6 +1004,15 @@ private fun shouldPlaceElement( return shouldPlaceSharedElement(layoutImpl, content, element.key, transition) } +private fun transitionDoesNotInvolveAncestorContent( + layoutImpl: SceneTransitionLayoutImpl, + transition: TransitionState.Transition, +): Boolean { + return layoutImpl.ancestors.fastAll { + it.inContent != transition.fromContent && it.inContent != transition.toContent + } +} + /** * Whether the element is opaque or not. * @@ -968,7 +1043,7 @@ private fun isElementOpaque( return true } - return transition.transformationSpec.transformations(element.key, content.key).alpha == null + return transition.transformationSpec.transformations(element.key, content.key)?.alpha == null } /** @@ -992,7 +1067,7 @@ private fun elementAlpha( element, transition, contentValue = { 1f }, - transformation = { it.alpha }, + transformation = { it?.alpha }, currentValue = { 1f }, isSpecified = { true }, ::lerp, @@ -1060,7 +1135,7 @@ private fun measure( element, transition, contentValue = { it.targetSize }, - transformation = { it.size }, + transformation = { it?.size }, currentValue = { measurable.measure(constraints).also { maybePlaceable = it }.size() }, isSpecified = { it != Element.SizeUnspecified }, ::lerp, @@ -1093,7 +1168,6 @@ private fun measure( ) }, ) - return measurable.measure( Constraints.fixed( interruptedSize.width.coerceAtLeast(0), @@ -1117,7 +1191,7 @@ private fun ContentDrawScope.getDrawScale( element, transition, contentValue = { Scale.Default }, - transformation = { it.drawScale }, + transformation = { it?.drawScale }, currentValue = { Scale.Default }, isSpecified = { true }, ::lerp, @@ -1205,7 +1279,8 @@ private inline fun <T> computeValue( element: Element, transition: TransitionState.Transition?, contentValue: (Element.State) -> T, - transformation: (ElementTransformations) -> TransformationWithRange<PropertyTransformation<T>>?, + transformation: + (ElementTransformations?) -> TransformationWithRange<PropertyTransformation<T>>?, currentValue: () -> T, isSpecified: (T) -> Boolean, lerp: (T, T, Float) -> T, @@ -1230,7 +1305,7 @@ private inline fun <T> computeValue( return contentValue(currentContentState) } - val currentContent = currentContentState.content + val currentContent = currentContentState.contents.last() // The element is shared: interpolate between the value in fromContent and the value in // toContent. @@ -1265,23 +1340,52 @@ private inline fun <T> computeValue( } } + // The content for which we compute the transformation. Note that this is not necessarily + // [currentContent] because [currentContent] could be a different content than the transition + // fromContent or toContent during interruptions or when a ancestor transition is running. + val content: ContentKey // Get the transformed value, i.e. the target value at the beginning (for entering elements) or // end (for leaving elements) of the transition. - val contentState = - checkNotNull( - when { - isSharedElement && currentContent == fromContent -> fromState - isSharedElement -> toState - currentSceneState != null && currentContent == transition.currentScene -> - currentSceneState - else -> fromState ?: toState + val contentState: Element.State + when { + isSharedElement -> { + content = currentContent + contentState = currentContentState + } + isAncestorTransition(layoutImpl, transition) -> { + if ( + fromState != null && + transition.transformationSpec.hasTransformation(element.key, fromContent) + ) { + content = fromContent + contentState = fromState + } else if ( + toState != null && + transition.transformationSpec.hasTransformation(element.key, toContent) + ) { + content = toContent + contentState = toState + } else { + throw IllegalStateException( + "Ancestor transition is active but no transformation " + + "spec was found. The ancestor transition should have only been selected " + + "when a transformation for that element and content was defined." + ) } - ) - - // The content for which we compute the transformation. Note that this is not necessarily - // [currentContent] because [currentContent] could be a different content than the transition - // fromContent or toContent during interruptions. - val content = contentState.content + } + currentSceneState != null && currentContent == transition.currentScene -> { + content = currentContent + contentState = currentSceneState + } + fromState != null -> { + content = fromContent + contentState = fromState + } + else -> { + content = toContent + contentState = toState!! + } + } val transformationWithRange = transformation(transition.transformationSpec.transformations(element.key, content)) @@ -1437,6 +1541,8 @@ private inline fun <T> computeValue( when { content == toContent -> true content == fromContent -> false + isAncestorTransition(layoutImpl, transition) -> + isEnteringAncestorTransition(layoutImpl, transition) content == transition.currentScene -> toState == null else -> content == toContent } @@ -1447,6 +1553,22 @@ private inline fun <T> computeValue( } } +private fun isAncestorTransition( + layoutImpl: SceneTransitionLayoutImpl, + transition: TransitionState.Transition, +): Boolean { + return layoutImpl.ancestors.fastAny { + it.inContent == transition.fromContent || it.inContent == transition.toContent + } +} + +private fun isEnteringAncestorTransition( + layoutImpl: SceneTransitionLayoutImpl, + transition: TransitionState.Transition +): Boolean { + return layoutImpl.ancestors.fastAny { it.inContent == transition.toContent } +} + private inline fun <T> PropertyTransformation<T>.requireInterpolatedTransformation( element: Element, transition: TransitionState.Transition, diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MovableElement.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MovableElement.kt index 388456e8893a..c10a48567b22 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MovableElement.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/MovableElement.kt @@ -163,7 +163,7 @@ private class MovableElementScopeImpl( // Important: Like in Modifier.element(), we read the transition states during // composition then pass them to Layout to make sure that composition sees new states // before layout and drawing. - val transitionStates = layoutImpl.state.transitionStates + val transitionStates = getAllNestedTransitionStates(layoutImpl) Layout { _, _ -> // No need to measure or place anything. val size = @@ -186,7 +186,7 @@ private fun shouldComposeMovableElement( element: MovableElementKey, ): Boolean { return when ( - val elementState = movableElementState(element, layoutImpl.state.transitionStates) + val elementState = movableElementState(element, getAllNestedTransitionStates(layoutImpl)) ) { null -> movableElementContentWhenIdle(layoutImpl, element, layoutImpl.state.transitionState) == @@ -221,10 +221,14 @@ private fun shouldComposeMoveableElement( private fun movableElementState( element: MovableElementKey, - transitionStates: List<TransitionState>, + transitionStates: List<List<TransitionState>>, ): TransitionState? { val contents = element.contentPicker.contents - return elementState(transitionStates, isInContent = { contents.contains(it) }) + return elementState( + transitionStates, + elementKey = element, + isInContent = { contents.contains(it) }, + ) } private fun movableElementContentWhenIdle( @@ -245,7 +249,7 @@ private fun placeholderContentSize( content: ContentKey, element: Element, elementKey: MovableElementKey, - transitionStates: List<TransitionState>, + transitionStates: List<List<TransitionState>>, ): IntSize { // If the content of the movable element was already composed in this scene before, use that // target size. diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt index ce385abea627..7b30a2a475e3 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayout.kt @@ -698,7 +698,7 @@ internal fun SceneTransitionLayoutForTesting( transitionInterceptionThreshold: Float = 0f, onLayoutImpl: ((SceneTransitionLayoutImpl) -> Unit)? = null, sharedElementMap: MutableMap<ElementKey, Element> = remember { mutableMapOf() }, - ancestorContentKeys: List<ContentKey> = emptyList(), + ancestors: List<Ancestor> = remember { emptyList() }, lookaheadScope: LookaheadScope? = null, builder: SceneTransitionLayoutScope.() -> Unit, ) { @@ -715,7 +715,7 @@ internal fun SceneTransitionLayoutForTesting( builder = builder, animationScope = animationScope, elements = sharedElementMap, - ancestorContentKeys = ancestorContentKeys, + ancestors = ancestors, lookaheadScope = lookaheadScope, ) .also { onLayoutImpl?.invoke(it) } @@ -738,9 +738,9 @@ internal fun SceneTransitionLayoutForTesting( "when creating it, which is not supported" ) } - if (layoutImpl.ancestorContentKeys != ancestorContentKeys) { + if (layoutImpl.ancestors != ancestors) { error( - "This SceneTransitionLayout was bound to a different ancestorContents that was " + + "This SceneTransitionLayout was bound to a different ancestors that was " + "used when creating it, which is not supported" ) } diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt index e1cecc750d3d..e5bdc92b5762 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitionLayoutImpl.kt @@ -57,6 +57,18 @@ import kotlinx.coroutines.launch /** The type for the content of movable elements. */ internal typealias MovableElementContent = @Composable (@Composable () -> Unit) -> Unit +internal data class Ancestor( + val layoutImpl: SceneTransitionLayoutImpl, + + /** + * This is the content in which the corresponding descendant of this ancestor appears in. + * + * Example: When A is the root and has two scenes SA and SB and SB contains a NestedSTL called + * B. Then A is the ancestor of B and inContent is SB. + */ + val inContent: ContentKey, +) + @Stable internal class SceneTransitionLayoutImpl( internal val state: MutableSceneTransitionLayoutStateImpl, @@ -83,16 +95,17 @@ internal class SceneTransitionLayoutImpl( internal val elements: MutableMap<ElementKey, Element> = mutableMapOf(), /** - * When this STL is a [NestedSceneTransitionLayout], this is a list of [ContentKey]s of where - * this STL is composed in within its ancestors. + * When this STL is a [NestedSceneTransitionLayout], this is a list of [Ancestor]s which + * provides a reference to the ancestor STLs and indicates where this STL is composed in within + * its ancestors. * * The root STL holds an emptyList. With each nesting level the parent is supposed to add * exactly one scene to the list, therefore the size of this list is equal to the nesting depth * of this STL. * - * This is used to know in which content of the ancestors a sharedElement appears in. + * This is used to enable transformations and shared elements across NestedSTLs. */ - internal val ancestorContentKeys: List<ContentKey> = emptyList(), + internal val ancestors: List<Ancestor> = emptyList(), lookaheadScope: LookaheadScope? = null, ) { diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitions.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitions.kt index 6479e69a2aac..756d71c1b5cf 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitions.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SceneTransitions.kt @@ -266,19 +266,26 @@ internal class TransformationSpecImpl( override val distance: UserActionDistance?, override val transformationMatchers: List<TransformationMatcher>, ) : TransformationSpec { - private val cache = mutableMapOf<ElementKey, MutableMap<ContentKey, ElementTransformations>>() + private val cache = mutableMapOf<ElementKey, MutableMap<ContentKey, ElementTransformations?>>() - internal fun transformations(element: ElementKey, content: ContentKey): ElementTransformations { + internal fun transformations( + element: ElementKey, + content: ContentKey, + ): ElementTransformations? { return cache .getOrPut(element) { mutableMapOf() } .getOrPut(content) { computeTransformations(element, content) } } + internal fun hasTransformation(element: ElementKey, content: ContentKey): Boolean { + return transformations(element, content) != null + } + /** Filter [transformationMatchers] to compute the [ElementTransformations] of [element]. */ private fun computeTransformations( element: ElementKey, content: ContentKey, - ): ElementTransformations { + ): ElementTransformations? { var shared: TransformationWithRange<SharedElementTransformation>? = null var offset: TransformationWithRange<PropertyTransformation<Offset>>? = null var size: TransformationWithRange<PropertyTransformation<IntSize>>? = null @@ -338,7 +345,13 @@ internal class TransformationSpecImpl( } } - return ElementTransformations(shared, offset, size, drawScale, alpha) + return if ( + shared == null && offset == null && size == null && drawScale == null && alpha == null + ) { + null + } else { + ElementTransformations(shared, offset, size, drawScale, alpha) + } } private fun throwIfNotNull( diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SharedElement.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SharedElement.kt index 9de297f3ad5a..ed3a5cac8184 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SharedElement.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/SharedElement.kt @@ -81,8 +81,9 @@ internal fun sharedElementTransformation( ): TransformationWithRange<SharedElementTransformation>? { val transformationSpec = transition.transformationSpec val sharedInFromContent = - transformationSpec.transformations(element, transition.fromContent).shared - val sharedInToContent = transformationSpec.transformations(element, transition.toContent).shared + transformationSpec.transformations(element, transition.fromContent)?.shared + val sharedInToContent = + transformationSpec.transformations(element, transition.toContent)?.shared // The sharedElement() transformation must either be null or be the same in both contents. if (sharedInFromContent != sharedInToContent) { diff --git a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/Content.kt b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/Content.kt index 3716df5b5a35..8c5a72738a41 100644 --- a/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/Content.kt +++ b/packages/SystemUI/compose/scene/src/com/android/compose/animation/scene/content/Content.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.layout.approachLayout import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.IntSize import androidx.compose.ui.zIndex +import com.android.compose.animation.scene.Ancestor import com.android.compose.animation.scene.AnimatedState import com.android.compose.animation.scene.ContentKey import com.android.compose.animation.scene.ContentScope @@ -181,16 +182,17 @@ internal class ContentScopeImpl( modifier: Modifier, builder: SceneTransitionLayoutScope.() -> Unit, ) { + val ancestors = + remember(layoutImpl, contentKey, layoutImpl.ancestors) { + layoutImpl.ancestors + Ancestor(layoutImpl, contentKey) + } SceneTransitionLayoutForTesting( state, modifier, onLayoutImpl = null, builder = builder, sharedElementMap = layoutImpl.elements, - ancestorContentKeys = - remember(layoutImpl.ancestorContentKeys, contentKey) { - layoutImpl.ancestorContentKeys + contentKey - }, + ancestors = ancestors, lookaheadScope = layoutImpl.lookaheadScope, ) } diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt index c69129b38bdd..676903274b38 100644 --- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt +++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/ElementTest.kt @@ -2208,6 +2208,7 @@ class ElementTest { } @Test + @Ignore("b/363964445") fun interruption_considerPreviousUniqueState() { @Composable fun SceneScope.Foo(modifier: Modifier = Modifier) { diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/NestedElementTransformationTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/NestedElementTransformationTest.kt new file mode 100644 index 000000000000..0da422bcb696 --- /dev/null +++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/NestedElementTransformationTest.kt @@ -0,0 +1,410 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.compose.animation.scene.transformation + +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertPositionInRootIsEqualTo +import androidx.compose.ui.test.isNotDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.compose.animation.scene.ContentScope +import com.android.compose.animation.scene.ElementKey +import com.android.compose.animation.scene.MutableSceneTransitionLayoutState +import com.android.compose.animation.scene.Scale +import com.android.compose.animation.scene.SceneKey +import com.android.compose.animation.scene.SceneTransitionLayout +import com.android.compose.animation.scene.SceneTransitions +import com.android.compose.animation.scene.TestScenes +import com.android.compose.animation.scene.testNestedTransition +import com.android.compose.animation.scene.testing.lastAlphaForTesting +import com.android.compose.animation.scene.testing.lastScaleForTesting +import com.android.compose.animation.scene.transitions +import com.android.compose.test.assertSizeIsEqualTo +import com.google.common.truth.Truth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class NestedElementTransformationTest { + @get:Rule val rule = createComposeRule() + + private object Scenes { + val NestedSceneA = SceneKey("NestedSceneA") + val NestedSceneB = SceneKey("NestedSceneB") + val NestedNestedSceneA = SceneKey("NestedNestedSceneA") + val NestedNestedSceneB = SceneKey("NestedNestedSceneB") + } + + // Variants are named: nestingDepth + sceneNameSuffix + private val elementVariant0A = + TestElement(ElementKey("0A"), 0.dp, 0.dp, 100.dp, 100.dp, Color.Red) + private val elementVariant0B = + TestElement(ElementKey("0B"), 100.dp, 100.dp, 20.dp, 20.dp, Color.Cyan) + private val elementVariant1A = + TestElement(ElementKey("1A"), 40.dp, 80.dp, 60.dp, 20.dp, Color.Blue) + private val elementVariant1B = + TestElement(ElementKey("1B"), 80.dp, 40.dp, 140.dp, 180.dp, Color.Yellow) + private val elementVariant2A = + TestElement(ElementKey("2A"), 120.dp, 240.dp, 20.dp, 140.dp, Color.Green) + private val elementVariant2B = + TestElement(ElementKey("2B"), 200.dp, 320.dp, 40.dp, 70.dp, Color.Magenta) + + private class TestElement( + val key: ElementKey, + val x: Dp, + val y: Dp, + val width: Dp, + val height: Dp, + val color: Color = Color.Black, + val alpha: Float = 0.8f, + ) + + @Composable + private fun ContentScope.TestElement(element: TestElement) { + Box(Modifier.fillMaxSize()) { + Box( + Modifier.offset(element.x, element.y) + .element(element.key) + .size(element.width, element.height) + .background(element.color) + .alpha(element.alpha) + ) + } + } + + private fun createState( + startScene: SceneKey, + transitions: SceneTransitions = SceneTransitions.Empty, + ): MutableSceneTransitionLayoutState { + return rule.runOnUiThread { MutableSceneTransitionLayoutState(startScene, transitions) } + } + + private val threeNestedStls: + @Composable + (states: List<MutableSceneTransitionLayoutState>) -> Unit = + { states -> + SceneTransitionLayout(states[0]) { + scene(TestScenes.SceneA, content = { TestElement(elementVariant0A) }) + scene( + TestScenes.SceneB, + content = { + Box(Modifier.fillMaxSize()) { + TestElement(elementVariant0B) + NestedSceneTransitionLayout(states[1], modifier = Modifier) { + scene(Scenes.NestedSceneA) { + Box(Modifier.fillMaxSize()) { + TestElement(elementVariant1A) + NestedSceneTransitionLayout( + state = states[2], + modifier = Modifier, + ) { + scene(Scenes.NestedNestedSceneA) { + TestElement(elementVariant2A) + } + scene(Scenes.NestedNestedSceneB) { + TestElement(elementVariant2B) + } + } + } + } + scene(Scenes.NestedSceneB) { TestElement(elementVariant1B) } + } + } + }, + ) + } + } + + @Test + fun transitionInNestedNestedStl_transitionsOut() { + rule.testNestedTransition( + states = + listOf( + createState(TestScenes.SceneB), + createState(Scenes.NestedSceneA), + createState( + Scenes.NestedNestedSceneA, + transitions { + from(from = Scenes.NestedNestedSceneA, to = Scenes.NestedNestedSceneB) { + spec = tween(16 * 4, easing = LinearEasing) + translate(elementVariant2A.key, x = 100.dp, y = 50.dp) + scaleSize(elementVariant2A.key, width = 2f, height = 0.5f) + scaleDraw(elementVariant2A.key, scaleX = 4f, scaleY = 0.25f) + fade(elementVariant2A.key) + } + }, + ), + ), + transitionLayout = threeNestedStls, + changeState = { it[2].setTargetScene(Scenes.NestedNestedSceneB, this) }, + ) { + before { onElement(elementVariant2A.key).assertElementVariant(elementVariant2A) } + atAllFrames(4) { + onElement(elementVariant2A.key) + .assertPositionInRootIsEqualTo( + interpolate(elementVariant2A.x, elementVariant2A.x + 100.dp), + interpolate(elementVariant2A.y, elementVariant2A.y + 50.dp), + ) + .assertSizeIsEqualTo( + interpolate(elementVariant2A.width, elementVariant2A.width * 2f), + interpolate(elementVariant2A.height, elementVariant2A.height * 0.5f), + ) + val semanticNode = onElement(elementVariant2A.key).fetchSemanticsNode() + assertThat(semanticNode.lastAlphaForTesting).isEqualTo(interpolate(1f, 0f)) + assertThat(semanticNode.lastScaleForTesting) + .isEqualTo(interpolate(Scale(1f, 1f), Scale(4f, 0.25f))) + } + after { onElement(elementVariant2A.key).isNotDisplayed() } + } + } + + @Test + fun transitionInNestedNestedStl_transitionsIn() { + rule.testNestedTransition( + states = + listOf( + createState(TestScenes.SceneB), + createState(Scenes.NestedSceneA), + createState( + Scenes.NestedNestedSceneB, + transitions { + from(from = Scenes.NestedNestedSceneB) { + spec = tween(16 * 4, easing = LinearEasing) + translate(elementVariant2A.key, x = 100.dp, y = 50.dp) + scaleSize(elementVariant2A.key, width = 2f, height = 0.5f) + } + }, + ), + ), + transitionLayout = threeNestedStls, + changeState = { it[2].setTargetScene(Scenes.NestedNestedSceneA, this) }, + ) { + before { onElement(elementVariant2A.key).isNotDisplayed() } + atAllFrames(4) { + onElement(elementVariant2A.key) + .assertPositionInRootIsEqualTo( + interpolate(elementVariant2A.x + 100.dp, elementVariant2A.x), + interpolate(elementVariant2A.y + 50.dp, elementVariant2A.y), + ) + .assertSizeIsEqualTo( + interpolate(elementVariant2A.width * 2f, elementVariant2A.width), + interpolate(elementVariant2A.height * 0.5f, elementVariant2A.height), + ) + } + after { onElement(elementVariant2A.key).assertElementVariant(elementVariant2A) } + } + } + + @Test + fun transitionInNestedStl_elementInNestedNestedStl_transitionsIn() { + rule.testNestedTransition( + states = + listOf( + createState(TestScenes.SceneB), + createState( + Scenes.NestedSceneB, + transitions { + from(from = Scenes.NestedSceneB, to = Scenes.NestedSceneA) { + spec = tween(16 * 4, easing = LinearEasing) + translate(elementVariant2A.key, x = 100.dp, y = 50.dp) + scaleSize(elementVariant2A.key, width = 2f, height = 0.5f) + } + }, + ), + createState(Scenes.NestedNestedSceneA), + ), + transitionLayout = threeNestedStls, + changeState = { it[1].setTargetScene(Scenes.NestedSceneA, this) }, + ) { + before { onElement(elementVariant2A.key).isNotDisplayed() } + atAllFrames(4) { + onElement(elementVariant2A.key) + .assertPositionInRootIsEqualTo( + interpolate(elementVariant2A.x + 100.dp, elementVariant2A.x), + interpolate(elementVariant2A.y + 50.dp, elementVariant2A.y), + ) + .assertSizeIsEqualTo( + interpolate(elementVariant2A.width * 2f, elementVariant2A.width), + interpolate(elementVariant2A.height * 0.5f, elementVariant2A.height), + ) + } + after { onElement(elementVariant2A.key).assertElementVariant(elementVariant2A) } + } + } + + @Test + fun transitionInRootStl_elementsInAllLayers_transitionInAndOut() { + rule.testNestedTransition( + states = + listOf( + createState( + TestScenes.SceneB, + transitions { + to(to = TestScenes.SceneA) { + spec = tween(16 * 4, easing = LinearEasing) + + // transitions out + translate(elementVariant2A.key, x = 100.dp, y = 50.dp) + scaleSize(elementVariant2A.key, width = 2f, height = 0.5f) + + // transitions out + translate(elementVariant0B.key, x = 200.dp, y = 20.dp) + scaleSize(elementVariant0B.key, width = 3f, height = 0.2f) + + // transitions out + translate(elementVariant1A.key, x = 300.dp, y = 10.dp) + scaleSize(elementVariant1A.key, width = 4f, height = 0.1f) + + // transitions in + translate(elementVariant0A.key, x = 400.dp, y = 40.dp) + scaleSize(elementVariant0A.key, width = 0.5f, height = 2f) + } + }, + ), + createState(Scenes.NestedSceneA), + createState(Scenes.NestedNestedSceneA), + ), + transitionLayout = threeNestedStls, + changeState = { it[0].setTargetScene(TestScenes.SceneA, this) }, + ) { + before { + onElement(elementVariant2A.key).assertElementVariant(elementVariant2A) + onElement(elementVariant0B.key).assertElementVariant(elementVariant0B) + onElement(elementVariant1A.key).assertElementVariant(elementVariant1A) + onElement(elementVariant0A.key).isNotDisplayed() + } + atAllFrames(4) { + onElement(elementVariant2A.key) + .assertPositionInRootIsEqualTo( + interpolate(elementVariant2A.x, elementVariant2A.x + 100.dp), + interpolate(elementVariant2A.y, elementVariant2A.y + 50.dp), + ) + .assertSizeIsEqualTo( + interpolate(elementVariant2A.width, elementVariant2A.width * 2f), + interpolate(elementVariant2A.height, elementVariant2A.height * 0.5f), + ) + + onElement(elementVariant0B.key) + .assertPositionInRootIsEqualTo( + interpolate(elementVariant0B.x, elementVariant0B.x + 200.dp), + interpolate(elementVariant0B.y, elementVariant0B.y + 20.dp), + ) + .assertSizeIsEqualTo( + interpolate(elementVariant0B.width, elementVariant0B.width * 3f), + interpolate(elementVariant0B.height, elementVariant0B.height * 0.2f), + ) + + onElement(elementVariant1A.key) + .assertPositionInRootIsEqualTo( + interpolate(elementVariant1A.x, elementVariant1A.x + 300.dp), + interpolate(elementVariant1A.y, elementVariant1A.y + 10.dp), + ) + .assertSizeIsEqualTo( + interpolate(elementVariant1A.width, elementVariant1A.width * 4f), + interpolate(elementVariant1A.height, elementVariant1A.height * 0.1f), + ) + + onElement(elementVariant0A.key) + .assertPositionInRootIsEqualTo( + interpolate(elementVariant0A.x + 400.dp, elementVariant0A.x), + interpolate(elementVariant0A.y + 40.dp, elementVariant0A.y), + ) + .assertSizeIsEqualTo( + interpolate(elementVariant0A.width * 0.5f, elementVariant0A.width), + interpolate(elementVariant0A.height * 2f, elementVariant0A.height), + ) + } + after { + onElement(elementVariant2A.key).isNotDisplayed() + onElement(elementVariant0B.key).isNotDisplayed() + onElement(elementVariant1A.key).isNotDisplayed() + onElement(elementVariant0A.key).assertElementVariant(elementVariant0A) + } + } + } + + @Test + fun transitionInMultipleStls_rootIsTakingControl() { + rule.testNestedTransition( + states = + listOf( + createState( + TestScenes.SceneB, + transitions { + to(to = TestScenes.SceneA) { + spec = tween(16 * 4, easing = LinearEasing) + translate(elementVariant2A.key, x = 100.dp, y = 50.dp) + } + }, + ), + createState( + Scenes.NestedSceneA, + transitions { + to(to = Scenes.NestedSceneB) { + spec = tween(16 * 4, easing = LinearEasing) + translate(elementVariant2A.key, x = 200.dp, y = 150.dp) + } + }, + ), + createState( + Scenes.NestedNestedSceneA, + transitions { + to(to = Scenes.NestedNestedSceneB) { + spec = tween(16 * 4, easing = LinearEasing) + translate(elementVariant2A.key, x = 300.dp, y = 250.dp) + } + }, + ), + ), + transitionLayout = threeNestedStls, + changeState = { + it[2].setTargetScene(Scenes.NestedNestedSceneB, this) + it[1].setTargetScene(Scenes.NestedSceneB, this) + it[0].setTargetScene(TestScenes.SceneA, this) + }, + ) { + before { onElement(elementVariant2A.key).assertElementVariant(elementVariant2A) } + atAllFrames(4) { + onElement(elementVariant2A.key) + .assertPositionInRootIsEqualTo( + interpolate(elementVariant2A.x, elementVariant2A.x + 100.dp), + interpolate(elementVariant2A.y, elementVariant2A.y + 50.dp), + ) + } + after { onElement(elementVariant2A.key).isNotDisplayed() } + } + } + + private fun SemanticsNodeInteraction.assertElementVariant(variant: TestElement) { + assertPositionInRootIsEqualTo(variant.x, variant.y) + assertSizeIsEqualTo(variant.width, variant.height) + } +} diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/NestedSharedElementTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/NestedSharedElementTest.kt index c6ef8cff1a66..d8b713625681 100644 --- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/NestedSharedElementTest.kt +++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/NestedSharedElementTest.kt @@ -61,7 +61,7 @@ class NestedSharedElementTest { val NestedNestedSceneB = SceneKey("NestedNestedSceneB") } - private val elementVariant1 = SharedElement(0.dp, 0.dp, 100.dp, 100.dp, Color.Red) + private val elementVariant1 = SharedElement(100.dp, 100.dp, 100.dp, 100.dp, Color.Red) private val elementVariant2 = SharedElement(40.dp, 80.dp, 60.dp, 20.dp, Color.Blue) private val elementVariant3 = SharedElement(80.dp, 40.dp, 140.dp, 180.dp, Color.Yellow) private val elementVariant4 = SharedElement(120.dp, 240.dp, 20.dp, 140.dp, Color.Green) @@ -223,7 +223,8 @@ class NestedSharedElementTest { // In SceneA, Foo leaves to the left edge. translate(TestElements.Foo.inScene(TestScenes.SceneA), Edge.Left, false) - // We can't reference the element inside the NestedSTL as of today + // In NestedSceneA, Foo comes in from the top edge. + translate(TestElements.Foo.inScene(Scenes.NestedSceneA), Edge.Top, false) }, ) { before { onElement(TestElements.Foo).assertElementVariant(elementVariant1) } @@ -234,6 +235,11 @@ class NestedSharedElementTest { elementVariant1.y, ) .assertSizeIsEqualTo(elementVariant1.width, elementVariant1.height) + onElement(TestElements.Foo, scene = Scenes.NestedSceneA) + .assertPositionInRootIsEqualTo( + elementVariant2.x, + interpolate(0.dp, elementVariant2.y), + ) } after { onElement(TestElements.Foo).assertElementVariant(elementVariant2) } } diff --git a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/SharedElementTest.kt b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/SharedElementTest.kt index 47c10f5ab3a3..0dd08d92eb41 100644 --- a/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/SharedElementTest.kt +++ b/packages/SystemUI/compose/scene/tests/src/com/android/compose/animation/scene/transformation/SharedElementTest.kt @@ -18,11 +18,13 @@ package com.android.compose.animation.scene.transformation import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.tween +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.test.assertIsNotDisplayed import androidx.compose.ui.test.assertPositionInRootIsEqualTo import androidx.compose.ui.test.junit4.createComposeRule @@ -47,11 +49,21 @@ class SharedElementTest { rule.testTransition( fromSceneContent = { // Foo is at (10, 50) with a size of (20, 80). - Box(Modifier.offset(10.dp, 50.dp).element(TestElements.Foo).size(20.dp, 80.dp)) + Box( + Modifier.offset(10.dp, 50.dp) + .element(TestElements.Foo) + .size(20.dp, 80.dp) + .background(Color.Red) + ) }, toSceneContent = { // Foo is at (50, 70) with a size of (10, 40). - Box(Modifier.offset(50.dp, 70.dp).element(TestElements.Foo).size(10.dp, 40.dp)) + Box( + Modifier.offset(50.dp, 70.dp) + .element(TestElements.Foo) + .size(10.dp, 40.dp) + .background(Color.Blue) + ) }, transition = { spec = tween(16 * 4, easing = LinearEasing) @@ -88,13 +100,23 @@ class SharedElementTest { fromSceneContent = { Box(Modifier.fillMaxSize()) { // Foo is at (10, 50). - Box(Modifier.offset(10.dp, 50.dp).element(TestElements.Foo)) + Box( + Modifier.offset(10.dp, 50.dp) + .element(TestElements.Foo) + .size(20.dp) + .background(Color.Red) + ) } }, toSceneContent = { Box(Modifier.fillMaxSize()) { // Foo is at (50, 60). - Box(Modifier.offset(50.dp, 60.dp).element(TestElements.Foo)) + Box( + Modifier.offset(50.dp, 60.dp) + .element(TestElements.Foo) + .size(20.dp) + .background(Color.Blue) + ) } }, transition = { @@ -104,7 +126,11 @@ class SharedElementTest { sharedElement(TestElements.Foo, enabled = false) // In SceneA, Foo leaves to the left edge. - translate(TestElements.Foo.inScene(TestScenes.SceneA), Edge.Left) + translate( + TestElements.Foo.inScene(TestScenes.SceneA), + Edge.Left, + startsOutsideLayoutBounds = false, + ) // In SceneB, Foo comes from the bottom edge. translate(TestElements.Foo.inScene(TestScenes.SceneB), Edge.Bottom) diff --git a/packages/SystemUI/compose/scene/tests/utils/src/com/android/compose/animation/scene/TestTransition.kt b/packages/SystemUI/compose/scene/tests/utils/src/com/android/compose/animation/scene/TestTransition.kt index 124b61e45ed6..bc160fc02498 100644 --- a/packages/SystemUI/compose/scene/tests/utils/src/com/android/compose/animation/scene/TestTransition.kt +++ b/packages/SystemUI/compose/scene/tests/utils/src/com/android/compose/animation/scene/TestTransition.kt @@ -25,6 +25,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.semantics.SemanticsNode import androidx.compose.ui.test.SemanticsNodeInteraction import androidx.compose.ui.test.SemanticsNodeInteractionsProvider @@ -63,6 +64,9 @@ interface TransitionTestBuilder { * Important: [timestamp] must be a multiple of 16 (the duration of a frame on the JVM/Android). * There is no intermediary state between `t` and `t + 16` , so testing transitions outside of * `t = 0`, `t = 16`, `t = 32`, etc does not make sense. + * + * @param builder the builder can run assertions and is passed the CoroutineScope such that the + * test can start transitions at any desired point in time. */ fun at(timestamp: Long, builder: TransitionTestAssertionScope.() -> Unit) @@ -85,7 +89,7 @@ interface TransitionTestBuilder { } @TransitionTestDsl -interface TransitionTestAssertionScope { +interface TransitionTestAssertionScope : CoroutineScope { /** * Assert on [element]. * @@ -312,6 +316,20 @@ fun ComposeContentTestRule.testTransition( ) } +fun ComposeContentTestRule.testNestedTransition( + states: List<MutableSceneTransitionLayoutState>, + changeState: CoroutineScope.(states: List<MutableSceneTransitionLayoutState>) -> Unit, + transitionLayout: @Composable (states: List<MutableSceneTransitionLayoutState>) -> Unit, + builder: TransitionTestBuilder.() -> Unit, +) { + testTransition( + state = states[0], + changeState = { changeState(states) }, + transitionLayout = { transitionLayout(states) }, + builder = builder, + ) +} + /** Test the transition from [state] to [to]. */ fun ComposeContentTestRule.testTransition( state: MutableSceneTransitionLayoutState, @@ -319,9 +337,15 @@ fun ComposeContentTestRule.testTransition( transitionLayout: @Composable (state: MutableSceneTransitionLayoutState) -> Unit, builder: TransitionTestBuilder.() -> Unit, ) { - val test = transitionTest(builder) + lateinit var coroutineScope: CoroutineScope + setContent { + coroutineScope = rememberCoroutineScope() + transitionLayout(state) + } + val assertionScope = - object : AutoTransitionTestAssertionScope { + object : AutoTransitionTestAssertionScope, CoroutineScope by coroutineScope { + var progress = 0f override fun onElement( @@ -338,6 +362,16 @@ fun ComposeContentTestRule.testTransition( from is Int && to is Int -> lerp(from, to, progress) from is Long && to is Long -> lerp(from, to, progress) from is Dp && to is Dp -> lerp(from, to, progress) + from is Scale && to is Scale -> + Scale( + lerp(from.scaleX, to.scaleX, progress), + lerp(from.scaleY, to.scaleY, progress), + interpolate(from.pivot, to.pivot), + ) + + from is Offset && to is Offset -> + Offset(lerp(from.x, to.x, progress), lerp(from.y, to.y, progress)) + else -> throw UnsupportedOperationException( "Interpolation not supported for this type" @@ -347,14 +381,9 @@ fun ComposeContentTestRule.testTransition( } } - lateinit var coroutineScope: CoroutineScope - setContent { - coroutineScope = rememberCoroutineScope() - transitionLayout(state) - } - // Wait for the UI to be idle then test the before state. waitForIdle() + val test = transitionTest(builder) test.before(assertionScope) // Manually advance the clock to the start of the animation. diff --git a/packages/SystemUI/multivalentTests/src/com/android/systemui/wmshell/TestableBubbleController.java b/packages/SystemUI/multivalentTests/src/com/android/systemui/wmshell/TestableBubbleController.java index 4a5ebd057835..aa71b84d7bbc 100644 --- a/packages/SystemUI/multivalentTests/src/com/android/systemui/wmshell/TestableBubbleController.java +++ b/packages/SystemUI/multivalentTests/src/com/android/systemui/wmshell/TestableBubbleController.java @@ -25,7 +25,6 @@ import android.view.WindowManager; import com.android.internal.statusbar.IStatusBarService; import com.android.wm.shell.ShellTaskOrganizer; -import com.android.wm.shell.WindowManagerShellWrapper; import com.android.wm.shell.bubbles.BubbleController; import com.android.wm.shell.bubbles.BubbleData; import com.android.wm.shell.bubbles.BubbleDataRepository; @@ -33,6 +32,8 @@ import com.android.wm.shell.bubbles.BubbleLogger; import com.android.wm.shell.bubbles.BubblePositioner; import com.android.wm.shell.bubbles.properties.BubbleProperties; import com.android.wm.shell.common.DisplayController; +import com.android.wm.shell.common.DisplayImeController; +import com.android.wm.shell.common.DisplayInsetsController; import com.android.wm.shell.common.FloatingContentCoordinator; import com.android.wm.shell.common.ShellExecutor; import com.android.wm.shell.common.SyncTransactionQueue; @@ -62,7 +63,8 @@ public class TestableBubbleController extends BubbleController { BubbleDataRepository dataRepository, IStatusBarService statusBarService, WindowManager windowManager, - WindowManagerShellWrapper windowManagerShellWrapper, + DisplayInsetsController displayInsetsController, + DisplayImeController displayImeController, UserManager userManager, LauncherApps launcherApps, BubbleLogger bubbleLogger, @@ -81,8 +83,8 @@ public class TestableBubbleController extends BubbleController { BubbleProperties bubbleProperties) { super(context, shellInit, shellCommandHandler, shellController, data, Runnable::run, floatingContentCoordinator, dataRepository, statusBarService, windowManager, - windowManagerShellWrapper, userManager, launcherApps, bubbleLogger, - taskStackListener, shellTaskOrganizer, positioner, displayController, + displayInsetsController, displayImeController, userManager, launcherApps, + bubbleLogger, taskStackListener, shellTaskOrganizer, positioner, displayController, oneHandedOptional, dragAndDropController, shellMainExecutor, shellMainHandler, new SyncExecutor(), taskViewTransitions, transitions, syncQueue, wmService, bubbleProperties); diff --git a/packages/SystemUI/res/values/dimens.xml b/packages/SystemUI/res/values/dimens.xml index 70a94f9f9a59..6994a55cdbcd 100644 --- a/packages/SystemUI/res/values/dimens.xml +++ b/packages/SystemUI/res/values/dimens.xml @@ -782,6 +782,13 @@ <!-- The top margin for the notification children container in its non-expanded form. --> <dimen name="notification_children_container_margin_top">48dp</dimen> + <!-- The spacing between the notification children container in its non-expanded form, and the + header text above it, scaling with text size. This value is chosen so that, taking into + account the text spacing for both the text in the top line and the text in the container, + the distance between them is 4dp with the default screen configuration (and will grow + accordingly for larger font sizes). --> + <dimen name="notification_2025_children_container_margin_top">@*android:dimen/notification_2025_content_margin_top</dimen> + <!-- The height of the gap between adjacent notification sections. --> <dimen name="notification_section_divider_height">@dimen/notification_side_paddings</dimen> diff --git a/packages/SystemUI/src/com/android/systemui/accessibility/hearingaid/AmbientVolumeSlider.java b/packages/SystemUI/src/com/android/systemui/accessibility/hearingaid/AmbientVolumeSlider.java index 92338ef3773c..1a068c4229c9 100644 --- a/packages/SystemUI/src/com/android/systemui/accessibility/hearingaid/AmbientVolumeSlider.java +++ b/packages/SystemUI/src/com/android/systemui/accessibility/hearingaid/AmbientVolumeSlider.java @@ -45,16 +45,31 @@ public class AmbientVolumeSlider extends LinearLayout { new Slider.OnSliderTouchListener() { @Override public void onStartTrackingTouch(@NonNull Slider slider) { + mTrackingTouch = true; } @Override public void onStopTrackingTouch(@NonNull Slider slider) { + mTrackingTouch = false; final int value = Math.round(slider.getValue()); for (OnChangeListener listener : mChangeListeners) { listener.onValueChange(AmbientVolumeSlider.this, value); } } }; + private final Slider.OnChangeListener mSliderChangeListener = new Slider.OnChangeListener() { + @Override + public void onValueChange(@NonNull Slider slider, float value, boolean fromUser) { + if (fromUser && !mTrackingTouch) { + final int roundedValue = Math.round(value); + for (OnChangeListener listener : mChangeListeners) { + listener.onValueChange(AmbientVolumeSlider.this, roundedValue); + } + } + } + }; + private boolean mTrackingTouch = false; + public AmbientVolumeSlider(@Nullable Context context) { this(context, /* attrs= */ null); } @@ -76,6 +91,7 @@ public class AmbientVolumeSlider extends LinearLayout { mTitle = requireViewById(R.id.ambient_volume_slider_title); mSlider = requireViewById(R.id.ambient_volume_slider); mSlider.addOnSliderTouchListener(mSliderTouchListener); + mSlider.addOnChangeListener(mSliderChangeListener); } /** diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationTemplateViewWrapper.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationTemplateViewWrapper.java index df43ff19de86..3ccf5063cd68 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationTemplateViewWrapper.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/wrapper/NotificationTemplateViewWrapper.java @@ -16,6 +16,7 @@ package com.android.systemui.statusbar.notification.row.wrapper; +import static android.app.Flags.notificationsRedesignTemplates; import static android.view.View.VISIBLE; import static com.android.systemui.statusbar.notification.row.ExpandableNotificationRow.DEFAULT_HEADER_VISIBLE_AMOUNT; @@ -149,10 +150,15 @@ public class NotificationTemplateViewWrapper extends NotificationHeaderViewWrapp } }, TRANSFORMING_VIEW_TEXT); - mFullHeaderTranslation = ctx.getResources().getDimensionPixelSize( - com.android.internal.R.dimen.notification_content_margin) - - ctx.getResources().getDimensionPixelSize( - com.android.internal.R.dimen.notification_content_margin_top); + int contentMargin = ctx.getResources().getDimensionPixelSize( + com.android.internal.R.dimen.notification_content_margin); + int contentMarginTop = + notificationsRedesignTemplates() + ? Notification.Builder.getContentMarginTop(ctx, + com.android.internal.R.dimen.notification_2025_content_margin_top) + : ctx.getResources().getDimensionPixelSize( + com.android.internal.R.dimen.notification_content_margin_top); + mFullHeaderTranslation = contentMargin - contentMarginTop; } @MainThread diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationChildrenContainer.java b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationChildrenContainer.java index 00cd8ce87738..9fb7fad52bb6 100644 --- a/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationChildrenContainer.java +++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/stack/NotificationChildrenContainer.java @@ -168,8 +168,10 @@ public class NotificationChildrenContainer extends ViewGroup mDividerHeight = res.getDimensionPixelOffset( R.dimen.notification_children_container_divider_height); mDividerAlpha = res.getFloat(R.dimen.notification_divider_alpha); - mNotificationHeaderMargin = res.getDimensionPixelOffset( - R.dimen.notification_children_container_margin_top); + mNotificationHeaderMargin = notificationsRedesignTemplates() + ? Notification.Builder.getContentMarginTop(getContext(), + R.dimen.notification_2025_children_container_margin_top) + : res.getDimensionPixelOffset(R.dimen.notification_children_container_margin_top); mNotificationTopPadding = res.getDimensionPixelOffset( R.dimen.notification_children_container_top_padding); mHeaderHeight = notificationsRedesignTemplates() diff --git a/packages/SystemUI/tests/src/com/android/systemui/wmshell/BubblesTest.java b/packages/SystemUI/tests/src/com/android/systemui/wmshell/BubblesTest.java index 192d66c44aa0..af12d0119c58 100644 --- a/packages/SystemUI/tests/src/com/android/systemui/wmshell/BubblesTest.java +++ b/packages/SystemUI/tests/src/com/android/systemui/wmshell/BubblesTest.java @@ -167,7 +167,6 @@ import com.android.systemui.util.settings.SystemSettings; import com.android.systemui.util.time.SystemClock; import com.android.wm.shell.Flags; import com.android.wm.shell.ShellTaskOrganizer; -import com.android.wm.shell.WindowManagerShellWrapper; import com.android.wm.shell.bubbles.Bubble; import com.android.wm.shell.bubbles.BubbleData; import com.android.wm.shell.bubbles.BubbleDataRepository; @@ -184,6 +183,8 @@ import com.android.wm.shell.bubbles.StackEducationView; import com.android.wm.shell.bubbles.bar.BubbleBarLayerView; import com.android.wm.shell.bubbles.properties.BubbleProperties; import com.android.wm.shell.common.DisplayController; +import com.android.wm.shell.common.DisplayImeController; +import com.android.wm.shell.common.DisplayInsetsController; import com.android.wm.shell.common.FloatingContentCoordinator; import com.android.wm.shell.common.ShellExecutor; import com.android.wm.shell.common.SyncTransactionQueue; @@ -325,7 +326,9 @@ public class BubblesTest extends SysuiTestCase { @Mock private LauncherApps mLauncherApps; @Mock - private WindowManagerShellWrapper mWindowManagerShellWrapper; + private DisplayInsetsController mDisplayInsetsController; + @Mock + private DisplayImeController mDisplayImeController; @Mock private BubbleLogger mBubbleLogger; @Mock @@ -503,7 +506,7 @@ public class BubblesTest extends SysuiTestCase { mContext, mock(NotificationManager.class), mock(NotificationSettingsInteractor.class) - ); + ); interruptionDecisionProvider.start(); mShellTaskOrganizer = new ShellTaskOrganizer(mock(ShellInit.class), @@ -523,7 +526,8 @@ public class BubblesTest extends SysuiTestCase { mDataRepository, mStatusBarService, mWindowManager, - mWindowManagerShellWrapper, + mDisplayInsetsController, + mDisplayImeController, mUserManager, mLauncherApps, mBubbleLogger, @@ -1430,9 +1434,12 @@ public class BubblesTest extends SysuiTestCase { mPositioner, mBubbleController.getStackView(), new BubbleIconFactory(mContext, - mContext.getResources().getDimensionPixelSize(com.android.wm.shell.R.dimen.bubble_size), - mContext.getResources().getDimensionPixelSize(com.android.wm.shell.R.dimen.bubble_badge_size), - mContext.getResources().getColor(com.android.launcher3.icons.R.color.important_conversation), + mContext.getResources().getDimensionPixelSize( + com.android.wm.shell.R.dimen.bubble_size), + mContext.getResources().getDimensionPixelSize( + com.android.wm.shell.R.dimen.bubble_badge_size), + mContext.getResources().getColor( + com.android.launcher3.icons.R.color.important_conversation), mContext.getResources().getDimensionPixelSize( com.android.internal.R.dimen.importance_ring_stroke_width)), bubble, diff --git a/services/core/java/com/android/server/hdmi/HdmiCecLocalDevice.java b/services/core/java/com/android/server/hdmi/HdmiCecLocalDevice.java index a8d5696e8c77..c384b5434bce 100644 --- a/services/core/java/com/android/server/hdmi/HdmiCecLocalDevice.java +++ b/services/core/java/com/android/server/hdmi/HdmiCecLocalDevice.java @@ -1345,7 +1345,10 @@ abstract class HdmiCecLocalDevice extends HdmiLocalDevice { iter.remove(); } if (mPendingActionClearedCallback != null) { - mPendingActionClearedCallback.onCleared(this); + PendingActionClearedCallback callback = mPendingActionClearedCallback; + // To prevent from calling the callback again during handling the callback itself. + mPendingActionClearedCallback = null; + callback.onCleared(this); } } |