diff --git a/core/java/com/android/internal/widget/CachingIconView.java b/core/java/com/android/internal/widget/CachingIconView.java index 299cbe12b4d13..d1156979ee072 100644 --- a/core/java/com/android/internal/widget/CachingIconView.java +++ b/core/java/com/android/internal/widget/CachingIconView.java @@ -23,6 +23,7 @@ import android.annotation.Nullable; import android.compat.annotation.UnsupportedAppUsage; import android.content.Context; import android.content.res.Configuration; +import android.content.res.TypedArray; import android.graphics.Bitmap; import android.graphics.PorterDuff; import android.graphics.drawable.Drawable; @@ -35,6 +36,8 @@ import android.view.RemotableViewMethod; import android.widget.ImageView; import android.widget.RemoteViews; +import com.android.internal.R; + import java.util.Objects; import java.util.function.Consumer; @@ -55,9 +58,42 @@ public class CachingIconView extends ImageView { private int mBackgroundColor; private boolean mWillBeForceHidden; + private int mMaxDrawableWidth = -1; + private int mMaxDrawableHeight = -1; + + public CachingIconView(Context context) { + this(context, null, 0, 0); + } + @UnsupportedAppUsage(maxTargetSdk = Build.VERSION_CODES.R, trackingBug = 170729553) public CachingIconView(Context context, @Nullable AttributeSet attrs) { - super(context, attrs); + this(context, attrs, 0, 0); + } + + public CachingIconView(Context context, @Nullable AttributeSet attrs, + int defStyleAttr) { + this(context, attrs, defStyleAttr, 0); + } + + public CachingIconView(Context context, @Nullable AttributeSet attrs, + int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(context, attrs, defStyleAttr, defStyleRes); + } + + private void init(Context context, @Nullable AttributeSet attrs, int defStyleAttr, + int defStyleRes) { + if (attrs == null) { + return; + } + + TypedArray ta = context.obtainStyledAttributes(attrs, + R.styleable.CachingIconView, defStyleAttr, defStyleRes); + mMaxDrawableWidth = ta.getDimensionPixelSize(R.styleable + .CachingIconView_maxDrawableWidth, -1); + mMaxDrawableHeight = ta.getDimensionPixelSize(R.styleable + .CachingIconView_maxDrawableHeight, -1); + ta.recycle(); } @Override @@ -66,15 +102,27 @@ public class CachingIconView extends ImageView { if (!testAndSetCache(icon)) { mInternalSetDrawable = true; // This calls back to setImageDrawable, make sure we don't clear the cache there. - super.setImageIcon(icon); + Drawable drawable = loadSizeRestrictedIcon(icon); + if (drawable == null) { + super.setImageIcon(icon); + } else { + super.setImageDrawable(drawable); + } mInternalSetDrawable = false; } } + @Nullable + private Drawable loadSizeRestrictedIcon(@Nullable Icon icon) { + return LocalImageResolver.resolveImage(icon, getContext(), mMaxDrawableWidth, + mMaxDrawableHeight); + } + @Override - public Runnable setImageIconAsync(@Nullable Icon icon) { + public Runnable setImageIconAsync(@Nullable final Icon icon) { resetCache(); - return super.setImageIconAsync(icon); + Drawable drawable = loadSizeRestrictedIcon(icon); + return () -> setImageDrawable(drawable); } @Override @@ -83,14 +131,30 @@ public class CachingIconView extends ImageView { if (!testAndSetCache(resId)) { mInternalSetDrawable = true; // This calls back to setImageDrawable, make sure we don't clear the cache there. - super.setImageResource(resId); + Drawable drawable = loadSizeRestrictedDrawable(resId); + if (drawable == null) { + super.setImageResource(resId); + } else { + super.setImageDrawable(drawable); + } mInternalSetDrawable = false; } } + @Nullable + private Drawable loadSizeRestrictedDrawable(@DrawableRes int resId) { + return LocalImageResolver.resolveImage(resId, getContext(), mMaxDrawableWidth, + mMaxDrawableHeight); + } + @Override public Runnable setImageResourceAsync(@DrawableRes int resId) { resetCache(); + Drawable drawable = loadSizeRestrictedDrawable(resId); + if (drawable != null) { + return () -> setImageDrawable(drawable); + } + return super.setImageResourceAsync(resId); } @@ -98,13 +162,31 @@ public class CachingIconView extends ImageView { @RemotableViewMethod(asyncImpl="setImageURIAsync") public void setImageURI(@Nullable Uri uri) { resetCache(); - super.setImageURI(uri); + Drawable drawable = loadSizeRestrictedUri(uri); + if (drawable == null) { + super.setImageURI(uri); + } else { + mInternalSetDrawable = true; + super.setImageDrawable(drawable); + mInternalSetDrawable = false; + } + } + + @Nullable + private Drawable loadSizeRestrictedUri(@Nullable Uri uri) { + return LocalImageResolver.resolveImage(uri, getContext(), mMaxDrawableWidth, + mMaxDrawableHeight); } @Override public Runnable setImageURIAsync(@Nullable Uri uri) { resetCache(); - return super.setImageURIAsync(uri); + Drawable drawable = loadSizeRestrictedUri(uri); + if (drawable == null) { + return super.setImageURIAsync(uri); + } else { + return () -> setImageDrawable(drawable); + } } @Override @@ -307,4 +389,18 @@ public class CachingIconView extends ImageView { public void setWillBeForceHidden(boolean forceHidden) { mWillBeForceHidden = forceHidden; } + + /** + * Returns the set maximum width of drawable in pixels. -1 if not set. + */ + public int getMaxDrawableWidth() { + return mMaxDrawableWidth; + } + + /** + * Returns the set maximum height of drawable in pixels. -1 if not set. + */ + public int getMaxDrawableHeight() { + return mMaxDrawableHeight; + } } diff --git a/core/java/com/android/internal/widget/LocalImageResolver.java b/core/java/com/android/internal/widget/LocalImageResolver.java index 616b69961b799..ce27b346567b5 100644 --- a/core/java/com/android/internal/widget/LocalImageResolver.java +++ b/core/java/com/android/internal/widget/LocalImageResolver.java @@ -16,70 +16,195 @@ package com.android.internal.widget; +import android.annotation.DrawableRes; import android.annotation.Nullable; import android.content.Context; +import android.content.res.Resources; +import android.graphics.Bitmap; import android.graphics.ImageDecoder; import android.graphics.drawable.Drawable; import android.graphics.drawable.Icon; import android.net.Uri; +import android.util.Log; import android.util.Size; +import com.android.internal.annotations.VisibleForTesting; + import java.io.IOException; /** A class to extract Drawables from a MessagingStyle/ConversationStyle message. */ public class LocalImageResolver { - private static final String TAG = LocalImageResolver.class.getSimpleName(); - private static final int MAX_SAFE_ICON_SIZE_PX = 480; + private static final String TAG = "LocalImageResolver"; + + @VisibleForTesting + static final int DEFAULT_MAX_SAFE_ICON_SIZE_PX = 480; /** - * Resolve an image from the given Uri using {@link ImageDecoder} + * Resolve an image from the given Uri using {@link ImageDecoder} if it contains a + * bitmap reference. */ + @Nullable public static Drawable resolveImage(Uri uri, Context context) throws IOException { - final ImageDecoder.Source source = - ImageDecoder.createSource(context.getContentResolver(), uri); - final Drawable drawable = - ImageDecoder.decodeDrawable(source, LocalImageResolver::onHeaderDecoded); - return drawable; + try { + final ImageDecoder.Source source = + ImageDecoder.createSource(context.getContentResolver(), uri); + return ImageDecoder.decodeDrawable(source, + (decoder, info, s) -> LocalImageResolver.onHeaderDecoded(decoder, info, + DEFAULT_MAX_SAFE_ICON_SIZE_PX, DEFAULT_MAX_SAFE_ICON_SIZE_PX)); + } catch (Exception e) { + // Invalid drawable resource can actually throw either NullPointerException or + // ResourceNotFoundException. This sanitizes to expected output. + throw new IOException(e); + } } /** - * Get the drawable from Icon using {@link ImageDecoder} if it contains a Uri, or + * Get the drawable from Icon using {@link ImageDecoder} if it contains a bitmap reference, or * using {@link Icon#loadDrawable(Context)} otherwise. This will correctly apply the Icon's, * tint, if present, to the drawable. + * + * @return drawable or null if loading failed. */ - public static Drawable resolveImage(Icon icon, Context context) throws IOException { - Uri uri = getResolvableUri(icon); - if (uri != null) { - Drawable result = resolveImage(uri, context); - if (icon.hasTint()) { - result.mutate(); - result.setTintList(icon.getTintList()); - result.setTintBlendMode(icon.getTintBlendMode()); - } - return result; + @Nullable + public static Drawable resolveImage(@Nullable Icon icon, Context context) throws IOException { + return resolveImage(icon, context, DEFAULT_MAX_SAFE_ICON_SIZE_PX, + DEFAULT_MAX_SAFE_ICON_SIZE_PX); + } + + /** + * Get the drawable from Icon using {@link ImageDecoder} if it contains a bitmap reference, or + * using {@link Icon#loadDrawable(Context)} otherwise. This will correctly apply the Icon's, + * tint, if present, to the drawable. + * + * @throws IOException if the icon could not be loaded for whichever reason + */ + @Nullable + public static Drawable resolveImage(@Nullable Icon icon, Context context, int maxWidth, + int maxHeight) { + if (icon == null) { + return null; } + + switch (icon.getType()) { + case Icon.TYPE_URI: + case Icon.TYPE_URI_ADAPTIVE_BITMAP: + Uri uri = getResolvableUri(icon); + if (uri != null) { + Drawable result = resolveImage(uri, context, maxWidth, maxHeight); + if (result != null) { + return tintDrawable(icon, result); + } + } + break; + case Icon.TYPE_RESOURCE: + Drawable result = resolveImage(icon.getResId(), context, maxWidth, maxHeight); + if (result != null) { + return tintDrawable(icon, result); + } + break; + case Icon.TYPE_BITMAP: + case Icon.TYPE_ADAPTIVE_BITMAP: + return resolveBitmapImage(icon, context, maxWidth, maxHeight); + case Icon.TYPE_DATA: // We can't really improve on raw data images. + default: + break; + } + + // Fallback to straight drawable load if we fail with more efficient approach. + try { + return icon.loadDrawable(context); + } catch (Resources.NotFoundException e) { + return null; + } + } + + /** + * Attempts to resolve the resource as a bitmap drawable constrained within max sizes. + */ + @Nullable + public static Drawable resolveImage(Uri uri, Context context, int maxWidth, int maxHeight) { + final ImageDecoder.Source source = + ImageDecoder.createSource(context.getContentResolver(), uri); + return resolveImage(source, maxWidth, maxHeight); + } + + /** + * Attempts to resolve the resource as a bitmap drawable constrained within max sizes. + * + * @return decoded drawable or null if the passed resource is not a straight bitmap + */ + @Nullable + public static Drawable resolveImage(@DrawableRes int resId, Context context, int maxWidth, + int maxHeight) { + final ImageDecoder.Source source = ImageDecoder.createSource(context.getResources(), resId); + return resolveImage(source, maxWidth, maxHeight); + } + + @Nullable + private static Drawable resolveBitmapImage(Icon icon, Context context, int maxWidth, + int maxHeight) { + Bitmap bitmap = icon.getBitmap(); + if (bitmap == null) { + return null; + } + + if (bitmap.getWidth() > maxWidth || bitmap.getHeight() > maxHeight) { + Icon smallerIcon = icon.getType() == Icon.TYPE_ADAPTIVE_BITMAP + ? Icon.createWithAdaptiveBitmap(bitmap) : Icon.createWithBitmap(bitmap); + // We don't want to modify the source icon, create a copy. + smallerIcon.setTintList(icon.getTintList()) + .setTintBlendMode(icon.getTintBlendMode()) + .scaleDownIfNecessary(maxWidth, maxHeight); + return smallerIcon.loadDrawable(context); + } + return icon.loadDrawable(context); } - public static Drawable resolveImage(Uri uri, Context context, int maxWidth, int maxHeight) - throws IOException { - final ImageDecoder.Source source = - ImageDecoder.createSource(context.getContentResolver(), uri); - return ImageDecoder.decodeDrawable(source, (decoder, info, unused) -> { - final Size size = info.getSize(); - if (size.getWidth() > size.getHeight()) { - if (size.getWidth() > maxWidth) { - final int targetHeight = size.getHeight() * maxWidth / size.getWidth(); - decoder.setTargetSize(maxWidth, targetHeight); + @Nullable + private static Drawable tintDrawable(Icon icon, @Nullable Drawable drawable) { + if (drawable == null) { + return null; + } + + if (icon.hasTint()) { + drawable.mutate(); + drawable.setTintList(icon.getTintList()); + drawable.setTintBlendMode(icon.getTintBlendMode()); + } + + return drawable; + } + + private static Drawable resolveImage(ImageDecoder.Source source, int maxWidth, int maxHeight) { + try { + return ImageDecoder.decodeDrawable(source, (decoder, info, unused) -> { + if (maxWidth <= 0 || maxHeight <= 0) { + return; } - } else { - if (size.getHeight() > maxHeight) { - final int targetWidth = size.getWidth() * maxHeight / size.getHeight(); - decoder.setTargetSize(targetWidth, maxHeight); + + final Size size = info.getSize(); + if (size.getWidth() > size.getHeight()) { + if (size.getWidth() > maxWidth) { + final int targetHeight = size.getHeight() * maxWidth / size.getWidth(); + decoder.setTargetSize(maxWidth, targetHeight); + } + } else { + if (size.getHeight() > maxHeight) { + final int targetWidth = size.getWidth() * maxHeight / size.getHeight(); + decoder.setTargetSize(targetWidth, maxHeight); + } } - } - }); + }); + + // ImageDecoder documentation is misleading a bit - it'll throw NotFoundException + // in some cases despite it not saying so. Rethrow it as an IOException to keep + // our API contract. + } catch (IOException | Resources.NotFoundException e) { + Log.e(TAG, "Failed to load image drawable", e); + return null; + } } private static int getPowerOfTwoForSampleRatio(double ratio) { @@ -88,11 +213,12 @@ public class LocalImageResolver { } private static void onHeaderDecoded(ImageDecoder decoder, ImageDecoder.ImageInfo info, - ImageDecoder.Source source) { + int maxWidth, int maxHeight) { final Size size = info.getSize(); final int originalSize = Math.max(size.getHeight(), size.getWidth()); - final double ratio = (originalSize > MAX_SAFE_ICON_SIZE_PX) - ? originalSize * 1f / MAX_SAFE_ICON_SIZE_PX + final int maxSize = Math.max(maxWidth, maxHeight); + final double ratio = (originalSize > maxSize) + ? originalSize * 1f / maxSize : 1.0; decoder.setTargetSampleSize(getPowerOfTwoForSampleRatio(ratio)); } @@ -101,7 +227,7 @@ public class LocalImageResolver { * Gets the Uri for this icon, assuming the icon can be treated as a pure Uri. Null otherwise. */ @Nullable - public static Uri getResolvableUri(@Nullable Icon icon) { + private static Uri getResolvableUri(@Nullable Icon icon) { if (icon == null || (icon.getType() != Icon.TYPE_URI && icon.getType() != Icon.TYPE_URI_ADAPTIVE_BITMAP)) { return null; diff --git a/core/res/res/layout/notification_template_header.xml b/core/res/res/layout/notification_template_header.xml index 81a79c50c3efe..a7f2aa7cba69a 100644 --- a/core/res/res/layout/notification_template_header.xml +++ b/core/res/res/layout/notification_template_header.xml @@ -49,6 +49,8 @@ android:layout_marginStart="@dimen/notification_icon_circle_start" android:background="@drawable/notification_icon_circle" android:padding="@dimen/notification_icon_circle_padding" + android:maxDrawableWidth="@dimen/notification_icon_circle_size" + android:maxDrawableHeight="@dimen/notification_icon_circle_size" /> diff --git a/core/res/res/layout/notification_template_material_base.xml b/core/res/res/layout/notification_template_material_base.xml index c6983ae5e0453..fd787f6ea470d 100644 --- a/core/res/res/layout/notification_template_material_base.xml +++ b/core/res/res/layout/notification_template_material_base.xml @@ -45,6 +45,8 @@ android:layout_marginStart="@dimen/notification_icon_circle_start" android:background="@drawable/notification_icon_circle" android:padding="@dimen/notification_icon_circle_padding" + android:maxDrawableWidth="@dimen/notification_icon_circle_size" + android:maxDrawableHeight="@dimen/notification_icon_circle_size" /> - - diff --git a/core/res/res/values/attrs.xml b/core/res/res/values/attrs.xml index 2295090b66164..155e9034b96ff 100644 --- a/core/res/res/values/attrs.xml +++ b/core/res/res/values/attrs.xml @@ -9809,4 +9809,12 @@ of the supported locale. {@link android.app.LocaleConfig} --> + + + + + + + + diff --git a/core/res/res/values/public-staging.xml b/core/res/res/values/public-staging.xml index 2997fef00f2fe..ffcadd06899d0 100644 --- a/core/res/res/values/public-staging.xml +++ b/core/res/res/values/public-staging.xml @@ -147,6 +147,10 @@ + + + + diff --git a/core/tests/coretests/res/drawable/big_a.png b/core/tests/coretests/res/drawable/big_a.png new file mode 100644 index 0000000000000..dc059a3557a8b Binary files /dev/null and b/core/tests/coretests/res/drawable/big_a.png differ diff --git a/core/tests/coretests/res/layout/caching_icon_view_test_max_size.xml b/core/tests/coretests/res/layout/caching_icon_view_test_max_size.xml new file mode 100644 index 0000000000000..9a034466b0fd3 --- /dev/null +++ b/core/tests/coretests/res/layout/caching_icon_view_test_max_size.xml @@ -0,0 +1,24 @@ + + + + diff --git a/core/tests/coretests/res/layout/caching_icon_view_test_no_max_size.xml b/core/tests/coretests/res/layout/caching_icon_view_test_no_max_size.xml new file mode 100644 index 0000000000000..a213a977761d2 --- /dev/null +++ b/core/tests/coretests/res/layout/caching_icon_view_test_no_max_size.xml @@ -0,0 +1,22 @@ + + + + diff --git a/core/tests/coretests/src/com/android/internal/widget/CachingIconViewTest.java b/core/tests/coretests/src/com/android/internal/widget/CachingIconViewTest.java new file mode 100644 index 0000000000000..aa9620312a912 --- /dev/null +++ b/core/tests/coretests/src/com/android/internal/widget/CachingIconViewTest.java @@ -0,0 +1,259 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.internal.widget; + +import static com.google.common.truth.Truth.assertThat; + +import android.annotation.Nullable; +import android.content.Context; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.Icon; +import android.graphics.drawable.InsetDrawable; +import android.net.Uri; +import android.util.TypedValue; +import android.view.LayoutInflater; + +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.android.frameworks.coretests.R; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +@RunWith(AndroidJUnit4.class) +public class CachingIconViewTest { + + private Context mContext; + + @Before + public void setUp() { + mContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); + } + + @Test + public void invalidIcon_skipsLoadSuccessfully() { + CachingIconView view = (CachingIconView) LayoutInflater.from(mContext).inflate( + R.layout.caching_icon_view_test_max_size, null); + view.setImageIcon(Icon.createWithResource(mContext, 0x85743222)); + Drawable drawable = view.getDrawable(); + assertThat(drawable).isNull(); + } + + @Test + public void customDrawable_setImageIcon_skipsResizeSuccessfully() { + CachingIconView view = (CachingIconView) LayoutInflater.from(mContext).inflate( + R.layout.caching_icon_view_test_max_size, null); + view.setImageIcon(Icon.createWithResource(mContext, R.drawable.custom_drawable)); + Drawable drawable = view.getDrawable(); + assertThat(drawable).isInstanceOf(InsetDrawable.class); + } + + @Test + public void customDrawable_setImageIconAsync_skipsResizeSuccessfully() { + CachingIconView view = (CachingIconView) LayoutInflater.from(mContext).inflate( + R.layout.caching_icon_view_test_max_size, null); + view.setImageIconAsync(Icon.createWithResource(mContext, R.drawable.custom_drawable)).run(); + Drawable drawable = view.getDrawable(); + assertThat(drawable).isInstanceOf(InsetDrawable.class); + } + + @Test + public void customDrawable_setImageResource_skipsResizeSuccessfully() { + CachingIconView view = (CachingIconView) LayoutInflater.from(mContext).inflate( + R.layout.caching_icon_view_test_max_size, null); + view.setImageResource(R.drawable.custom_drawable); + Drawable drawable = view.getDrawable(); + assertThat(drawable).isInstanceOf(InsetDrawable.class); + } + + @Test + public void customDrawable_setImageResourceAsync_skipsResizeSuccessfully() { + CachingIconView view = (CachingIconView) LayoutInflater.from(mContext).inflate( + R.layout.caching_icon_view_test_max_size, null); + view.setImageResourceAsync(R.drawable.custom_drawable).run(); + Drawable drawable = view.getDrawable(); + assertThat(drawable).isInstanceOf(InsetDrawable.class); + } + + @Test + public void customDrawable_setImageUri_skipsResizeSuccessfully() { + CachingIconView view = (CachingIconView) LayoutInflater.from(mContext).inflate( + R.layout.caching_icon_view_test_max_size, null); + view.setImageURI(Uri.parse( + "android.resource://com.android.frameworks.coretests/" + + R.drawable.custom_drawable)); + Drawable drawable = view.getDrawable(); + assertThat(drawable).isInstanceOf(InsetDrawable.class); + } + + @Test + public void customDrawable_setImageUriAsync_skipsResizeSuccessfully() { + CachingIconView view = (CachingIconView) LayoutInflater.from(mContext).inflate( + R.layout.caching_icon_view_test_max_size, null); + view.setImageURIAsync(Uri.parse( + "android.resource://com.android.frameworks.coretests/" + + R.drawable.custom_drawable)).run(); + Drawable drawable = view.getDrawable(); + assertThat(drawable).isInstanceOf(InsetDrawable.class); + } + + @Test + public void maxDrawableDimensionsSet_setImageIcon_resizesImageIcon() { + CachingIconView view = (CachingIconView) LayoutInflater.from(mContext).inflate( + R.layout.caching_icon_view_test_max_size, null); + view.setImageIcon(Icon.createWithResource(mContext, R.drawable.big_a)); + + assertDrawableResized(view); + } + + @Test + public void maxDrawableWithNoDimensionsSet_setImageIcon_doesNotResizeImageIcon() { + CachingIconView view = (CachingIconView) LayoutInflater.from(mContext).inflate( + R.layout.caching_icon_view_test_no_max_size, null); + view.setImageIcon(Icon.createWithResource(mContext, R.drawable.big_a)); + + assertDrawableNotResized(view); + } + + @Test + public void maxDrawableDimensionsSet_setImageIconAsync_resizesImageIcon() { + CachingIconView view = (CachingIconView) LayoutInflater.from(mContext).inflate( + R.layout.caching_icon_view_test_max_size, null); + view.setImageIconAsync(Icon.createWithResource(mContext, R.drawable.big_a)).run(); + + assertDrawableResized(view); + } + + @Test + public void maxDrawableWithNoDimensionsSet_setImageIconAsync_doesNotResizeImageIcon() { + CachingIconView view = (CachingIconView) LayoutInflater.from(mContext).inflate( + R.layout.caching_icon_view_test_no_max_size, null); + view.setImageIconAsync(Icon.createWithResource(mContext, R.drawable.big_a)).run(); + + assertDrawableNotResized(view); + } + + @Test + public void maxDrawableDimensionsSet_setImageResource_resizesImageIcon() { + CachingIconView view = (CachingIconView) LayoutInflater.from(mContext).inflate( + R.layout.caching_icon_view_test_max_size, null); + view.setImageResource(R.drawable.big_a); + + assertDrawableResized(view); + } + + @Test + public void maxDrawableWithNoDimensionsSet_setImageResource_doesNotResizeImageIcon() { + CachingIconView view = (CachingIconView) LayoutInflater.from(mContext).inflate( + R.layout.caching_icon_view_test_no_max_size, null); + view.setImageResource(R.drawable.big_a); + + assertDrawableNotResized(view); + } + + @Test + public void maxDrawableDimensionsSet_setImageResourceAsync_resizesImageIcon() { + CachingIconView view = (CachingIconView) LayoutInflater.from(mContext).inflate( + R.layout.caching_icon_view_test_max_size, null); + view.setImageResourceAsync(R.drawable.big_a).run(); + + assertDrawableResized(view); + } + + @Test + public void maxDrawableWithNoDimensionsSet_setImageResourceAsync_doesNotResizeImageIcon() { + CachingIconView view = (CachingIconView) LayoutInflater.from(mContext).inflate( + R.layout.caching_icon_view_test_no_max_size, null); + view.setImageResourceAsync(R.drawable.big_a).run(); + + assertDrawableNotResized(view); + } + + @Test + public void maxDrawableDimensionsSet_setImageUri_resizesImageIcon() { + CachingIconView view = (CachingIconView) LayoutInflater.from(mContext).inflate( + R.layout.caching_icon_view_test_max_size, null); + view.setImageURI(Uri.parse( + "android.resource://com.android.frameworks.coretests/" + R.drawable.big_a)); + + assertDrawableResized(view); + } + + @Test + public void maxDrawableWithNoDimensionsSet_setImageUri_doesNotResizeImageIcon() { + CachingIconView view = (CachingIconView) LayoutInflater.from(mContext).inflate( + R.layout.caching_icon_view_test_no_max_size, null); + view.setImageURI(Uri.parse( + "android.resource://com.android.frameworks.coretests/" + R.drawable.big_a)); + + assertDrawableNotResized(view); + } + + @Test + public void maxDrawableDimensionsSet_setImageUriAsync_resizesImageIcon() { + CachingIconView view = (CachingIconView) LayoutInflater.from(mContext).inflate( + R.layout.caching_icon_view_test_max_size, null); + view.setImageURIAsync(Uri.parse( + "android.resource://com.android.frameworks.coretests/" + R.drawable.big_a)).run(); + + assertDrawableResized(view); + } + + @Test + public void maxDrawableWithNoDimensionsSet_setImageUriAsync_doesNotResizeImageIcon() { + CachingIconView view = (CachingIconView) LayoutInflater.from(mContext).inflate( + R.layout.caching_icon_view_test_no_max_size, null); + view.setImageURIAsync(Uri.parse( + "android.resource://com.android.frameworks.coretests/" + R.drawable.big_a)).run(); + + assertDrawableNotResized(view); + } + + + private void assertDrawableResized(@Nullable CachingIconView view) { + assertThat(view).isNotNull(); + int maxSize = + (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 80f, + mContext.getResources().getDisplayMetrics()); + assertThat(view.getMaxDrawableHeight()).isEqualTo(maxSize); + assertThat(view.getMaxDrawableWidth()).isEqualTo(maxSize); + + Drawable drawable = view.getDrawable(); + assertThat(drawable).isInstanceOf(BitmapDrawable.class); + BitmapDrawable bitmapDrawable = (BitmapDrawable) drawable; + assertThat(bitmapDrawable.getBitmap().getWidth()).isLessThan(maxSize + 1); + assertThat(bitmapDrawable.getBitmap().getHeight()).isLessThan(maxSize + 1); + } + + private void assertDrawableNotResized(@Nullable CachingIconView view) { + assertThat(view).isNotNull(); + int maxSize = + (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 80f, + mContext.getResources().getDisplayMetrics()); + assertThat(view.getMaxDrawableHeight()).isEqualTo(-1); + assertThat(view.getMaxDrawableWidth()).isEqualTo(-1); + + Drawable drawable = view.getDrawable(); + assertThat(drawable).isInstanceOf(BitmapDrawable.class); + BitmapDrawable bitmapDrawable = (BitmapDrawable) drawable; + assertThat(bitmapDrawable.getBitmap().getWidth()).isGreaterThan(maxSize); + assertThat(bitmapDrawable.getBitmap().getHeight()).isGreaterThan(maxSize); + } +} diff --git a/core/tests/coretests/src/com/android/internal/widget/LocalImageResolverTest.java b/core/tests/coretests/src/com/android/internal/widget/LocalImageResolverTest.java new file mode 100644 index 0000000000000..d8b37803f5685 --- /dev/null +++ b/core/tests/coretests/src/com/android/internal/widget/LocalImageResolverTest.java @@ -0,0 +1,212 @@ +/* + * Copyright (C) 2022 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.internal.widget; + +import android.content.Context; +import android.graphics.BitmapFactory; +import android.graphics.drawable.AdaptiveIconDrawable; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.ColorDrawable; +import android.graphics.drawable.Drawable; +import android.graphics.drawable.Icon; +import android.net.Uri; + +import androidx.test.internal.runner.junit4.AndroidJUnit4ClassRunner; +import androidx.test.platform.app.InstrumentationRegistry; + +import com.android.frameworks.coretests.R; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.IOException; + +@RunWith(AndroidJUnit4ClassRunner.class) +public class LocalImageResolverTest { + + private final Context mContext = InstrumentationRegistry.getInstrumentation().getContext(); + + @Test + public void resolveImage_invalidResource_returnsNull() throws IOException { + // We promise IOException in case of errors - but ImageDecode will throw NotFoundException + // in case of wrong resource. This test verifies that we throw IOException for API users. + Icon icon = Icon.createWithResource(mContext, 0x85849454); + Drawable d = LocalImageResolver.resolveImage(icon, mContext); + assertThat(d).isNull(); + } + + @Test + public void resolveImage_invalidIconUri_returnsNull() throws IOException { + // We promise IOException in case of errors - but ImageDecode will throw NotFoundException + // in case of wrong resource. This test verifies that we throw IOException for API users. + Icon icon = Icon.createWithContentUri(Uri.parse("bogus://uri")); + Drawable d = LocalImageResolver.resolveImage(icon, mContext); + assertThat(d).isNull(); + } + + @Test(expected = IOException.class) + public void resolveImage_invalidUri_throwsException() throws IOException { + Drawable d = LocalImageResolver.resolveImage(Uri.parse("bogus://uri"), mContext); + assertThat(d).isNull(); + } + + @Test + public void resolveImage_nonBitmapResourceIcon_fallsBackToNonResizingLoad() throws IOException { + Icon icon = Icon.createWithResource(mContext, R.drawable.blue); + Drawable d = LocalImageResolver.resolveImage(icon, mContext); + assertThat(d).isInstanceOf(ColorDrawable.class); + } + + @Test(expected = IOException.class) + public void resolveImage_nonBitmapResourceUri_throwsIoException() throws IOException { + LocalImageResolver.resolveImage( + Uri.parse("android.resource://com.android.frameworks.coretests/" + R.drawable.blue), + mContext); + } + + @Test + public void resolveImageWithResId_nonBitmapResourceIcon_returnsNull() { + Drawable d = LocalImageResolver.resolveImage(R.drawable.blue, mContext, 480, 480); + assertThat(d).isNull(); + } + + @Test + public void resolveImage_largeBitmapIcon_defaultSize_resizeToDefaultSize() throws + IOException { + Icon icon = Icon.createWithBitmap( + BitmapFactory.decodeResource(mContext.getResources(), R.drawable.big_a)); + Drawable d = LocalImageResolver.resolveImage(icon, mContext); + + assertThat(d).isInstanceOf(BitmapDrawable.class); + BitmapDrawable bd = (BitmapDrawable) d; + // No isLessOrEqualThan sadly. + assertThat(bd.getBitmap().getWidth()).isLessThan( + LocalImageResolver.DEFAULT_MAX_SAFE_ICON_SIZE_PX + 1); + assertThat(bd.getBitmap().getHeight()).isLessThan( + LocalImageResolver.DEFAULT_MAX_SAFE_ICON_SIZE_PX + 1); + } + + @Test + public void resolveImage_largeAdaptiveBitmapIcon_defaultSize_resizeToDefaultSize() throws + IOException { + Icon icon = Icon.createWithAdaptiveBitmap( + BitmapFactory.decodeResource(mContext.getResources(), R.drawable.big_a)); + Drawable d = LocalImageResolver.resolveImage(icon, mContext); + + assertThat(d).isInstanceOf(AdaptiveIconDrawable.class); + BitmapDrawable bd = (BitmapDrawable) ((AdaptiveIconDrawable) d).getForeground(); + // No isLessOrEqualThan sadly. + assertThat(bd.getBitmap().getWidth()).isLessThan( + LocalImageResolver.DEFAULT_MAX_SAFE_ICON_SIZE_PX + 1); + assertThat(bd.getBitmap().getHeight()).isLessThan( + LocalImageResolver.DEFAULT_MAX_SAFE_ICON_SIZE_PX + 1); + } + + @Test + public void resolveImage_largeResourceIcon_defaultSize_resizeToDefaultSize() throws + IOException { + Icon icon = Icon.createWithResource(mContext, R.drawable.big_a); + Drawable d = LocalImageResolver.resolveImage(icon, mContext); + + assertThat(d).isInstanceOf(BitmapDrawable.class); + BitmapDrawable bd = (BitmapDrawable) d; + // No isLessOrEqualThan sadly. + assertThat(bd.getBitmap().getWidth()).isLessThan( + LocalImageResolver.DEFAULT_MAX_SAFE_ICON_SIZE_PX + 1); + assertThat(bd.getBitmap().getHeight()).isLessThan( + LocalImageResolver.DEFAULT_MAX_SAFE_ICON_SIZE_PX + 1); + } + + @Test + public void resolveImage_largeResourceIcon_passedSize_resizeToDefinedSize() { + Icon icon = Icon.createWithResource(mContext, R.drawable.big_a); + Drawable d = LocalImageResolver.resolveImage(icon, mContext, 100, 50); + + assertThat(d).isInstanceOf(BitmapDrawable.class); + BitmapDrawable bd = (BitmapDrawable) d; + assertThat(bd.getBitmap().getWidth()).isLessThan(101); + assertThat(bd.getBitmap().getHeight()).isLessThan(51); + } + + @Test + public void resolveImage_largeBitmapIcon_passedSize_resizeToDefinedSize() { + Icon icon = Icon.createWithBitmap( + BitmapFactory.decodeResource(mContext.getResources(), R.drawable.big_a)); + Drawable d = LocalImageResolver.resolveImage(icon, mContext, 100, 50); + + assertThat(d).isInstanceOf(BitmapDrawable.class); + BitmapDrawable bd = (BitmapDrawable) d; + assertThat(bd.getBitmap().getWidth()).isLessThan(101); + assertThat(bd.getBitmap().getHeight()).isLessThan(51); + } + + @Test + public void resolveImage_largeAdaptiveBitmapIcon_passedSize_resizeToDefinedSize() { + Icon icon = Icon.createWithAdaptiveBitmap( + BitmapFactory.decodeResource(mContext.getResources(), R.drawable.big_a)); + Drawable d = LocalImageResolver.resolveImage(icon, mContext, 100, 50); + + assertThat(d).isInstanceOf(AdaptiveIconDrawable.class); + BitmapDrawable bd = (BitmapDrawable) ((AdaptiveIconDrawable) d).getForeground(); + assertThat(bd.getBitmap().getWidth()).isLessThan(101); + assertThat(bd.getBitmap().getHeight()).isLessThan(51); + } + + + @Test + public void resolveImage_smallResourceIcon_defaultSize_untouched() throws IOException { + Icon icon = Icon.createWithResource(mContext, R.drawable.test32x24); + Drawable d = LocalImageResolver.resolveImage(icon, mContext); + + assertThat(d).isInstanceOf(BitmapDrawable.class); + BitmapDrawable bd = (BitmapDrawable) d; + assertThat(bd.getBitmap().getWidth()).isEqualTo(32); + assertThat(bd.getBitmap().getHeight()).isEqualTo(24); + } + + @Test + public void resolveImage_smallBitmapIcon_defaultSize_untouched() throws IOException { + Icon icon = Icon.createWithBitmap( + BitmapFactory.decodeResource(mContext.getResources(), R.drawable.test32x24)); + final int originalWidth = icon.getBitmap().getWidth(); + final int originalHeight = icon.getBitmap().getHeight(); + + Drawable d = LocalImageResolver.resolveImage(icon, mContext); + + assertThat(d).isInstanceOf(BitmapDrawable.class); + BitmapDrawable bd = (BitmapDrawable) d; + assertThat(bd.getBitmap().getWidth()).isEqualTo(originalWidth); + assertThat(bd.getBitmap().getHeight()).isEqualTo(originalHeight); + } + + @Test + public void resolveImage_smallAdaptiveBitmapIcon_defaultSize_untouched() throws IOException { + Icon icon = Icon.createWithAdaptiveBitmap( + BitmapFactory.decodeResource(mContext.getResources(), R.drawable.test32x24)); + final int originalWidth = icon.getBitmap().getWidth(); + final int originalHeight = icon.getBitmap().getHeight(); + + Drawable d = LocalImageResolver.resolveImage(icon, mContext); + assertThat(d).isInstanceOf(AdaptiveIconDrawable.class); + BitmapDrawable bd = (BitmapDrawable) ((AdaptiveIconDrawable) d).getForeground(); + assertThat(bd.getBitmap().getWidth()).isEqualTo(originalWidth); + assertThat(bd.getBitmap().getHeight()).isEqualTo(originalHeight); + + } +}