diff options
author | 2023-09-20 15:02:21 +0000 | |
---|---|---|
committer | 2023-09-20 15:02:21 +0000 | |
commit | 07c906b2c45e406effb57cc70c51e4fa12dc4c59 (patch) | |
tree | 3a9c196a46423d815a52cb19f0655cbb580252c5 /java | |
parent | c7bcd3858d5aa3b7750890abfec73f40e175357b (diff) | |
parent | 5e4370b5de1347909d514e279e1aada582458820 (diff) |
Merge "Make preview scrollable under a feature flag." into main
Diffstat (limited to 'java')
14 files changed, 947 insertions, 78 deletions
diff --git a/java/res/layout/chooser_grid_scrollable_preview.xml b/java/res/layout/chooser_grid_scrollable_preview.xml new file mode 100644 index 00000000..a5ac75a2 --- /dev/null +++ b/java/res/layout/chooser_grid_scrollable_preview.xml @@ -0,0 +1,128 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- +/* +* Copyright 2015, 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. +*/ +--> +<com.android.intentresolver.widget.ResolverDrawerLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:layout_gravity="center" + app:maxCollapsedHeight="0dp" + app:maxCollapsedHeightSmall="56dp" + app:useScrollablePreviewNestedFlingLogic="true" + android:maxWidth="@dimen/chooser_width" + android:id="@androidprv:id/contentPanel"> + + <RelativeLayout + android:id="@androidprv:id/chooser_header" + android:layout_width="match_parent" + android:layout_height="wrap_content" + app:layout_alwaysShow="true" + android:elevation="0dp" + android:background="@drawable/bottomsheet_background"> + + <View + android:id="@androidprv:id/drag" + android:layout_width="64dp" + android:layout_height="4dp" + android:background="@drawable/ic_drag_handle" + android:layout_marginTop="@dimen/chooser_edge_margin_thin" + android:layout_marginBottom="@dimen/chooser_edge_margin_thin" + android:layout_centerHorizontal="true" + android:layout_alignParentTop="true" /> + + <TextView android:id="@android:id/title" + android:layout_height="wrap_content" + android:layout_width="wrap_content" + android:textAppearance="@android:style/TextAppearance.DeviceDefault.WindowTitle" + android:gravity="center" + android:paddingBottom="@dimen/chooser_view_spacing" + android:paddingLeft="24dp" + android:paddingRight="24dp" + android:visibility="gone" + android:layout_below="@androidprv:id/drag" + android:layout_centerHorizontal="true"/> + </RelativeLayout> + + <FrameLayout + android:id="@+id/chooser_headline_row_container" + android:layout_width="match_parent" + android:layout_height="wrap_content" + app:layout_alwaysShow="true" + android:background="?androidprv:attr/materialColorSurfaceContainer"> + + <ViewStub + android:id="@+id/chooser_headline_row_stub" + android:inflatedId="@+id/chooser_headline_row" + android:layout="@layout/chooser_headline_row" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:paddingHorizontal="@dimen/chooser_edge_margin_normal" + android:layout_marginBottom="@dimen/chooser_view_spacing" /> + </FrameLayout> + + <com.android.intentresolver.widget.ChooserNestedScrollView + android:layout_width="match_parent" + android:layout_height="wrap_content"> + + <LinearLayout + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:orientation="vertical" + android:background="#f00"> + + <FrameLayout + android:id="@androidprv:id/content_preview_container" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:visibility="gone" /> + + <TabHost + android:id="@androidprv:id/profile_tabhost" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:layout_alignParentTop="true" + android:layout_centerHorizontal="true" + android:background="?androidprv:attr/materialColorSurfaceContainer"> + <LinearLayout + android:orientation="vertical" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + <TabWidget + android:id="@android:id/tabs" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:visibility="gone"> + </TabWidget> + <FrameLayout + android:id="@android:id/tabcontent" + android:layout_width="match_parent" + android:layout_height="wrap_content"> + <com.android.intentresolver.ResolverViewPager + android:id="@androidprv:id/profile_pager" + android:layout_width="match_parent" + android:layout_height="wrap_content"/> + </FrameLayout> + </LinearLayout> + </TabHost> + </LinearLayout> + + </com.android.intentresolver.widget.ChooserNestedScrollView> + +</com.android.intentresolver.widget.ResolverDrawerLayout> diff --git a/java/res/layout/chooser_list_per_profile_wrap.xml b/java/res/layout/chooser_list_per_profile_wrap.xml new file mode 100644 index 00000000..157fa75d --- /dev/null +++ b/java/res/layout/chooser_list_per_profile_wrap.xml @@ -0,0 +1,42 @@ +<!-- + ~ Copyright (C) 2019 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. + --> +<RelativeLayout + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:androidprv="http://schemas.android.com/apk/prv/res/android" + xmlns:app="http://schemas.android.com/apk/res-auto" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:descendantFocusability="blocksDescendants"> + <!-- ^^^ Block descendants from receiving focus to prevent NestedScrollView + (ChooserNestedScrollView) scrolling to the focused view when switching tabs. Without it, TabHost + view will request focus on the newly activated tab. The RecyclerView from this layout gets + focused and notifies its parents (including NestedScrollView) about it through + #requestChildFocus method call. NestedScrollView's view implementation of the method will + scroll to the focused view. --> + + <androidx.recyclerview.widget.RecyclerView + android:layout_width="match_parent" + android:layout_height="wrap_content" + app:layoutManager="com.android.intentresolver.ChooserGridLayoutManager" + android:id="@androidprv:id/resolver_list" + android:clipToPadding="false" + android:background="?androidprv:attr/materialColorSurfaceContainer" + android:scrollbars="none" + android:elevation="1dp" + android:nestedScrollingEnabled="true" /> + + <include layout="@layout/resolver_empty_states" /> +</RelativeLayout> diff --git a/java/res/values/attrs.xml b/java/res/values/attrs.xml index 67acb3ae..c9f2c300 100644 --- a/java/res/values/attrs.xml +++ b/java/res/values/attrs.xml @@ -32,6 +32,11 @@ will push all ignoreOffset siblings below it when the drawer is moved i.e. setting the top limit the ignoreOffset elements. --> <attr name="ignoreOffsetTopLimit" format="reference" /> + <!-- Specifies whether ResolverDrawerLayout should use an alternative nested fling logic + adjusted for the scrollable preview feature. + Controlled by the flag com.android.intentresolver.Flags#FLAG_SCROLLABLE_PREVIEW. + --> + <attr name="useScrollablePreviewNestedFlingLogic" format="boolean" /> </declare-styleable> <declare-styleable name="ResolverDrawerLayout_LayoutParams"> diff --git a/java/src/com/android/intentresolver/ChooserActivity.java b/java/src/com/android/intentresolver/ChooserActivity.java index 26dbd224..f455be4c 100644 --- a/java/src/com/android/intentresolver/ChooserActivity.java +++ b/java/src/com/android/intentresolver/ChooserActivity.java @@ -491,7 +491,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements /* workProfileQuietModeChecker= */ () -> false, /* workProfileUserHandle= */ null, getAnnotatedUserHandles().cloneProfileUserHandle, - mMaxTargetsPerRow); + mMaxTargetsPerRow, + mFeatureFlags); } private ChooserMultiProfilePagerAdapter createChooserMultiProfilePagerAdapterForTwoProfiles( @@ -525,7 +526,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements selectedProfile, getAnnotatedUserHandles().workProfileUserHandle, getAnnotatedUserHandles().cloneProfileUserHandle, - mMaxTargetsPerRow); + mMaxTargetsPerRow, + mFeatureFlags); } private int findSelectedProfile() { @@ -667,7 +669,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements getResources(), getLayoutInflater(), parent, - /*headlineViewParent=*/null); + mFeatureFlags.scrollablePreview() + ? findViewById(R.id.chooser_headline_row_container) + : null); if (layout != null) { adjustPreviewWidth(getResources().getConfiguration().orientation, layout); @@ -788,7 +792,9 @@ public class ChooserActivity extends Hilt_ChooserActivity implements @Override public int getLayoutResource() { - return R.layout.chooser_grid; + return mFeatureFlags.scrollablePreview() + ? R.layout.chooser_grid_scrollable_preview + : R.layout.chooser_grid; } @Override // ResolverListCommunicator @@ -1208,7 +1214,8 @@ public class ChooserActivity extends Hilt_ChooserActivity implements }, chooserListAdapter, shouldShowContentPreview(), - mMaxTargetsPerRow); + mMaxTargetsPerRow, + mFeatureFlags); } @VisibleForTesting @@ -1639,11 +1646,13 @@ public class ChooserActivity extends Hilt_ChooserActivity implements } private boolean shouldShowStickyContentPreviewNoOrientationCheck() { - return shouldShowTabs() - && (mMultiProfilePagerAdapter.getListAdapterForUserHandle( - UserHandle.of(UserHandle.myUserId())).getCount() > 0 - || shouldShowContentPreviewWhenEmpty()) - && shouldShowContentPreview(); + if (!shouldShowContentPreview()) { + return false; + } + boolean isEmpty = mMultiProfilePagerAdapter.getListAdapterForUserHandle( + UserHandle.of(UserHandle.myUserId())).getCount() == 0; + return (mFeatureFlags.scrollablePreview() || shouldShowTabs()) + && (!isEmpty || shouldShowContentPreviewWhenEmpty()); } /** diff --git a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java index c159243e..ba35ae5d 100644 --- a/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java +++ b/java/src/com/android/intentresolver/ChooserMultiProfilePagerAdapter.java @@ -52,7 +52,8 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda Supplier<Boolean> workProfileQuietModeChecker, UserHandle workProfileUserHandle, UserHandle cloneProfileUserHandle, - int maxTargetsPerRow) { + int maxTargetsPerRow, + FeatureFlags featureFlags) { this( context, new ChooserProfileAdapterBinder(maxTargetsPerRow), @@ -62,7 +63,8 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda /* defaultProfile= */ 0, workProfileUserHandle, cloneProfileUserHandle, - new BottomPaddingOverrideSupplier(context)); + new BottomPaddingOverrideSupplier(context), + featureFlags); } ChooserMultiProfilePagerAdapter( @@ -74,7 +76,8 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda @Profile int defaultProfile, UserHandle workProfileUserHandle, UserHandle cloneProfileUserHandle, - int maxTargetsPerRow) { + int maxTargetsPerRow, + FeatureFlags featureFlags) { this( context, new ChooserProfileAdapterBinder(maxTargetsPerRow), @@ -84,7 +87,8 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda defaultProfile, workProfileUserHandle, cloneProfileUserHandle, - new BottomPaddingOverrideSupplier(context)); + new BottomPaddingOverrideSupplier(context), + featureFlags); } private ChooserMultiProfilePagerAdapter( @@ -96,7 +100,8 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda @Profile int defaultProfile, UserHandle workProfileUserHandle, UserHandle cloneProfileUserHandle, - BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier) { + BottomPaddingOverrideSupplier bottomPaddingOverrideSupplier, + FeatureFlags featureFlags) { super( context, gridAdapter -> gridAdapter.getListAdapter(), @@ -107,7 +112,7 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda defaultProfile, workProfileUserHandle, cloneProfileUserHandle, - () -> makeProfileView(context), + () -> makeProfileView(context, featureFlags), bottomPaddingOverrideSupplier); mAdapterBinder = adapterBinder; mBottomPaddingOverrideSupplier = bottomPaddingOverrideSupplier; @@ -131,10 +136,12 @@ public class ChooserMultiProfilePagerAdapter extends GenericMultiProfilePagerAda } } - private static ViewGroup makeProfileView(Context context) { + private static ViewGroup makeProfileView( + Context context, FeatureFlags featureFlags) { LayoutInflater inflater = LayoutInflater.from(context); - ViewGroup rootView = (ViewGroup) inflater.inflate( - R.layout.chooser_list_per_profile, null, false); + ViewGroup rootView = featureFlags.scrollablePreview() + ? (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile_wrap, null, false) + : (ViewGroup) inflater.inflate(R.layout.chooser_list_per_profile, null, false); RecyclerView recyclerView = rootView.findViewById(com.android.internal.R.id.resolver_list); recyclerView.setAccessibilityDelegateCompat( new ChooserRecyclerViewAccessibilityDelegate(recyclerView)); diff --git a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java index fadea934..2ab38f30 100644 --- a/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java +++ b/java/src/com/android/intentresolver/grid/ChooserGridAdapter.java @@ -32,9 +32,12 @@ import android.view.animation.DecelerateInterpolator; import android.widget.Space; import android.widget.TextView; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; import com.android.intentresolver.ChooserListAdapter; +import com.android.intentresolver.FeatureFlags; import com.android.intentresolver.R; import com.android.intentresolver.ResolverListAdapter.ViewHolder; import com.android.internal.annotations.VisibleForTesting; @@ -107,6 +110,9 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. private final boolean mShouldShowContentPreview; private final int mChooserWidthPixels; private final int mChooserRowTextOptionTranslatePixelSize; + private final FeatureFlags mFeatureFlags; + @Nullable + private RecyclerView mRecyclerView; private int mChooserTargetWidth = 0; @@ -119,7 +125,8 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. ChooserActivityDelegate chooserActivityDelegate, ChooserListAdapter wrappedAdapter, boolean shouldShowContentPreview, - int maxTargetsPerRow) { + int maxTargetsPerRow, + FeatureFlags featureFlags) { super(); mChooserActivityDelegate = chooserActivityDelegate; @@ -133,6 +140,7 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. mChooserWidthPixels = context.getResources().getDimensionPixelSize(R.dimen.chooser_width); mChooserRowTextOptionTranslatePixelSize = context.getResources().getDimensionPixelSize( R.dimen.chooser_row_text_option_translate); + mFeatureFlags = featureFlags; wrappedAdapter.registerDataSetObserver(new DataSetObserver() { @Override @@ -149,6 +157,18 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. }); } + @Override + public void onAttachedToRecyclerView(@NonNull RecyclerView recyclerView) { + if (mFeatureFlags.scrollablePreview()) { + mRecyclerView = recyclerView; + } + } + + @Override + public void onDetachedFromRecyclerView(@NonNull RecyclerView recyclerView) { + mRecyclerView = null; + } + public void setFooterHeight(int height) { mFooterHeight = height; } @@ -198,7 +218,8 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. public int getSystemRowCount() { // For the tabbed case we show the sticky content preview above the tabs, // please refer to shouldShowStickyContentPreview - if (mChooserActivityDelegate.shouldShowTabs()) { + if (mChooserActivityDelegate.shouldShowTabs() + || mFeatureFlags.scrollablePreview()) { return 0; } @@ -318,6 +339,15 @@ public final class ChooserGridAdapter extends RecyclerView.Adapter<RecyclerView. mAzLabelVisibility = isVisible; int azRowPos = getAzLabelRowPosition(); if (azRowPos >= 0) { + if (mRecyclerView != null) { + for (int i = 0, size = mRecyclerView.getChildCount(); i < size; i++) { + View child = mRecyclerView.getChildAt(i); + if (mRecyclerView.getChildAdapterPosition(child) == azRowPos) { + child.setVisibility(isVisible ? View.VISIBLE : View.GONE); + } + } + return; + } notifyItemChanged(azRowPos); } } diff --git a/java/src/com/android/intentresolver/widget/ChooserNestedScrollView.kt b/java/src/com/android/intentresolver/widget/ChooserNestedScrollView.kt new file mode 100644 index 00000000..26464ca1 --- /dev/null +++ b/java/src/com/android/intentresolver/widget/ChooserNestedScrollView.kt @@ -0,0 +1,90 @@ +package com.android.intentresolver.widget + +import android.content.Context +import android.util.AttributeSet +import android.view.View +import android.widget.LinearLayout +import androidx.core.view.ScrollingView +import androidx.core.view.marginBottom +import androidx.core.view.marginLeft +import androidx.core.view.marginRight +import androidx.core.view.marginTop +import androidx.core.widget.NestedScrollView + +/** + * A narrowly tailored [NestedScrollView] to be used inside [ResolverDrawerLayout] and help to + * orchestrate content preview scrolling. It expects one [LinearLayout] child with + * [LinearLayout.VERTICAL] orientation. If the child has more than one child, the first its child + * will be made scrollable (it is expected to be a content preview view). + */ +class ChooserNestedScrollView : NestedScrollView { + constructor(context: Context) : super(context) + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + constructor( + context: Context, + attrs: AttributeSet?, + defStyleAttr: Int + ) : super(context, attrs, defStyleAttr) + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val content = + getChildAt(0) as? LinearLayout ?: error("Exactly one child, LinerLayout, is expected") + require(content.orientation == LinearLayout.VERTICAL) { "VERTICAL orientation is expected" } + require(MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY) { + "Expected to have an exact width" + } + + val lp = content.layoutParams ?: error("LayoutParams is missing") + val contentWidthSpec = + getChildMeasureSpec( + widthMeasureSpec, + paddingLeft + content.marginLeft + content.marginRight + paddingRight, + lp.width + ) + val contentHeightSpec = + getChildMeasureSpec( + heightMeasureSpec, + paddingTop + content.marginTop + content.marginBottom + paddingBottom, + lp.height + ) + content.measure(contentWidthSpec, contentHeightSpec) + + if (content.childCount > 1) { + // We expect that the first child should be scrollable up + val child = content.getChildAt(0) + val height = + MeasureSpec.getSize(heightMeasureSpec) + + child.measuredHeight + + child.marginTop + + child.marginBottom + + content.measure( + contentWidthSpec, + MeasureSpec.makeMeasureSpec(height, MeasureSpec.getMode(heightMeasureSpec)) + ) + } + setMeasuredDimension( + MeasureSpec.getSize(widthMeasureSpec), + minOf( + MeasureSpec.getSize(heightMeasureSpec), + paddingTop + + content.marginTop + + content.measuredHeight + + content.marginBottom + + paddingBottom + ) + ) + } + + override fun onNestedPreScroll(target: View, dx: Int, dy: Int, consumed: IntArray, type: Int) { + // let the parent scroll + super.onNestedPreScroll(target, dx, dy, consumed, type) + // scroll ourselves, if recycler has not scrolled + val delta = dy - consumed[1] + if (delta > 0 && target is ScrollingView && !target.canScrollVertically(-1)) { + val preScrollY = scrollY + scrollBy(0, delta) + consumed[1] += scrollY - preScrollY + } + } +} diff --git a/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java b/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java index de76a1d2..b8fbedbf 100644 --- a/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java +++ b/java/src/com/android/intentresolver/widget/ResolverDrawerLayout.java @@ -45,6 +45,8 @@ import android.view.animation.AnimationUtils; import android.widget.AbsListView; import android.widget.OverScroller; +import androidx.annotation.Nullable; +import androidx.core.view.ScrollingView; import androidx.recyclerview.widget.RecyclerView; import com.android.intentresolver.R; @@ -131,6 +133,9 @@ public class ResolverDrawerLayout extends ViewGroup { private AbsListView mNestedListChild; private RecyclerView mNestedRecyclerChild; + @Nullable + private final ScrollablePreviewFlingLogicDelegate mFlingLogicDelegate; + private final ViewTreeObserver.OnTouchModeChangeListener mTouchModeChangeListener = new ViewTreeObserver.OnTouchModeChangeListener() { @Override @@ -167,6 +172,12 @@ public class ResolverDrawerLayout extends ViewGroup { mIgnoreOffsetTopLimitViewId = a.getResourceId( R.styleable.ResolverDrawerLayout_ignoreOffsetTopLimit, ID_NULL); } + mFlingLogicDelegate = + a.getBoolean( + R.styleable.ResolverDrawerLayout_useScrollablePreviewNestedFlingLogic, + false) + ? new ScrollablePreviewFlingLogicDelegate() {} + : null; a.recycle(); mScrollIndicatorDrawable = mContext.getDrawable( @@ -832,6 +843,9 @@ public class ResolverDrawerLayout extends ViewGroup { @Override public boolean onNestedPreFling(View target, float velocityX, float velocityY) { + if (mFlingLogicDelegate != null) { + return mFlingLogicDelegate.onNestedPreFling(this, target, velocityX, velocityY); + } if (!getShowAtTop() && velocityY > mMinFlingVelocity && mCollapseOffset != 0) { smoothScrollTo(0, velocityY); return true; @@ -841,9 +855,12 @@ public class ResolverDrawerLayout extends ViewGroup { @Override public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed) { + if (mFlingLogicDelegate != null) { + return mFlingLogicDelegate.onNestedFling(this, target, velocityX, velocityY, consumed); + } // TODO: find a more suitable way to fix it. // RecyclerView started reporting `consumed` as true whenever a scrolling is enabled, - // previously the value was based whether the fling can be performed in given direction + // previously the value was based on whether the fling can be performed in given direction // i.e. whether it is at the top or at the bottom. isRecyclerViewAtTheTop method is a // workaround that restores the legacy functionality. boolean shouldConsume = (Math.abs(velocityY) > mMinFlingVelocity) @@ -885,6 +902,13 @@ public class ResolverDrawerLayout extends ViewGroup { && firstChild.getTop() >= recyclerView.getPaddingTop(); } + private static boolean isFlingTargetAtTop(View target) { + if (target instanceof ScrollingView) { + return !target.canScrollVertically(-1); + } + return false; + } + private boolean performAccessibilityActionCommon(int action) { switch (action) { case AccessibilityNodeInfo.ACTION_SCROLL_FORWARD: @@ -1299,4 +1323,74 @@ public class ResolverDrawerLayout extends ViewGroup { } return mMetricsLogger; } + + /** + * Controlled by + * {@link com.android.intentresolver.Flags#FLAG_SCROLLABLE_PREVIEW} + */ + private interface ScrollablePreviewFlingLogicDelegate { + default boolean onNestedPreFling( + ResolverDrawerLayout drawer, View target, float velocityX, float velocityY) { + boolean shouldScroll = !drawer.getShowAtTop() && velocityY > drawer.mMinFlingVelocity + && drawer.mCollapseOffset != 0; + if (shouldScroll) { + drawer.smoothScrollTo(0, velocityY); + return true; + } + boolean shouldDismiss = (Math.abs(velocityY) > drawer.mMinFlingVelocity) + && velocityY < 0 + && isFlingTargetAtTop(target); + if (shouldDismiss) { + if (drawer.getShowAtTop()) { + drawer.smoothScrollTo(drawer.mCollapsibleHeight, velocityY); + } else { + if (drawer.isDismissable() + && drawer.mCollapseOffset > drawer.mCollapsibleHeight) { + drawer.smoothScrollTo(drawer.mHeightUsed, velocityY); + drawer.mDismissOnScrollerFinished = true; + } else { + drawer.smoothScrollTo(drawer.mCollapsibleHeight, velocityY); + } + } + return true; + } + return false; + } + + default boolean onNestedFling( + ResolverDrawerLayout drawer, + View target, + float velocityX, + float velocityY, + boolean consumed) { + // TODO: find a more suitable way to fix it. + // RecyclerView started reporting `consumed` as true whenever a scrolling is enabled, + // previously the value was based on whether the fling can be performed in given + // direction i.e. whether it is at the top or at the bottom. isRecyclerViewAtTheTop + // method is a workaround that restores the legacy functionality. + boolean shouldConsume = (Math.abs(velocityY) > drawer.mMinFlingVelocity) && !consumed; + if (shouldConsume) { + if (drawer.getShowAtTop()) { + if (drawer.isDismissable() && velocityY > 0) { + drawer.abortAnimation(); + drawer.dismiss(); + } else { + drawer.smoothScrollTo( + velocityY < 0 ? drawer.mCollapsibleHeight : 0, velocityY); + } + } else { + if (drawer.isDismissable() + && velocityY < 0 + && drawer.mCollapseOffset > drawer.mCollapsibleHeight) { + drawer.smoothScrollTo(drawer.mHeightUsed, velocityY); + drawer.mDismissOnScrollerFinished = true; + } else { + drawer.smoothScrollTo( + velocityY > 0 ? 0 : drawer.mCollapsibleHeight, velocityY); + } + } + } + return shouldConsume; + } + } } diff --git a/java/tests/Android.bp b/java/tests/Android.bp index 974b8a47..5244bf7b 100644 --- a/java/tests/Android.bp +++ b/java/tests/Android.bp @@ -51,6 +51,8 @@ android_test { "mockito-target-minus-junit4", "testables", "truth-prebuilt", + "flag-junit", + "platform-test-annotations", ], plugins: ["dagger2-compiler"], test_suites: ["general-tests"], diff --git a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java index c0b15b6d..bc9e521c 100644 --- a/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java +++ b/java/tests/src/com/android/intentresolver/UnbundledChooserActivityTest.java @@ -17,6 +17,7 @@ package com.android.intentresolver; import static android.app.Activity.RESULT_OK; + import static androidx.test.espresso.Espresso.onView; import static androidx.test.espresso.action.ViewActions.click; import static androidx.test.espresso.action.ViewActions.longClick; @@ -24,10 +25,12 @@ import static androidx.test.espresso.action.ViewActions.swipeUp; import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; import static androidx.test.espresso.assertion.ViewAssertions.matches; import static androidx.test.espresso.matcher.ViewMatchers.hasSibling; +import static androidx.test.espresso.matcher.ViewMatchers.isCompletelyDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; import static androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility; import static androidx.test.espresso.matcher.ViewMatchers.withId; import static androidx.test.espresso.matcher.ViewMatchers.withText; + import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_CHOOSER_TARGET; import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_DEFAULT; import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_FROM_PREDICTION_SERVICE; @@ -35,9 +38,12 @@ import static com.android.intentresolver.ChooserActivity.TARGET_TYPE_SHORTCUTS_F import static com.android.intentresolver.ChooserListAdapter.CALLER_TARGET_SCORE_BOOST; import static com.android.intentresolver.ChooserListAdapter.SHORTCUT_TARGET_SCORE_BOOST; import static com.android.intentresolver.MatcherUtils.first; + import static com.google.common.truth.Truth.assertThat; import static com.google.common.truth.Truth.assertWithMessage; + import static junit.framework.Assert.assertNull; + import static org.hamcrest.CoreMatchers.allOf; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.not; @@ -82,6 +88,9 @@ import android.graphics.drawable.Icon; import android.net.Uri; import android.os.Bundle; import android.os.UserHandle; +import android.platform.test.annotations.RequiresFlagsEnabled; +import android.platform.test.flag.junit.CheckFlagsRule; +import android.platform.test.flag.junit.DeviceFlagsValueProvider; import android.provider.DeviceConfig; import android.service.chooser.ChooserAction; import android.service.chooser.ChooserTarget; @@ -190,11 +199,13 @@ public class UnbundledChooserActivityTest { private static final int CONTENT_PREVIEW_FILE = 2; private static final int CONTENT_PREVIEW_TEXT = 3; - @Rule(order = 0) - public HiltAndroidRule mHiltAndroidRule = new HiltAndroidRule(this); + public CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule(); @Rule(order = 1) + public HiltAndroidRule mHiltAndroidRule = new HiltAndroidRule(this); + + @Rule(order = 2) public ActivityTestRule<ChooserWrapperActivity> mActivityRule = new ActivityTestRule<>(ChooserWrapperActivity.class, false, false); @@ -2160,6 +2171,42 @@ public class UnbundledChooserActivityTest { .check(matches(isDisplayed())); } + @Test + @RequiresFlagsEnabled(Flags.FLAG_SCROLLABLE_PREVIEW) + public void testWorkTab_previewIsScrollable() { + markOtherProfileAvailability(/* workAvailable= */ true, /* cloneAvailable= */ false); + List<ResolvedComponentInfo> personalResolvedComponentInfos = + createResolvedComponentsForTest(300); + List<ResolvedComponentInfo> workResolvedComponentInfos = + createResolvedComponentsForTest(3); + setupResolverControllers(personalResolvedComponentInfos, workResolvedComponentInfos); + + Uri uri = createTestContentProviderUri("image/png", null); + + ArrayList<Uri> uris = new ArrayList<>(); + uris.add(uri); + + Intent sendIntent = createSendUriIntentWithPreview(uris); + ChooserActivityOverrideData.getInstance().imageLoader = + createImageLoader(uri, createWideBitmap()); + + mActivityRule.launchActivity(Intent.createChooser(sendIntent, "Scrollable preview test")); + waitForIdle(); + + onView(withId(com.android.intentresolver.R.id.scrollable_image_preview)) + .check(matches(isDisplayed())); + + onView(withId(com.android.internal.R.id.contentPanel)).perform(swipeUp()); + waitForIdle(); + + onView(withId(com.android.intentresolver.R.id.chooser_headline_row_container)) + .check(matches(isCompletelyDisplayed())); + onView(withId(com.android.intentresolver.R.id.headline)) + .check(matches(isDisplayed())); + onView(withId(com.android.intentresolver.R.id.scrollable_image_preview)) + .check(matches(not(isDisplayed()))); + } + @Ignore // b/220067877 @Test public void testWorkTab_xProfileOff_noAppsAvailable_workOff_xProfileOffEmptyStateShown() { diff --git a/java/tests/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt index 6409da8a..d2d952ae 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/FileContentPreviewUiTest.kt @@ -17,6 +17,7 @@ package com.android.intentresolver.contentpreview import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup import android.widget.TextView import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -25,7 +26,7 @@ import com.android.intentresolver.R import com.android.intentresolver.mock import com.android.intentresolver.whenever import com.android.intentresolver.widget.ActionRow -import com.google.common.truth.Truth +import com.google.common.truth.Truth.assertThat import java.util.function.Consumer import org.junit.Test import org.junit.runner.RunWith @@ -48,15 +49,15 @@ class FileContentPreviewUiTest { private val context get() = InstrumentationRegistry.getInstrumentation().context + private val testSubject = + FileContentPreviewUi( + fileCount, + actionFactory, + headlineGenerator, + ) + @Test fun test_display_titleIsDisplayed() { - val testSubject = - FileContentPreviewUi( - fileCount, - actionFactory, - headlineGenerator, - ) - val layoutInflater = LayoutInflater.from(context) val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup @@ -68,9 +69,31 @@ class FileContentPreviewUiTest { /*headlineViewParent=*/ null ) - Truth.assertThat(previewView).isNotNull() + assertThat(previewView).isNotNull() val headlineView = previewView?.findViewById<TextView>(R.id.headline) - Truth.assertThat(headlineView).isNotNull() - Truth.assertThat(headlineView?.text).isEqualTo(text) + assertThat(headlineView).isNotNull() + assertThat(headlineView?.text).isEqualTo(text) + } + + @Test + fun test_displayWithExternalHeaderView() { + val layoutInflater = LayoutInflater.from(context) + val gridLayout = + layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) + as ViewGroup + val externalHeaderView = + gridLayout.requireViewById<View>(R.id.chooser_headline_row_container) + + assertThat(externalHeaderView.findViewById<View>(R.id.headline)).isNull() + + val previewView = + testSubject.display(context.resources, layoutInflater, gridLayout, externalHeaderView) + + assertThat(previewView).isNotNull() + assertThat(previewView.findViewById<View>(R.id.headline)).isNull() + + val headlineView = externalHeaderView.findViewById<TextView>(R.id.headline) + assertThat(headlineView).isNotNull() + assertThat(headlineView?.text).isEqualTo(text) } } diff --git a/java/tests/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt index 1144e3c9..0976dbf1 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/FilesPlusTextContentPreviewUiTest.kt @@ -18,6 +18,7 @@ package com.android.intentresolver.contentpreview import android.net.Uri import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup import android.widget.TextView import androidx.lifecycle.testing.TestLifecycleOwner @@ -28,6 +29,7 @@ import com.android.intentresolver.mock import com.android.intentresolver.whenever import com.android.intentresolver.widget.ActionRow import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage import java.util.function.Consumer import org.junit.Test import org.junit.runner.RunWith @@ -74,6 +76,17 @@ class FilesPlusTextContentPreviewUiTest { } @Test + fun test_displayImagesPlusTextWithoutUriMetadataExternalHeader_showImagesHeadline() { + val sharedFileCount = 2 + val (previewView, headerParent) = testLoadingExternalHeadline("image/*", sharedFileCount) + + verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount) + verifyInternalHeadlineAbsence(previewView) + verifyPreviewHeadline(headerParent, HEADLINE_IMAGES) + verifySharedText(previewView) + } + + @Test fun test_displayVideosPlusTextWithoutUriMetadata_showVideosHeadline() { val sharedFileCount = 2 val previewView = testLoadingHeadline("video/*", sharedFileCount) @@ -84,6 +97,17 @@ class FilesPlusTextContentPreviewUiTest { } @Test + fun test_displayVideosPlusTextWithoutUriMetadataExternalHeader_showVideosHeadline() { + val sharedFileCount = 2 + val (previewView, headerParent) = testLoadingExternalHeadline("video/*", sharedFileCount) + + verify(headlineGenerator, times(1)).getVideosHeadline(sharedFileCount) + verifyInternalHeadlineAbsence(previewView) + verifyPreviewHeadline(headerParent, HEADLINE_VIDEOS) + verifySharedText(previewView) + } + + @Test fun test_displayDocsPlusTextWithoutUriMetadata_showFilesHeadline() { val sharedFileCount = 2 val previewView = testLoadingHeadline("application/pdf", sharedFileCount) @@ -94,6 +118,18 @@ class FilesPlusTextContentPreviewUiTest { } @Test + fun test_displayDocsPlusTextWithoutUriMetadataExternalHeader_showFilesHeadline() { + val sharedFileCount = 2 + val (previewView, headerParent) = + testLoadingExternalHeadline("application/pdf", sharedFileCount) + + verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) + verifyInternalHeadlineAbsence(previewView) + verifyPreviewHeadline(headerParent, HEADLINE_FILES) + verifySharedText(previewView) + } + + @Test fun test_displayMixedContentPlusTextWithoutUriMetadata_showFilesHeadline() { val sharedFileCount = 2 val previewView = testLoadingHeadline("*/*", sharedFileCount) @@ -104,6 +140,17 @@ class FilesPlusTextContentPreviewUiTest { } @Test + fun test_displayMixedContentPlusTextWithoutUriMetadataExternalHeader_showFilesHeadline() { + val sharedFileCount = 2 + val (previewView, headerParent) = testLoadingExternalHeadline("*/*", sharedFileCount) + + verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) + verifyInternalHeadlineAbsence(previewView) + verifyPreviewHeadline(headerParent, HEADLINE_FILES) + verifySharedText(previewView) + } + + @Test fun test_displayImagesPlusTextWithUriMetadataSet_showImagesHeadline() { val loadedFileMetadata = createFileInfosWithMimeTypes("image/png", "image/jpeg") val sharedFileCount = loadedFileMetadata.size @@ -115,6 +162,19 @@ class FilesPlusTextContentPreviewUiTest { } @Test + fun test_displayImagesPlusTextWithUriMetadataSetExternalHeader_showImagesHeadline() { + val loadedFileMetadata = createFileInfosWithMimeTypes("image/png", "image/jpeg") + val sharedFileCount = loadedFileMetadata.size + val (previewView, headerParent) = + testLoadingExternalHeadline("image/*", sharedFileCount, loadedFileMetadata) + + verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount) + verifyInternalHeadlineAbsence(previewView) + verifyPreviewHeadline(headerParent, HEADLINE_IMAGES) + verifySharedText(previewView) + } + + @Test fun test_displayVideosPlusTextWithUriMetadataSet_showVideosHeadline() { val loadedFileMetadata = createFileInfosWithMimeTypes("video/mp4", "video/mp4") val sharedFileCount = loadedFileMetadata.size @@ -126,6 +186,19 @@ class FilesPlusTextContentPreviewUiTest { } @Test + fun test_displayVideosPlusTextWithUriMetadataSetExternalHeader_showVideosHeadline() { + val loadedFileMetadata = createFileInfosWithMimeTypes("video/mp4", "video/mp4") + val sharedFileCount = loadedFileMetadata.size + val (previewView, headerParent) = + testLoadingExternalHeadline("video/*", sharedFileCount, loadedFileMetadata) + + verify(headlineGenerator, times(1)).getVideosHeadline(sharedFileCount) + verifyInternalHeadlineAbsence(previewView) + verifyPreviewHeadline(headerParent, HEADLINE_VIDEOS) + verifySharedText(previewView) + } + + @Test fun test_displayImagesAndVideosPlusTextWithUriMetadataSet_showFilesHeadline() { val loadedFileMetadata = createFileInfosWithMimeTypes("image/png", "video/mp4") val sharedFileCount = loadedFileMetadata.size @@ -137,6 +210,19 @@ class FilesPlusTextContentPreviewUiTest { } @Test + fun test_displayImagesAndVideosPlusTextWithUriMetadataSetExternalHeader_showFilesHeadline() { + val loadedFileMetadata = createFileInfosWithMimeTypes("image/png", "video/mp4") + val sharedFileCount = loadedFileMetadata.size + val (previewView, headerParent) = + testLoadingExternalHeadline("*/*", sharedFileCount, loadedFileMetadata) + + verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) + verifyInternalHeadlineAbsence(previewView) + verifyPreviewHeadline(headerParent, HEADLINE_FILES) + verifySharedText(previewView) + } + + @Test fun test_displayDocsPlusTextWithUriMetadataSet_showFilesHeadline() { val loadedFileMetadata = createFileInfosWithMimeTypes("application/pdf", "application/pdf") val sharedFileCount = loadedFileMetadata.size @@ -149,6 +235,19 @@ class FilesPlusTextContentPreviewUiTest { } @Test + fun test_displayDocsPlusTextWithUriMetadataSetExternalHeader_showFilesHeadline() { + val loadedFileMetadata = createFileInfosWithMimeTypes("application/pdf", "application/pdf") + val sharedFileCount = loadedFileMetadata.size + val (previewView, headerParent) = + testLoadingExternalHeadline("application/pdf", sharedFileCount, loadedFileMetadata) + + verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) + verifyInternalHeadlineAbsence(previewView) + verifyPreviewHeadline(headerParent, HEADLINE_FILES) + verifySharedText(previewView) + } + + @Test fun test_uriMetadataIsMoreSpecificThanIntentMimeType_headlineGetsUpdated() { val sharedFileCount = 2 val testSubject = @@ -180,10 +279,56 @@ class FilesPlusTextContentPreviewUiTest { verifyPreviewHeadline(previewView, HEADLINE_IMAGES) } + @Test + fun test_uriMetadataIsMoreSpecificThanIntentMimeTypeExternalHeader_headlineGetsUpdated() { + val sharedFileCount = 2 + val testSubject = + FilesPlusTextContentPreviewUi( + lifecycleOwner.lifecycle, + /*isSingleImage=*/ false, + sharedFileCount, + SHARED_TEXT, + /*intentMimeType=*/ "*/*", + actionFactory, + imageLoader, + DefaultMimeTypeClassifier, + headlineGenerator + ) + val layoutInflater = LayoutInflater.from(context) + val gridLayout = + layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) + as ViewGroup + val externalHeaderView = + gridLayout.requireViewById<View>(R.id.chooser_headline_row_container) + + assertWithMessage("External headline should not be inflated by default") + .that(externalHeaderView.findViewById<View>(R.id.headline)) + .isNull() + + val previewView = + testSubject.display( + context.resources, + LayoutInflater.from(context), + gridLayout, + externalHeaderView + ) + + verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) + verify(headlineGenerator, never()).getImagesHeadline(sharedFileCount) + verifyInternalHeadlineAbsence(previewView) + verifyPreviewHeadline(externalHeaderView, HEADLINE_FILES) + + testSubject.updatePreviewMetadata(createFileInfosWithMimeTypes("image/png", "image/jpg")) + + verify(headlineGenerator, times(1)).getFilesHeadline(sharedFileCount) + verify(headlineGenerator, times(1)).getImagesHeadline(sharedFileCount) + verifyPreviewHeadline(externalHeaderView, HEADLINE_IMAGES) + } + private fun testLoadingHeadline( intentMimeType: String, sharedFileCount: Int, - loadedFileMetadata: List<FileInfo>? = null + loadedFileMetadata: List<FileInfo>? = null, ): ViewGroup? { val testSubject = FilesPlusTextContentPreviewUi( @@ -209,14 +354,51 @@ class FilesPlusTextContentPreviewUiTest { ) } + private fun testLoadingExternalHeadline( + intentMimeType: String, + sharedFileCount: Int, + loadedFileMetadata: List<FileInfo>? = null, + ): Pair<ViewGroup?, View> { + val testSubject = + FilesPlusTextContentPreviewUi( + lifecycleOwner.lifecycle, + /*isSingleImage=*/ false, + sharedFileCount, + SHARED_TEXT, + intentMimeType, + actionFactory, + imageLoader, + DefaultMimeTypeClassifier, + headlineGenerator + ) + val layoutInflater = LayoutInflater.from(context) + val gridLayout = + layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) + as ViewGroup + val externalHeaderView = + gridLayout.requireViewById<View>(R.id.chooser_headline_row_container) + + assertWithMessage("External headline should not be inflated by default") + .that(externalHeaderView.findViewById<View>(R.id.headline)) + .isNull() + + loadedFileMetadata?.let(testSubject::updatePreviewMetadata) + return testSubject.display( + context.resources, + LayoutInflater.from(context), + gridLayout, + externalHeaderView + ) to externalHeaderView + } + private fun createFileInfosWithMimeTypes(vararg mimeTypes: String): List<FileInfo> { val uri = Uri.parse("content://pkg.app/file") return mimeTypes.map { mimeType -> FileInfo.Builder(uri).withMimeType(mimeType).build() } } - private fun verifyPreviewHeadline(previewView: ViewGroup?, expectedText: String) { - assertThat(previewView).isNotNull() - val headlineView = previewView?.findViewById<TextView>(R.id.headline) + private fun verifyPreviewHeadline(headerViewParent: View?, expectedText: String) { + assertThat(headerViewParent).isNotNull() + val headlineView = headerViewParent?.findViewById<TextView>(R.id.headline) assertThat(headlineView).isNotNull() assertThat(headlineView?.text).isEqualTo(expectedText) } @@ -227,4 +409,13 @@ class FilesPlusTextContentPreviewUiTest { assertThat(textContentView).isNotNull() assertThat(textContentView?.text).isEqualTo(SHARED_TEXT) } + + private fun verifyInternalHeadlineAbsence(previewView: ViewGroup?) { + assertWithMessage("Preview parent should not be null").that(previewView).isNotNull() + assertWithMessage( + "Preview headline should not be inflated when an external headline is used" + ) + .that(previewView?.findViewById<View>(R.id.headline)) + .isNull() + } } diff --git a/java/tests/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt index 69053f73..b91ed436 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/TextContentPreviewUiTest.kt @@ -17,6 +17,7 @@ package com.android.intentresolver.contentpreview import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup import android.widget.TextView import androidx.lifecycle.testing.TestLifecycleOwner @@ -51,18 +52,19 @@ class TextContentPreviewUiTest { private val context get() = InstrumentationRegistry.getInstrumentation().context + private val testSubject = + TextContentPreviewUi( + lifecycleOwner.lifecycle, + text, + title, + /*previewThumbnail=*/ null, + actionFactory, + imageLoader, + headlineGenerator, + ) + @Test fun test_display_headlineIsDisplayed() { - val testSubject = - TextContentPreviewUi( - lifecycleOwner.lifecycle, - text, - title, - /*previewThumbnail=*/ null, - actionFactory, - imageLoader, - headlineGenerator, - ) val layoutInflater = LayoutInflater.from(context) val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup @@ -79,4 +81,26 @@ class TextContentPreviewUiTest { assertThat(headlineView).isNotNull() assertThat(headlineView?.text).isEqualTo(text) } + + @Test + fun test_displayWithExternalHeaderView_externalHeaderIsDisplayed() { + val layoutInflater = LayoutInflater.from(context) + val gridLayout = + layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) + as ViewGroup + val externalHeaderView = + gridLayout.requireViewById<View>(R.id.chooser_headline_row_container) + + assertThat(externalHeaderView.findViewById<View>(R.id.headline)).isNull() + + val previewView = + testSubject.display(context.resources, layoutInflater, gridLayout, externalHeaderView) + + assertThat(previewView).isNotNull() + assertThat(previewView.findViewById<View>(R.id.headline)).isNull() + + val headlineView = externalHeaderView.findViewById<TextView>(R.id.headline) + assertThat(headlineView).isNotNull() + assertThat(headlineView?.text).isEqualTo(text) + } } diff --git a/java/tests/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt b/java/tests/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt index 6b22b850..7e07e0ca 100644 --- a/java/tests/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt +++ b/java/tests/src/com/android/intentresolver/contentpreview/UnifiedContentPreviewUiTest.kt @@ -18,13 +18,17 @@ package com.android.intentresolver.contentpreview import android.net.Uri import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup +import android.widget.TextView import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry.getInstrumentation -import com.android.intentresolver.R.layout.chooser_grid +import com.android.intentresolver.R import com.android.intentresolver.mock import com.android.intentresolver.whenever import com.android.intentresolver.widget.ImagePreviewView.TransitionElementStatusCallback +import com.google.common.truth.Truth +import com.google.common.truth.Truth.assertWithMessage import kotlin.coroutines.EmptyCoroutineContext import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asFlow @@ -38,6 +42,10 @@ import org.mockito.Mockito.anyInt import org.mockito.Mockito.times import org.mockito.Mockito.verify +private const val IMAGE_HEADLINE = "Image Headline" +private const val VIDEO_HEADLINE = "Video Headline" +private const val FILES_HEADLINE = "Files Headline" + @RunWith(AndroidJUnit4::class) class UnifiedContentPreviewUiTest { private val testScope = TestScope(EmptyCoroutineContext + UnconfinedTestDispatcher()) @@ -48,40 +56,76 @@ class UnifiedContentPreviewUiTest { private val imageLoader = mock<ImageLoader>() private val headlineGenerator = mock<HeadlineGenerator> { - whenever(getImagesHeadline(anyInt())).thenReturn("Image Headline") - whenever(getVideosHeadline(anyInt())).thenReturn("Video Headline") - whenever(getFilesHeadline(anyInt())).thenReturn("Files Headline") + whenever(getImagesHeadline(anyInt())).thenReturn(IMAGE_HEADLINE) + whenever(getVideosHeadline(anyInt())).thenReturn(VIDEO_HEADLINE) + whenever(getFilesHeadline(anyInt())).thenReturn(FILES_HEADLINE) } private val context - get() = getInstrumentation().getContext() + get() = getInstrumentation().context @Test fun test_displayImagesWithoutUriMetadata_showImagesHeadline() { - testLoadingHeadline("image/*", files = null) + testLoadingHeadline("image/*", files = null) { previewView -> + verify(headlineGenerator, times(1)).getImagesHeadline(2) + verifyPreviewHeadline(previewView, IMAGE_HEADLINE) + } + } - verify(headlineGenerator, times(1)).getImagesHeadline(2) + @Test + fun test_displayImagesWithoutUriMetadataExternalHeader_showImagesHeadline() { + testLoadingExternalHeadline("image/*", files = null) { externalHeaderView -> + verify(headlineGenerator, times(1)).getImagesHeadline(2) + verifyPreviewHeadline(externalHeaderView, IMAGE_HEADLINE) + } } @Test fun test_displayVideosWithoutUriMetadata_showImagesHeadline() { - testLoadingHeadline("video/*", files = null) + testLoadingHeadline("video/*", files = null) { previewView -> + verify(headlineGenerator, times(1)).getVideosHeadline(2) + verifyPreviewHeadline(previewView, VIDEO_HEADLINE) + } + } - verify(headlineGenerator, times(1)).getVideosHeadline(2) + @Test + fun test_displayVideosWithoutUriMetadataExternalHeader_showImagesHeadline() { + testLoadingExternalHeadline("video/*", files = null) { externalHeaderView -> + verify(headlineGenerator, times(1)).getVideosHeadline(2) + verifyPreviewHeadline(externalHeaderView, VIDEO_HEADLINE) + } } @Test fun test_displayDocumentsWithoutUriMetadata_showImagesHeadline() { - testLoadingHeadline("application/pdf", files = null) + testLoadingHeadline("application/pdf", files = null) { previewView -> + verify(headlineGenerator, times(1)).getFilesHeadline(2) + verifyPreviewHeadline(previewView, FILES_HEADLINE) + } + } - verify(headlineGenerator, times(1)).getFilesHeadline(2) + @Test + fun test_displayDocumentsWithoutUriMetadataExternalHeader_showImagesHeadline() { + testLoadingExternalHeadline("application/pdf", files = null) { externalHeaderView -> + verify(headlineGenerator, times(1)).getFilesHeadline(2) + verifyPreviewHeadline(externalHeaderView, FILES_HEADLINE) + } } @Test fun test_displayMixedContentWithoutUriMetadata_showImagesHeadline() { - testLoadingHeadline("*/*", files = null) + testLoadingHeadline("*/*", files = null) { previewView -> + verify(headlineGenerator, times(1)).getFilesHeadline(2) + verifyPreviewHeadline(previewView, FILES_HEADLINE) + } + } - verify(headlineGenerator, times(1)).getFilesHeadline(2) + @Test + fun test_displayMixedContentWithoutUriMetadataExternalHeader_showImagesHeadline() { + testLoadingExternalHeadline("*/*", files = null) { externalHeader -> + verify(headlineGenerator, times(1)).getFilesHeadline(2) + verifyPreviewHeadline(externalHeader, FILES_HEADLINE) + } } @Test @@ -92,9 +136,24 @@ class UnifiedContentPreviewUiTest { FileInfo.Builder(uri).withMimeType("image/png").build(), FileInfo.Builder(uri).withMimeType("image/jpeg").build(), ) - testLoadingHeadline("image/*", files) + testLoadingHeadline("image/*", files) { preivewView -> + verify(headlineGenerator, times(1)).getImagesHeadline(2) + verifyPreviewHeadline(preivewView, IMAGE_HEADLINE) + } + } - verify(headlineGenerator, times(1)).getImagesHeadline(2) + @Test + fun test_displayImagesWithUriMetadataSetExternalHeader_showImagesHeadline() { + val uri = Uri.parse("content://pkg.app/image.png") + val files = + listOf( + FileInfo.Builder(uri).withMimeType("image/png").build(), + FileInfo.Builder(uri).withMimeType("image/jpeg").build(), + ) + testLoadingExternalHeadline("image/*", files) { externalHeader -> + verify(headlineGenerator, times(1)).getImagesHeadline(2) + verifyPreviewHeadline(externalHeader, IMAGE_HEADLINE) + } } @Test @@ -105,9 +164,24 @@ class UnifiedContentPreviewUiTest { FileInfo.Builder(uri).withMimeType("video/mp4").build(), FileInfo.Builder(uri).withMimeType("video/mp4").build(), ) - testLoadingHeadline("video/*", files) + testLoadingHeadline("video/*", files) { previewView -> + verify(headlineGenerator, times(1)).getVideosHeadline(2) + verifyPreviewHeadline(previewView, VIDEO_HEADLINE) + } + } - verify(headlineGenerator, times(1)).getVideosHeadline(2) + @Test + fun test_displayVideosWithUriMetadataSetExternalHeader_showImagesHeadline() { + val uri = Uri.parse("content://pkg.app/image.png") + val files = + listOf( + FileInfo.Builder(uri).withMimeType("video/mp4").build(), + FileInfo.Builder(uri).withMimeType("video/mp4").build(), + ) + testLoadingExternalHeadline("video/*", files) { externalHeader -> + verify(headlineGenerator, times(1)).getVideosHeadline(2) + verifyPreviewHeadline(externalHeader, VIDEO_HEADLINE) + } } @Test @@ -118,9 +192,24 @@ class UnifiedContentPreviewUiTest { FileInfo.Builder(uri).withMimeType("image/png").build(), FileInfo.Builder(uri).withMimeType("video/mp4").build(), ) - testLoadingHeadline("*/*", files) + testLoadingHeadline("*/*", files) { previewView -> + verify(headlineGenerator, times(1)).getFilesHeadline(2) + verifyPreviewHeadline(previewView, FILES_HEADLINE) + } + } - verify(headlineGenerator, times(1)).getFilesHeadline(2) + @Test + fun test_displayImagesAndVideosWithUriMetadataSetExternalHeader_showImagesHeadline() { + val uri = Uri.parse("content://pkg.app/image.png") + val files = + listOf( + FileInfo.Builder(uri).withMimeType("image/png").build(), + FileInfo.Builder(uri).withMimeType("video/mp4").build(), + ) + testLoadingExternalHeadline("*/*", files) { externalHeader -> + verify(headlineGenerator, times(1)).getFilesHeadline(2) + verifyPreviewHeadline(externalHeader, FILES_HEADLINE) + } } @Test @@ -131,12 +220,31 @@ class UnifiedContentPreviewUiTest { FileInfo.Builder(uri).withMimeType("application/pdf").build(), FileInfo.Builder(uri).withMimeType("application/pdf").build(), ) - testLoadingHeadline("application/pdf", files) + testLoadingHeadline("application/pdf", files) { previewView -> + verify(headlineGenerator, times(1)).getFilesHeadline(2) + verifyPreviewHeadline(previewView, FILES_HEADLINE) + } + } - verify(headlineGenerator, times(1)).getFilesHeadline(2) + @Test + fun test_displayDocumentsWithUriMetadataSetExternalHeader_showImagesHeadline() { + val uri = Uri.parse("content://pkg.app/image.png") + val files = + listOf( + FileInfo.Builder(uri).withMimeType("application/pdf").build(), + FileInfo.Builder(uri).withMimeType("application/pdf").build(), + ) + testLoadingExternalHeadline("application/pdf", files) { externalHeader -> + verify(headlineGenerator, times(1)).getFilesHeadline(2) + verifyPreviewHeadline(externalHeader, FILES_HEADLINE) + } } - private fun testLoadingHeadline(intentMimeType: String, files: List<FileInfo>?) { + private fun testLoadingHeadline( + intentMimeType: String, + files: List<FileInfo>?, + verificationBlock: (ViewGroup?) -> Unit, + ) { testScope.runTest { val endMarker = FileInfo.Builder(Uri.EMPTY).build() val emptySourceFlow = MutableSharedFlow<FileInfo>(replay = 1) @@ -157,15 +265,84 @@ class UnifiedContentPreviewUiTest { headlineGenerator ) val layoutInflater = LayoutInflater.from(context) - val gridLayout = layoutInflater.inflate(chooser_grid, null, false) as ViewGroup + val gridLayout = layoutInflater.inflate(R.layout.chooser_grid, null, false) as ViewGroup - testSubject.display( - context.resources, - LayoutInflater.from(context), - gridLayout, - /*headlineViewParent=*/ null - ) + val previewView = + testSubject.display( + context.resources, + LayoutInflater.from(context), + gridLayout, + /*headlineViewParent=*/ null + ) emptySourceFlow.tryEmit(endMarker) + + verificationBlock(previewView) } } + + private fun testLoadingExternalHeadline( + intentMimeType: String, + files: List<FileInfo>?, + verificationBlock: (View?) -> Unit, + ) { + testScope.runTest { + val endMarker = FileInfo.Builder(Uri.EMPTY).build() + val emptySourceFlow = MutableSharedFlow<FileInfo>(replay = 1) + val testSubject = + UnifiedContentPreviewUi( + testScope, + /*isSingleImage=*/ false, + intentMimeType, + actionFactory, + imageLoader, + DefaultMimeTypeClassifier, + object : TransitionElementStatusCallback { + override fun onTransitionElementReady(name: String) = Unit + override fun onAllTransitionElementsReady() = Unit + }, + files?.let { it.asFlow() } ?: emptySourceFlow.takeWhile { it !== endMarker }, + /*itemCount=*/ 2, + headlineGenerator + ) + val layoutInflater = LayoutInflater.from(context) + val gridLayout = + layoutInflater.inflate(R.layout.chooser_grid_scrollable_preview, null, false) + as ViewGroup + val externalHeaderView = + gridLayout.requireViewById<View>(R.id.chooser_headline_row_container) + + assertWithMessage("External headline should not be inflated by default") + .that(externalHeaderView.findViewById<View>(R.id.headline)) + .isNull() + + val previewView = + testSubject.display( + context.resources, + LayoutInflater.from(context), + gridLayout, + externalHeaderView, + ) + + emptySourceFlow.tryEmit(endMarker) + + verifyInternalHeadlineAbsence(previewView) + verificationBlock(externalHeaderView) + } + } + + private fun verifyPreviewHeadline(headerViewParent: View?, expectedText: String) { + Truth.assertThat(headerViewParent).isNotNull() + val headlineView = headerViewParent?.findViewById<TextView>(R.id.headline) + Truth.assertThat(headlineView).isNotNull() + Truth.assertThat(headlineView?.text).isEqualTo(expectedText) + } + + private fun verifyInternalHeadlineAbsence(previewView: ViewGroup?) { + assertWithMessage("Preview parent should not be null").that(previewView).isNotNull() + assertWithMessage( + "Preview headline should not be inflated when an external headline is used" + ) + .that(previewView?.findViewById<View>(R.id.headline)) + .isNull() + } } |