diff --git a/core/java/com/android/internal/widget/LocalImageResolver.java b/core/java/com/android/internal/widget/LocalImageResolver.java index 616b69961b799..66a3ff950577b 100644 --- a/core/java/com/android/internal/widget/LocalImageResolver.java +++ b/core/java/com/android/internal/widget/LocalImageResolver.java @@ -16,21 +16,25 @@ package com.android.internal.widget; +import android.annotation.DrawableRes; import android.annotation.Nullable; import android.content.Context; +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.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; + @VisibleForTesting + static final int DEFAULT_MAX_SAFE_ICON_SIZE_PX = 480; /** * Resolve an image from the given Uri using {@link ImageDecoder} @@ -38,9 +42,9 @@ public class LocalImageResolver { 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; + return ImageDecoder.decodeDrawable(source, + (decoder, info, s) -> LocalImageResolver.onHeaderDecoded(decoder, info, + DEFAULT_MAX_SAFE_ICON_SIZE_PX, DEFAULT_MAX_SAFE_ICON_SIZE_PX)); } /** @@ -48,17 +52,49 @@ public class LocalImageResolver { * using {@link Icon#loadDrawable(Context)} otherwise. This will correctly apply the Icon's, * tint, if present, to the drawable. */ - 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; + 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 Uri, or + * using {@link Icon#loadDrawable(Context)} otherwise. This will correctly apply the Icon's, + * tint, if present, to the drawable. + */ + @Nullable + public static Drawable resolveImage(@Nullable Icon icon, Context context, int maxWidth, + int maxHeight) + throws IOException { + 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); + 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. return icon.loadDrawable(context); } @@ -66,7 +102,71 @@ public class LocalImageResolver { throws IOException { 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) + throws IOException { + final ImageDecoder.Source source = ImageDecoder.createSource(context.getResources(), resId); + // It's possible that the resource isn't an actual bitmap drawable so this decode can fail. + // Return null in that case. + try { + return resolveImage(source, maxWidth, maxHeight); + } catch (ImageDecoder.DecodeException e) { + return null; + } + } + + @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); + } + + @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) + throws IOException { return ImageDecoder.decodeDrawable(source, (decoder, info, unused) -> { + if (maxWidth <= 0 || maxHeight <= 0) { + return; + } + final Size size = info.getSize(); if (size.getWidth() > size.getHeight()) { if (size.getWidth() > maxWidth) { @@ -88,11 +188,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)); } 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/src/com/android/internal/widget/LocalImageResolverTest.java b/core/tests/coretests/src/com/android/internal/widget/LocalImageResolverTest.java new file mode 100644 index 0000000000000..8dcb4a27b24d8 --- /dev/null +++ b/core/tests/coretests/src/com/android/internal/widget/LocalImageResolverTest.java @@ -0,0 +1,169 @@ +/* + * 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.Drawable; +import android.graphics.drawable.Icon; + +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_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() throws + IOException { + 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() throws + IOException { + 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() throws + IOException { + 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); + + } +}