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

Commit b11f2deb authored by Dan Sandler's avatar Dan Sandler
Browse files

Add importance indicator to conversation icons.

We're also drawing these icons a little differently than the
launcher does, both to accommodate the importance marker, as
well as remove the shadows that are useful for separation
vs. user wallpaper but are not appropriate for the
notification surface or settings screens.

Bug: 149747760
Test: make RunSettingsLibRoboTests
test: atest NotificationConversationInfoTest
Test: manual in notifications UI
Change-Id: I3f73c025b1d32194e3d5147d03cb781e370f2a1b
parent 9577cd78
Loading
Loading
Loading
Loading
+3 −0
Original line number Diff line number Diff line
@@ -39,4 +39,7 @@

    <color name="dark_mode_icon_color_single_tone">#99000000</color>
    <color name="light_mode_icon_color_single_tone">#ffffff</color>

    <!-- Yellow 600, used for highlighting "important" conversations in settings & notifications -->
    <color name="important_conversation">#f9ab00</color>
</resources>
+145 −38
Original line number Diff line number Diff line
@@ -15,32 +15,48 @@
 */
package com.android.settingslib.notification;

import android.annotation.ColorInt;
import android.content.Context;
import android.content.pm.ApplicationInfo;
import android.content.pm.LauncherApps;
import android.content.pm.PackageManager;
import android.content.pm.ShortcutInfo;
import android.graphics.Bitmap;
import android.graphics.Canvas;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.ColorFilter;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import android.os.UserHandle;
import android.util.IconDrawableFactory;
import android.util.Log;

import com.android.launcher3.icons.BaseIconFactory;
import com.android.launcher3.icons.BitmapInfo;
import com.android.launcher3.icons.ShadowGenerator;
import com.android.settingslib.R;

/**
 * Factory for creating normalized conversation icons.
 * We are not using Launcher's IconFactory because conversation rendering only runs on the UI
 * thread, so there is no need to manage a pool across multiple threads.
 * thread, so there is no need to manage a pool across multiple threads. Launcher's rendering
 * also includes shadows, which are only appropriate on top of wallpaper, not embedded in UI.
 */
public class ConversationIconFactory extends BaseIconFactory {
    // Geometry of the various parts of the design. All values are 1dp on a 48x48dp icon grid.
    // Space is left around the "head" (main avatar) for
    // ........
    // .HHHHHH.
    // .HHHrrrr
    // .HHHrBBr
    // ....rrrr

    private static final float BASE_ICON_SIZE = 48f;
    private static final float RING_STROKE_WIDTH = 2f;
    private static final float HEAD_SIZE = BASE_ICON_SIZE - RING_STROKE_WIDTH * 2 - 2; // 40
    private static final float BADGE_SIZE = HEAD_SIZE * 0.4f; // 16

    final LauncherApps mLauncherApps;
    final PackageManager mPackageManager;
    final IconDrawableFactory mIconDrawableFactory;
    private int mImportantConversationColor;

