Android 8.0のNotification Badgesの仕組み

# まえおき

Android 7.1から利用できるApp shortcuts、Android 8.0から利用できるNotification Badges、いずれも、Androidフレームワークとランチャーアプリのコラボで実現されている機能です。

[前編](https://qiita.com/YusukeIwaki/items/630aef6b0bf1fed93f7f)ではApp Shortcutのことを書いたので、後編のこの記事では、Notification Badgesの方をみていきます。
前編を読んでいない方は、[まとめ](https://qiita.com/YusukeIwaki/items/630aef6b0bf1fed93f7f#shortcutmanager%E3%81%BE%E3%81%A8%E3%82%81)だけでも読んでみて下さい :v:

# Notification Badges

[<img src="https://qiita-image-store.s3.amazonaws.com/0/74571/480a66d9-bc88-ad66-addc-377eaa286a4d.png" width=260 />](https://developer.android.com/guide/topics/ui/notifiers/notifications.html?hl=en#Badges)

ショートカットのときと違って、 **通知**バッジなので、システムサービスが一つ増えているわけではなさそうです。

さっそくNotificationRecordを見てみると・・・

```java:frameworks/base/services/core/java/com/android/server/notification/NotificationRecord.java
     65 /**
     66  * Holds data about notifications that should not be shared with the
     67  * {@link android.service.notification.NotificationListenerService}s.
     68  *
     69  * <p>These objects should not be mutated unless the code is synchronized
     70  * on {@link NotificationManagerService#mNotificationLock}, and any
     71  * modification should be followed by a sorting of that list.</p>
     72  *
     73  * <p>Is sortable by {@link NotificationComparator}.</p>
     74  *
     75  * {@hide}
     76  */
     77 public final class NotificationRecord {
     :
     :
    132     private boolean mShowBadge;
     :
     :
    846     public void setShowBadge(boolean showBadge) {
    847         mShowBadge = showBadge;
    848     }
    849 
    850     public boolean canShowBadge() {
    851         return mShowBadge;
    852     }
```

mShowBadgeっていうのが増えているじゃないか！

ということで、なんとなく

* 各アプリはNotificationManagerとかNotificationChannelとかを駆使して、いい感じに mShowBadgeのtrue/falseを付けた状態のNotificationを作るんだろう
* ランチャーアプリはきっと通知の監視をしていて、mShowBadgeがtrueのものが通知されたら、ランチャーアイコンにバッジを付けるんだろう

くらいは予想がつきます。

いっぽうで、

* バッジの見せ方（色や形）は各アプリで固有実装なのかな？
    * 各アプリからバッジの色指定はできないのかな？
    * ランチャーアプリ向けの補助クラスは提供されてないのかな？

あたりは気になります。

順に見ていきましょう。

## NotificationRecordのmShowBadgeのtrue/falseはどうやって決まるか

NotificationManagerServiceが直接指定してるのかと思いきやそうではなく、RankingHelperっていう補助クラスを介して、BadgeExtractorというクラスがセットしているようです。

```java:frameworks/base/services/core/java/com/android/server/notification/BadgeExtractor.java
     21 /**
     22  * Determines whether a badge should be shown for this notification
     23  */
     24 public class BadgeExtractor implements NotificationSignalExtractor {
     25     private static final String TAG = "BadgeExtractor";
     26     private static final boolean DBG = false;
     27 
     28     private RankingConfig mConfig;
     29 
     30     public void initialize(Context ctx, NotificationUsageStats usageStats) {
     31         if (DBG) Slog.d(TAG, "Initializing  " + getClass().getSimpleName() + ".");
     32     }
     33 
     34     public RankingReconsideration process(NotificationRecord record) {
     35         if (record == null || record.getNotification() == null) {
     36             if (DBG) Slog.d(TAG, "skipping empty notification");
     37             return null;
     38         }
     39 
     40         if (mConfig == null) {
     41             if (DBG) Slog.d(TAG, "missing config");
     42             return null;
     43         }
     44         boolean userWantsBadges = mConfig.badgingEnabled(record.sbn.getUser());
     45         boolean appCanShowBadge =
     46                 mConfig.canShowBadge(record.sbn.getPackageName(), record.sbn.getUid());
     47         if (!userWantsBadges || !appCanShowBadge) {
     48             record.setShowBadge(false);
     49         } else {
     50             if (record.getChannel() != null) {
     51                 record.setShowBadge(record.getChannel().canShowBadge() && appCanShowBadge);
     52             } else {
     53                 record.setShowBadge(appCanShowBadge);
     54             }
     55         }
     56 
     57         return null;
     58     }
     59 
     60     @Override
     61     public void setConfig(RankingConfig config) {
     62         mConfig = config;
     63     }
     64 }
```

ソースはかなり読みやすいですね。

* ユーザーがバッジを許可している (userWantsBadges)
* ユーザが、当該アプリのバッジ表示を許可している (appCanShowBadge)
* 通知するアプリ側が利用するNotificationChannelでバッジの表示が許可されている （ `record.getChannel().canShowBadge()` ）

で全てを満たす（通知チャネルがないときは上の２つを満たす）ときに、NotificationRecordのmShowBadgeがOnになる、ということです。

上記３つは、いずれも設定アプリからユーザーが設定するやつです。
あんまりフレームワーク関係ないので、今回は深くは書きません。気になる人は自分で調べてみましょう。

## NotificationRecordのmShowBadgeをランチャーアプリはどうやって見ているか

たいていのランチャーアプリは、何らかのビューモデルを写像しているだけなので、まずはAOSP Launcher3のバッジを管理しているビューモデルを見てみます。

```java:packages/apps/Launcher3/src/com/android/launcher3/badge/BadgeInfo.java
     34 /**
     35  * Contains data to be used in an icon badge.
     36  */
     37 public class BadgeInfo {
     38 
     39     public static final int MAX_COUNT = 999;
     40 
     41     /** Used to link this BadgeInfo to icons on the workspace and all apps */
     42     private PackageUserKey mPackageUserKey;
     43 
     44     /**
     45      * The keys of the notifications that this badge represents. These keys can later be
     46      * used to retrieve {@link NotificationInfo}'s.
     47      */
     48     private List<NotificationKeyData> mNotificationKeys;
     49 
     50     /**
     51      * The current sum of the counts in {@link #mNotificationKeys},
     52      * updated whenever a key is added or removed.
     53      */
     54     private int mTotalCount;
     55 
     56     /** This will only be initialized if the badge should display the notification icon. */
     57     private NotificationInfo mNotificationInfo;
     58 
     59     /**
     60      * When retrieving the notification icon, we draw it into this shader, which can be clipped
     61      * as necessary when drawn in a badge.
     62      */
     63     private Shader mNotificationIcon;
     64 
     65     public BadgeInfo(PackageUserKey packageUserKey) {
     66         mPackageUserKey = packageUserKey;
     67         mNotificationKeys = new ArrayList<>();
     68     }
```

おそらくこいつでしょう。BadgeInfoと、それを継承したFolderBadgeInfoというクラスがありました。
通知を監視して、BadgeInfoを更新して、それをレンダリング、みたいな流れで処理しているのが想像できます。

### BadgeInfoはどこで作られているか

やっぱり予想通りなんですが、 

```packages/apps/Launcher3/src/com/android/launcher3/popup/PopupDataProvider.java
     44 /**
     45  * Provides data for the popup menu that appears after long-clicking on apps.
     46  */
     47 public class PopupDataProvider implements NotificationListener.NotificationsChangedListener {
     48 
     49     private static final boolean LOGD = false;
     50     private static final String TAG = "PopupDataProvider";
     51 
     52     /** Note that these are in order of priority. */
     53     private static final SystemShortcut[] SYSTEM_SHORTCUTS = new SystemShortcut[] {
     54             new SystemShortcut.AppInfo(),
     55             new SystemShortcut.Widgets(),
     56     };
     57 
     58     private final Launcher mLauncher;
     59 
     60     /** Maps launcher activity components to their list of shortcut ids. */
     61     private MultiHashMap<ComponentKey, String> mDeepShortcutMap = new MultiHashMap<>();
     62     /** Maps packages to their BadgeInfo's . */
     63     private Map<PackageUserKey, BadgeInfo> mPackageUserToBadgeInfos = new HashMap<>();
     64 
     65     public PopupDataProvider(Launcher launcher) {
     66         mLauncher = launcher;
     67     }
     68 
     69     @Override
     70     public void onNotificationPosted(PackageUserKey postedPackageUserKey,
     71             NotificationKeyData notificationKey, boolean shouldBeFilteredOut) {
     72         BadgeInfo badgeInfo = mPackageUserToBadgeInfos.get(postedPackageUserKey);
     73         boolean badgeShouldBeRefreshed;
     74         if (badgeInfo == null) {
     75             if (!shouldBeFilteredOut) {
     76                 BadgeInfo newBadgeInfo = new BadgeInfo(postedPackageUserKey);
     77                 newBadgeInfo.addOrUpdateNotificationKey(notificationKey);
     78                 mPackageUserToBadgeInfos.put(postedPackageUserKey, newBadgeInfo);
     79                 badgeShouldBeRefreshed = true;
     80             } else {
     81                 badgeShouldBeRefreshed = false;
     82             }
     83         } else {
     84             badgeShouldBeRefreshed = shouldBeFilteredOut
     85                     ? badgeInfo.removeNotificationKey(notificationKey)
     86                     : badgeInfo.addOrUpdateNotificationKey(notificationKey);
     87             if (badgeInfo.getNotificationKeys().size() == 0) {
     88                 mPackageUserToBadgeInfos.remove(postedPackageUserKey);
     89             }
     90         }
     91         updateLauncherIconBadges(Utilities.singletonHashSet(postedPackageUserKey),
     92                 badgeShouldBeRefreshed);
     93     }
```

NotificationListenerServiceで通知を監視して、 `onNotificationPosted` 契機で新たなBadgeInfoを作って、更新して、updateLauncherIconBadges ってやってます。

## バッジの描画はどんなかんじでやっているか

まず、バッジの更新が本当に必要かをみて、

```java:packages/apps/Launcher3/src/com/android/launcher3/popup/PopupDataProvider.java
    156     /**
    157      * Updates the icons on launcher (workspace, folders, all apps) to refresh their badges.
    158      * @param updatedBadges The packages whose badges should be refreshed (either a notification was
    159      *                      added or removed, or the badge should show the notification icon).
    160      * @param shouldRefresh An optional parameter that will allow us to only refresh badges that
    161      *                      have actually changed. If a notification updated its content but not
    162      *                      its count or icon, then the badge doesn't change.
    163      */
    164     private void updateLauncherIconBadges(Set<PackageUserKey> updatedBadges,
    165             boolean shouldRefresh) {
    166         Iterator<PackageUserKey> iterator = updatedBadges.iterator();
    167         while (iterator.hasNext()) {
    168             BadgeInfo badgeInfo = mPackageUserToBadgeInfos.get(iterator.next());
    169             if (badgeInfo != null && !updateBadgeIcon(badgeInfo) && !shouldRefresh) {
    170                 // The notification icon isn't used, and the badge hasn't changed
    171                 // so there is no update to be made.
    172                 iterator.remove();
    173             }
    174         }
    175         if (!updatedBadges.isEmpty()) {
    176             mLauncher.updateIconBadges(updatedBadges);
    177         }
    178     }
    179 
    180     /**
    181      * Determines whether the badge should show a notification icon rather than a number,
    182      * and sets that icon on the BadgeInfo if so.
    183      * @param badgeInfo The badge to update with an icon (null if it shouldn't show one).
    184      * @return Whether the badge icon potentially changed (true unless it stayed null).
    185      */
    186     private boolean updateBadgeIcon(BadgeInfo badgeInfo) {
    187         boolean hadNotificationToShow = badgeInfo.hasNotificationToShow();
    188         NotificationInfo notificationInfo = null;
    189         NotificationListener notificationListener = NotificationListener.getInstanceIfConnected();
    190         if (notificationListener != null && badgeInfo.getNotificationKeys().size() >= 1) {
    191             // Look for the most recent notification that has an icon that should be shown in badge.
    192             for (NotificationKeyData notificationKeyData : badgeInfo.getNotificationKeys()) {
    193                 String notificationKey = notificationKeyData.notificationKey;
    194                 StatusBarNotification[] activeNotifications = notificationListener
    195                         .getActiveNotifications(new String[]{notificationKey});
    196                 if (activeNotifications.length == 1) {
    197                     notificationInfo = new NotificationInfo(mLauncher, activeNotifications[0]);
    198                     if (notificationInfo.shouldShowIconInBadge()) {
    199                         // Found an appropriate icon.
    200                         break;
    201                     } else {
    202                         // Keep looking.
    203                         notificationInfo = null;
    204                     }
    205                 }
    206             }
    207         }
    208         badgeInfo.setNotificationToShow(notificationInfo);
    209         return hadNotificationToShow || badgeInfo.hasNotificationToShow();
    210     }
```

本当に更新が必要なやつは再描画

```java:packages/apps/Launcher3/src/com/android/launcher3/BubbleTextView.java
    369     @Override
    370     public void onDraw(Canvas canvas) {
    371         super.onDraw(canvas);
    372         drawBadgeIfNecessary(canvas);
    373     }
    374 
    375     /**
    376      * Draws the icon badge in the top right corner of the icon bounds.
    377      * @param canvas The canvas to draw to.
    378      */
    379     protected void drawBadgeIfNecessary(Canvas canvas) {
    380         if (!mForceHideBadge && (hasBadge() || mBadgeScale > 0)) {
    381             getIconBounds(mTempIconBounds);
    382             mTempSpaceForBadgeOffset.set((getWidth() - mIconSize) / 2, getPaddingTop());
    383             final int scrollX = getScrollX();
    384             final int scrollY = getScrollY();
    385             canvas.translate(scrollX, scrollY);
    386             mBadgeRenderer.draw(canvas, mBadgePalette, mBadgeInfo, mTempIconBounds, mBadgeScale,
    387                     mTempSpaceForBadgeOffset);
    388             canvas.translate(-scrollX, -scrollY);
    389         }
    390     }
     :
     :
    521     public void applyBadgeState(ItemInfo itemInfo, boolean animate) {
    522         if (mIcon instanceof FastBitmapDrawable) {
    523             boolean wasBadged = mBadgeInfo != null;
    524             mBadgeInfo = mLauncher.getPopupDataProvider().getBadgeInfoForItem(itemInfo);
    525             boolean isBadged = mBadgeInfo != null;
    526             float newBadgeScale = isBadged ? 1f : 0;
    527             mBadgeRenderer = mLauncher.getDeviceProfile().mBadgeRenderer;
    528             if (wasBadged || isBadged) {
    529                 mBadgePalette = IconPalette.getBadgePalette(getResources());
    530                 if (mBadgePalette == null) {
    531                     mBadgePalette = ((FastBitmapDrawable) mIcon).getIconPalette();
    532                 }
    533                 // Animate when a badge is first added or when it is removed.
    534                 if (animate && (wasBadged ^ isBadged) && isShown()) {
    535                     ObjectAnimator.ofFloat(this, BADGE_SCALE_PROPERTY, newBadgeScale).start();
    536                 } else {
    537                     mBadgeScale = newBadgeScale;
    538                     invalidate();
    539                 }
    540             }
    541         }
    542     }
    543 
```

という、基本ロジックは予想通りでした。

### バッジの色・形はどうやって決めてる？

`BadgeRenderer` `BadgePalette` あたりがロジックを握ってそうです。Paletteというと、[サポートライブラリにあるやつ](https://developer.android.com/reference/android/support/v7/graphics/Palette.html)を思い出しますね。


```packages/apps/Launcher3/src/com/android/launcher3/badge/BadgeRenderer.java
     87     /**
     88      * Draw a circle in the top right corner of the given bounds, and draw
     89      * {@link BadgeInfo#getNotificationCount()} on top of the circle.
     90      * @param palette The colors (based on the icon) to use for the badge.
     91      * @param badgeInfo Contains data to draw on the badge. Could be null if we are animating out.
     92      * @param iconBounds The bounds of the icon being badged.
     93      * @param badgeScale The progress of the animation, from 0 to 1.
     94      * @param spaceForOffset How much space is available to offset the badge up and to the right.
     95      */
     96     public void draw(Canvas canvas, IconPalette palette, @Nullable BadgeInfo badgeInfo,
     97             Rect iconBounds, float badgeScale, Point spaceForOffset) {
     98         mTextPaint.setColor(palette.textColor);
     99         IconDrawer iconDrawer = badgeInfo != null && badgeInfo.isIconLarge()
    100                 ? mLargeIconDrawer : mSmallIconDrawer;
    101         Shader icon = badgeInfo == null ? null : badgeInfo.getNotificationIconForBadge(
    102                 mContext, palette.backgroundColor, mSize, iconDrawer.mPadding);
    103         String notificationCount = badgeInfo == null ? "0"
    104                 : String.valueOf(badgeInfo.getNotificationCount());
    105         int numChars = notificationCount.length();
    106         int width = DOTS_ONLY ? mSize : mSize + mCharSize * (numChars - 1);
    107         // Lazily load the background with shadow.
    108         Bitmap backgroundWithShadow = mBackgroundsWithShadow.get(numChars);
    109         if (backgroundWithShadow == null) {
    110             backgroundWithShadow = new ShadowGenerator.Builder(Color.WHITE)
    111                     .setupBlurForSize(mSize).createPill(width, mSize);
    112             mBackgroundsWithShadow.put(numChars, backgroundWithShadow);
    113         }
    114         canvas.save(Canvas.MATRIX_SAVE_FLAG);
    115         // We draw the badge relative to its center.
    116         int badgeCenterX = iconBounds.right - width / 2;
    117         int badgeCenterY = iconBounds.top + mSize / 2;
    118         boolean isText = !DOTS_ONLY && badgeInfo != null && badgeInfo.getNotificationCount() != 0;
    119         boolean isIcon = !DOTS_ONLY && icon != null;
    120         boolean isDot = !(isText || isIcon);
    121         if (isDot) {
    122             badgeScale *= DOT_SCALE;
    123         }
    124         int offsetX = Math.min(mOffset, spaceForOffset.x);
    125         int offsetY = Math.min(mOffset, spaceForOffset.y);
    126         canvas.translate(badgeCenterX + offsetX, badgeCenterY - offsetY);
    127         canvas.scale(badgeScale, badgeScale);
    128         // Prepare the background and shadow and possible stacking effect.
    129         mBackgroundPaint.setColorFilter(palette.backgroundColorMatrixFilter);
    130         int backgroundWithShadowSize = backgroundWithShadow.getHeight(); // Same as width.
    131         boolean shouldStack = !isDot && badgeInfo != null
    132                 && badgeInfo.getNotificationKeys().size() > 1;
    133         if (shouldStack) {
    134             int offsetDiffX = mStackOffsetX - mOffset;
    135             int offsetDiffY = mStackOffsetY - mOffset;
    136             canvas.translate(offsetDiffX, offsetDiffY);
    137             canvas.drawBitmap(backgroundWithShadow, -backgroundWithShadowSize / 2,
    138                     -backgroundWithShadowSize / 2, mBackgroundPaint);
    139             canvas.translate(-offsetDiffX, -offsetDiffY);
    140         }
    141 
    142         if (isText) {
    143             canvas.drawBitmap(backgroundWithShadow, -backgroundWithShadowSize / 2,
    144                     -backgroundWithShadowSize / 2, mBackgroundPaint);
    145             canvas.drawText(notificationCount, 0, mTextHeight / 2, mTextPaint);
    146         } else if (isIcon) {
    147             canvas.drawBitmap(backgroundWithShadow, -backgroundWithShadowSize / 2,
    148                     -backgroundWithShadowSize / 2, mBackgroundPaint);
    149             iconDrawer.drawIcon(icon, canvas);
    150         } else if (isDot) {
    151             mBackgroundPaint.setColorFilter(palette.saturatedBackgroundColorMatrixFilter);
    152             canvas.drawBitmap(backgroundWithShadow, -backgroundWithShadowSize / 2,
    153                     -backgroundWithShadowSize / 2, mBackgroundPaint);
    154         }
    155         canvas.restore();
    156     }
```

なんとなく、ゴリゴリ自前で書いてますね。
Androidフレームワークのサポートは特になさげ。

## Notification Badgesまとめ

* 実はNotificationRecordにmShowBadgesというフラグが付いただけ
* 監視するランチャーアプリは実装が大変。
    * バッジの見せ方（色とか形とか）は、Androidフレームワークはノータッチ（アプリ屋さん頑張ってね！）
* App shortcutsと違って、NotificationListenerServiceさえ実装すればランチャーアプリじゃなくても実装できそう

という感じでした。