Posted at

Android 8.0のNotification Badgesの仕組み

More than 1 year has passed since last update.


まえおき

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

前編ではApp Shortcutのことを書いたので、後編のこの記事では、Notification Badgesの方をみていきます。

前編を読んでいない方は、まとめだけでも読んでみて下さい :v:


Notification Badges

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

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


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というクラスがセットしているようです。


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のバッジを管理しているビューモデルを見てみます。


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 ってやってます。


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

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


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 }

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


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というと、サポートライブラリにあるやつを思い出しますね。


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さえ実装すればランチャーアプリじゃなくても実装できそう

という感じでした。