From 6f2b34baf854be996dde5b7aebe4ae2ec4f53d2f Mon Sep 17 00:00:00 2001 From: Jernej Virag Date: Wed, 16 Mar 2022 09:25:55 +0000 Subject: [PATCH] Downscale large bitmaps in CachingIconView CachingIconView is used to displayed (smallish) icons in Notifications. Those can accidentally be made very large if big resources are used. This makes CachingIconView use LocalImageResolver to load those images with limited size. This fixes large memory use of notification header icons. Bug: 210690571 Bug: 218845090 Test: atest CachingIconViewTest atest NotificationManagerTest Verified with notification test APK on a Pixel Change-Id: Ia37c1fd31a7720e6ec2fd20d7466d8dcaffa6507 --- .../internal/widget/CachingIconView.java | 110 +++++++- core/res/res/values/attrs.xml | 8 + core/res/res/values/public-staging.xml | 4 + .../caching_icon_view_test_max_size.xml | 24 ++ .../caching_icon_view_test_no_max_size.xml | 22 ++ .../internal/widget/CachingIconViewTest.java | 259 ++++++++++++++++++ 6 files changed, 420 insertions(+), 7 deletions(-) create mode 100644 core/tests/coretests/res/layout/caching_icon_view_test_max_size.xml create mode 100644 core/tests/coretests/res/layout/caching_icon_view_test_no_max_size.xml create mode 100644 core/tests/coretests/src/com/android/internal/widget/CachingIconViewTest.java 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/res/res/values/attrs.xml b/core/res/res/values/attrs.xml index 2107f651eade6..b3203aebcb993 100644 --- a/core/res/res/values/attrs.xml +++ b/core/res/res/values/attrs.xml @@ -9807,4 +9807,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 e35d2e9f488a3..1d7e181b7a8ff 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/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); + } +}