1. YusukeIwaki

    Posted

    YusukeIwaki
Changes in title
+Android 8.0のNotification Badgesの仕組み
Changes in tags
Changes in body
Source | HTML | Preview
@@ -0,0 +1,433 @@
+# まえおき
+
+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()` )
+
+で全てを満たす(通知チャネルがないときは上の2つを満たす)ときに、NotificationRecordのmShowBadgeがOnになる、ということです。
+
+上記3つは、いずれも設定アプリからユーザーが設定するやつです。
+あんまりフレームワーク関係ないので、今回は深くは書きません。気になる人は自分で調べてみましょう。
+
+## 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さえ実装すればランチャーアプリじゃなくても実装できそう
+
+という感じでした。