Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 69832591 authored by Jernej Virag's avatar Jernej Virag
Browse files

Support downscaling of Drawable icons in LocalImageResolver

LocalImageResolver only enforced downscaling for Uri-based Icons at this point. This still allowed app developers to provide large bitmaps as a resource or as a bitmap payload itself. This change also verifies these new types of bitmaps.
Also adds an external size parameter to image resolver so different widgets can use it - right now the upper limit was hardcoded in pixels.

This also updates error handling - in some cases ImageDecoder would throw NullPointerException or NotFoundException except for promised IOException. This wraps those cases into more consistent API.

Bug:218845090
Bug:210690571
Bug:224768026

Test: Manually on device - tested on Raven and new Pixel with multiple
                       notification sizes via notification test apk.
      atest LocalImageResolver
      atest NotificationManagerTest

Change-Id: I4701d58ac3335d11a93355a39cee9f5c998eb830
parent 5220fe91
Loading
Loading
Loading
Loading
+164 −38
Original line number Diff line number Diff line
@@ -16,57 +16,174 @@

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 {
        try {
            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));
        } 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 {
    @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);
            if (icon.hasTint()) {
                result.mutate();
                result.setTintList(icon.getTintList());
                result.setTintBlendMode(icon.getTintBlendMode());
                    Drawable result = resolveImage(uri, context, maxWidth, maxHeight);
                    if (result != null) {
                        return tintDrawable(icon, result);
                    }
            return 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;
        }
    }

    public static Drawable resolveImage(Uri uri, Context context, int maxWidth, int maxHeight)
            throws IOException {
    /**
     * 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);
    }

    @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;
                }

                final Size size = info.getSize();
                if (size.getWidth() > size.getHeight()) {
                    if (size.getWidth() > maxWidth) {
@@ -80,6 +197,14 @@ public class LocalImageResolver {
                    }
                }
            });

        // 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;
+11.6 KiB
Loading image diff...
+212 −0
Original line number Diff line number Diff line
/*
 * 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);

    }
}