    public ConversationIconFactory(Context context, LauncherApps la, PackageManager pm,
            IconDrawableFactory iconDrawableFactory, int iconSizePx) {
@@ -49,65 +65,156 @@ public class ConversationIconFactory extends BaseIconFactory {
        mLauncherApps = la;
        mPackageManager = pm;
        mIconDrawableFactory = iconDrawableFactory;
        mImportantConversationColor = context.getResources().getColor(
                R.color.important_conversation, null);
    }

    private int getBadgeSize() {
        return mContext.getResources().getDimensionPixelSize(
                com.android.launcher3.icons.R.dimen.profile_badge_size);
    }
    /**
     * Returns the conversation info drawable
     */
    private Drawable getConversationDrawable(ShortcutInfo shortcutInfo) {
    private Drawable getBaseIconDrawable(ShortcutInfo shortcutInfo) {
        return mLauncherApps.getShortcutIconDrawable(shortcutInfo, mFillResIconDpi);
    }

    /**
     * Get the {@link Drawable} that represents the app icon
     * Get the {@link Drawable} that represents the app icon, badged with the work profile icon
     * if appropriate.
     */
    private Drawable getBadgedIcon(String packageName, int userId) {
    private Drawable getAppBadge(String packageName, int userId) {
        Drawable badge = null;
        try {
            final ApplicationInfo appInfo = mPackageManager.getApplicationInfoAsUser(
                    packageName, PackageManager.GET_META_DATA, userId);
            return mIconDrawableFactory.getBadgedIcon(appInfo, userId);
            badge = mIconDrawableFactory.getBadgedIcon(appInfo, userId);
        } catch (PackageManager.NameNotFoundException e) {
            return mPackageManager.getDefaultActivityIcon();
            badge = mPackageManager.getDefaultActivityIcon();
        }
        return badge;
    }

    /**
     * Turns a Drawable into a Bitmap
     * Returns a {@link Drawable} for the entire conversation. The shortcut icon will be badged
     * with the launcher icon of the app specified by packageName.
     */
    BitmapInfo toBitmap(Drawable userBadgedAppIcon) {
        Bitmap bitmap = createIconBitmap(
                userBadgedAppIcon, 1f, getBadgeSize());
    public Drawable getConversationDrawable(ShortcutInfo info, String packageName, int uid,
            boolean important) {
        return getConversationDrawable(getBaseIconDrawable(info), packageName, uid, important);
    }

        Canvas c = new Canvas();
        ShadowGenerator shadowGenerator = new ShadowGenerator(getBadgeSize());
        c.setBitmap(bitmap);
        shadowGenerator.recreateIcon(Bitmap.createBitmap(bitmap), c);
        return createIconBitmap(bitmap);
    /**
     * Returns a {@link Drawable} for the entire conversation. The drawable will be badged
     * with the launcher icon of the app specified by packageName.
     */
    public Drawable getConversationDrawable(Drawable baseIcon, String packageName, int uid,
            boolean important) {
        return new ConversationIconDrawable(baseIcon,
                getAppBadge(packageName, UserHandle.getUserId(uid)),
                mIconBitmapSize,
                mImportantConversationColor,
                important);
    }

    /**
     * Returns a {@link BitmapInfo} for the entire conversation icon including the badge.
     * Custom Drawable that overlays a badge drawable (e.g. notification small icon or app icon) on
     * a base icon (conversation/person avatar), plus decorations indicating conversation
     * importance.
     */
    public Bitmap getConversationBitmap(ShortcutInfo info, String packageName, int uid) {
        return getConversationBitmap(getConversationDrawable(info), packageName, uid);
    public static class ConversationIconDrawable extends Drawable {
        private Drawable mBaseIcon;
        private Drawable mBadgeIcon;
        private int mIconSize;
        private Paint mRingPaint;
        private boolean mShowRing;

        public ConversationIconDrawable(Drawable baseIcon,
                Drawable badgeIcon,
                int iconSize,
                @ColorInt int ringColor,
                boolean showImportanceRing) {
            mBaseIcon = baseIcon;
            mBadgeIcon = badgeIcon;
            mIconSize = iconSize;
            mShowRing = showImportanceRing;
            mRingPaint = new Paint();
            mRingPaint.setStyle(Paint.Style.STROKE);
            mRingPaint.setColor(ringColor);
        }

        /**
     * Returns a {@link BitmapInfo} for the entire conversation icon including the badge.
         * Show or hide the importance ring.
         */
    public Bitmap getConversationBitmap(Drawable baseIcon, String packageName, int uid) {
        int userId = UserHandle.getUserId(uid);
        Drawable badge = getBadgedIcon(packageName, userId);
        BitmapInfo iconInfo = createBadgedIconBitmap(baseIcon,
                UserHandle.of(userId),
                true /* shrinkNonAdaptiveIcons */);

        badgeWithDrawable(iconInfo.icon,
                new BitmapDrawable(mContext.getResources(), toBitmap(badge).icon));
        return iconInfo.icon;
        public void setImportant(boolean important) {
            if (important != mShowRing) {
                mShowRing = important;
                invalidateSelf();
            }
        }

        @Override
        public int getIntrinsicWidth() {
            return mIconSize;
        }

        @Override
        public int getIntrinsicHeight() {
            return mIconSize;
        }

        // Similar to badgeWithDrawable, but relying on the bounds of each underlying drawable
        @Override
        public void draw(Canvas canvas) {
            final Rect bounds = getBounds();

            // scale to our internal 48x48 grid
            final float scale = bounds.width() / BASE_ICON_SIZE;
            final int centerX = bounds.centerX();
            final int centerY = bounds.centerX();
            final int ringStrokeWidth = (int) (RING_STROKE_WIDTH * scale);
            final int headSize = (int) (HEAD_SIZE * scale);
            final int badgeSize = (int) (BADGE_SIZE * scale);

            if (mBaseIcon != null) {
                mBaseIcon.setBounds(
                        centerX - headSize / 2,
                        centerY - headSize / 2,
                        centerX + headSize / 2,
                        centerY + headSize / 2);
                mBaseIcon.draw(canvas);
            } else {
                Log.w("ConversationIconFactory", "ConversationIconDrawable has null base icon");
            }
            if (mBadgeIcon != null) {
                mBadgeIcon.setBounds(
                        bounds.right - badgeSize - ringStrokeWidth,
                        bounds.bottom - badgeSize - ringStrokeWidth,
                        bounds.right - ringStrokeWidth,
                        bounds.bottom - ringStrokeWidth);
                mBadgeIcon.draw(canvas);
            } else {
                Log.w("ConversationIconFactory", "ConversationIconDrawable has null badge icon");
            }
            if (mShowRing) {
                mRingPaint.setStrokeWidth(ringStrokeWidth);
                final float radius = badgeSize * 0.5f + ringStrokeWidth * 0.5f; // stroke outside
                final float cx = bounds.right - badgeSize * 0.5f - ringStrokeWidth;
                final float cy = bounds.bottom - badgeSize * 0.5f - ringStrokeWidth;
                canvas.drawCircle(cx, cy, radius, mRingPaint);
            }
        }

        @Override
        public void setAlpha(int alpha) {
            // unimplemented
        }

        @Override
        public void setColorFilter(ColorFilter colorFilter) {
            // unimplemented
        }

        @Override
        public int getOpacity() {
            return 0;
        }
    }
}
+6 −2
Original line number Diff line number Diff line
@@ -324,8 +324,9 @@ public class NotificationConversationInfo extends LinearLayout implements
    private void bindIcon() {
        ImageView image = findViewById(R.id.conversation_icon);
        if (mShortcutInfo != null) {
            image.setImageBitmap(mIconFactory.getConversationBitmap(
                    mShortcutInfo, mPackageName, mAppUid));
            image.setImageDrawable(mIconFactory.getConversationDrawable(
                    mShortcutInfo, mPackageName, mAppUid,
                    mNotificationChannel.isImportantConversation()));
        } else {
            if (mSbn.getNotification().extras.getBoolean(EXTRA_IS_GROUP_CONVERSATION, false)) {
                // TODO: maybe use a generic group icon, or a composite of recent senders
@@ -479,6 +480,9 @@ public class NotificationConversationInfo extends LinearLayout implements
                    mContext.getString(R.string.notification_conversation_mute));
            mute.setImageResource(R.drawable.ic_notifications_silence);
        }

        // update icon in case importance has changed
        bindIcon();
    }

    private void updateChannel() {
+1 −1
Original line number Diff line number Diff line
@@ -390,7 +390,7 @@ public class NotificationGutsManager implements Dumpable, NotificationLifetimeEx
            };
        }
        ConversationIconFactory iconFactoryLoader = new ConversationIconFactory(mContext,
                launcherApps, pmUser, IconDrawableFactory.newInstance(mContext),
                launcherApps, pmUser, IconDrawableFactory.newInstance(mContext, false),
                mContext.getResources().getDimensionPixelSize(
                        R.dimen.notification_guts_conversation_icon_size));

+7 −6
Original line number Diff line number Diff line
@@ -30,6 +30,7 @@ import static junit.framework.Assert.assertEquals;
import static junit.framework.Assert.assertFalse;
import static junit.framework.Assert.assertTrue;

import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.Mockito.any;
import static org.mockito.Mockito.anyInt;
import static org.mockito.Mockito.anyString;
@@ -53,8 +54,7 @@ import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.pm.ShortcutInfo;
import android.content.pm.ShortcutManager;
import android.graphics.Bitmap;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.Icon;
import android.os.UserHandle;
import android.provider.Settings;
@@ -117,7 +117,7 @@ public class NotificationConversationInfoTest extends SysuiTestCase {
    @Mock
    private ShortcutInfo mShortcutInfo;
    @Mock
    private Bitmap mImage;
    private Drawable mIconDrawable;

    @Rule
    public MockitoRule mockito = MockitoJUnit.rule();
@@ -183,8 +183,9 @@ public class NotificationConversationInfoTest extends SysuiTestCase {
        when(mShortcutInfo.getShortLabel()).thenReturn("Convo name");
        List<ShortcutInfo> shortcuts = Arrays.asList(mShortcutInfo);
        when(mLauncherApps.getShortcuts(any(), any())).thenReturn(shortcuts);
        when(mIconFactory.getConversationBitmap(any(ShortcutInfo.class), anyString(), anyInt()))
                .thenReturn(mImage);
        when(mIconFactory.getConversationDrawable(
                any(ShortcutInfo.class), anyString(), anyInt(), anyBoolean()))
                .thenReturn(mIconDrawable);

        mNotificationChannel = new NotificationChannel(
                TEST_CHANNEL, TEST_CHANNEL_NAME, IMPORTANCE_LOW);
@@ -233,7 +234,7 @@ public class NotificationConversationInfoTest extends SysuiTestCase {
                mIconFactory,
                true);
        final ImageView view = mNotificationInfo.findViewById(R.id.conversation_icon);
        assertEquals(mImage, ((BitmapDrawable) view.getDrawable()).getBitmap());
        assertEquals(mIconDrawable, view.getDrawable());
    }

    @Test