blob: 92927cf104a0cb26943bc0fa8a50aebc66a71668 [file] [log] [blame]
Ned Burnsf098dbf2019-09-13 19:17:53 -04001/*
2 * Copyright (C) 2019 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.systemui.statusbar.notification.collection;
18
19import static android.service.notification.NotificationListenerService.REASON_APP_CANCEL;
20import static android.service.notification.NotificationListenerService.REASON_APP_CANCEL_ALL;
21import static android.service.notification.NotificationListenerService.REASON_CANCEL_ALL;
22import static android.service.notification.NotificationListenerService.REASON_CHANNEL_BANNED;
23import static android.service.notification.NotificationListenerService.REASON_CLICK;
24import static android.service.notification.NotificationListenerService.REASON_ERROR;
25import static android.service.notification.NotificationListenerService.REASON_GROUP_OPTIMIZATION;
26import static android.service.notification.NotificationListenerService.REASON_GROUP_SUMMARY_CANCELED;
27import static android.service.notification.NotificationListenerService.REASON_LISTENER_CANCEL;
28import static android.service.notification.NotificationListenerService.REASON_LISTENER_CANCEL_ALL;
29import static android.service.notification.NotificationListenerService.REASON_PACKAGE_BANNED;
30import static android.service.notification.NotificationListenerService.REASON_PACKAGE_CHANGED;
31import static android.service.notification.NotificationListenerService.REASON_PACKAGE_SUSPENDED;
32import static android.service.notification.NotificationListenerService.REASON_PROFILE_TURNED_OFF;
33import static android.service.notification.NotificationListenerService.REASON_SNOOZED;
34import static android.service.notification.NotificationListenerService.REASON_TIMEOUT;
35import static android.service.notification.NotificationListenerService.REASON_UNAUTOBUNDLED;
36import static android.service.notification.NotificationListenerService.REASON_USER_STOPPED;
37
Ned Burnsf098dbf2019-09-13 19:17:53 -040038import android.annotation.IntDef;
39import android.annotation.MainThread;
40import android.annotation.NonNull;
41import android.annotation.Nullable;
42import android.os.RemoteException;
43import android.service.notification.NotificationListenerService.Ranking;
44import android.service.notification.NotificationListenerService.RankingMap;
45import android.service.notification.StatusBarNotification;
46import android.util.ArrayMap;
Ned Burnsf098dbf2019-09-13 19:17:53 -040047
48import com.android.internal.statusbar.IStatusBarService;
Beverlyb6f4dc22020-01-10 14:58:20 -050049import com.android.systemui.DumpController;
50import com.android.systemui.Dumpable;
Beverlybac7f002020-01-24 15:30:30 -050051import com.android.systemui.statusbar.FeatureFlags;
Ned Burns012048d2020-01-08 19:57:30 -050052import com.android.systemui.statusbar.notification.collection.coalescer.CoalescedEvent;
53import com.android.systemui.statusbar.notification.collection.coalescer.GroupCoalescer;
54import com.android.systemui.statusbar.notification.collection.coalescer.GroupCoalescer.BatchableNotificationHandler;
55import com.android.systemui.statusbar.notification.collection.notifcollection.CollectionReadyForBuildListener;
56import com.android.systemui.statusbar.notification.collection.notifcollection.DismissedByUserStats;
57import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionListener;
Ned Burns7d2e9c12020-01-21 17:47:16 -050058import com.android.systemui.statusbar.notification.collection.notifcollection.NotifCollectionLogger;
Ned Burns012048d2020-01-08 19:57:30 -050059import com.android.systemui.statusbar.notification.collection.notifcollection.NotifLifetimeExtender;
Ned Burnsf098dbf2019-09-13 19:17:53 -040060import com.android.systemui.util.Assert;
61
Beverlyb6f4dc22020-01-10 14:58:20 -050062import java.io.FileDescriptor;
63import java.io.PrintWriter;
Ned Burnsf098dbf2019-09-13 19:17:53 -040064import java.lang.annotation.Retention;
65import java.lang.annotation.RetentionPolicy;
66import java.util.ArrayList;
67import java.util.Collection;
68import java.util.Collections;
69import java.util.List;
70import java.util.Map;
Daulet Zhanguzind0549ae2020-01-03 11:08:54 +000071import java.util.Objects;
Ned Burnsf098dbf2019-09-13 19:17:53 -040072
73import javax.inject.Inject;
74import javax.inject.Singleton;
75
76/**
77 * Keeps a record of all of the "active" notifications, i.e. the notifications that are currently
78 * posted to the phone. This collection is unsorted, ungrouped, and unfiltered. Just because a
79 * notification appears in this collection doesn't mean that it's currently present in the shade
80 * (notifications can be hidden for a variety of reasons). Code that cares about what notifications
81 * are *visible* right now should register listeners later in the pipeline.
82 *
83 * Each notification is represented by a {@link NotificationEntry}, which is itself made up of two
84 * parts: a {@link StatusBarNotification} and a {@link Ranking}. When notifications are updated,
85 * their underlying SBNs and Rankings are swapped out, but the enclosing NotificationEntry (and its
86 * associated key) remain the same. In general, an SBN can only be updated when the notification is
87 * reposted by the source app; Rankings are updated much more often, usually every time there is an
88 * update from any kind from NotificationManager.
89 *
90 * In general, this collection closely mirrors the list maintained by NotificationManager, but it
91 * can occasionally diverge due to lifetime extenders (see
92 * {@link #addNotificationLifetimeExtender(NotifLifetimeExtender)}).
93 *
94 * Interested parties can register listeners
95 * ({@link #addCollectionListener(NotifCollectionListener)}) to be informed when notifications are
96 * added, updated, or removed.
97 */
98@MainThread
99@Singleton
Beverlyb6f4dc22020-01-10 14:58:20 -0500100public class NotifCollection implements Dumpable {
Ned Burnsf098dbf2019-09-13 19:17:53 -0400101 private final IStatusBarService mStatusBarService;
Beverlybac7f002020-01-24 15:30:30 -0500102 private final FeatureFlags mFeatureFlags;
Ned Burns7d2e9c12020-01-21 17:47:16 -0500103 private final NotifCollectionLogger mLogger;
Ned Burnsf098dbf2019-09-13 19:17:53 -0400104
105 private final Map<String, NotificationEntry> mNotificationSet = new ArrayMap<>();
106 private final Collection<NotificationEntry> mReadOnlyNotificationSet =
107 Collections.unmodifiableCollection(mNotificationSet.values());
108
Ned Burns77050aa2019-10-17 21:55:24 -0400109 @Nullable private CollectionReadyForBuildListener mBuildListener;
Ned Burnsf098dbf2019-09-13 19:17:53 -0400110 private final List<NotifCollectionListener> mNotifCollectionListeners = new ArrayList<>();
111 private final List<NotifLifetimeExtender> mLifetimeExtenders = new ArrayList<>();
112
113 private boolean mAttached = false;
114 private boolean mAmDispatchingToOtherCode;
115
116 @Inject
Beverlybac7f002020-01-24 15:30:30 -0500117 public NotifCollection(
118 IStatusBarService statusBarService,
119 DumpController dumpController,
Ned Burns7d2e9c12020-01-21 17:47:16 -0500120 FeatureFlags featureFlags,
121 NotifCollectionLogger logger) {
Ned Burnsf098dbf2019-09-13 19:17:53 -0400122 Assert.isMainThread();
123 mStatusBarService = statusBarService;
Ned Burns7d2e9c12020-01-21 17:47:16 -0500124 mLogger = logger;
Beverlyb6f4dc22020-01-10 14:58:20 -0500125 dumpController.registerDumpable(TAG, this);
Beverlybac7f002020-01-24 15:30:30 -0500126 mFeatureFlags = featureFlags;
Ned Burnsf098dbf2019-09-13 19:17:53 -0400127 }
128
129 /** Initializes the NotifCollection and registers it to receive notification events. */
Ned Burnsa944ea32019-12-19 17:04:19 -0500130 public void attach(GroupCoalescer groupCoalescer) {
Ned Burnsf098dbf2019-09-13 19:17:53 -0400131 Assert.isMainThread();
132 if (mAttached) {
133 throw new RuntimeException("attach() called twice");
134 }
135 mAttached = true;
136
Ned Burnsa944ea32019-12-19 17:04:19 -0500137 groupCoalescer.setNotificationHandler(mNotifHandler);
Ned Burnsf098dbf2019-09-13 19:17:53 -0400138 }
139
140 /**
141 * Sets the class responsible for converting the collection into the list of currently-visible
142 * notifications.
143 */
Ned Burns8172d3a2020-01-10 00:24:17 -0500144 void setBuildListener(CollectionReadyForBuildListener buildListener) {
Ned Burnsf098dbf2019-09-13 19:17:53 -0400145 Assert.isMainThread();
Ned Burns77050aa2019-10-17 21:55:24 -0400146 mBuildListener = buildListener;
Ned Burnsf098dbf2019-09-13 19:17:53 -0400147 }
148
Ned Burns8172d3a2020-01-10 00:24:17 -0500149 /** @see NotifPipeline#getActiveNotifs() */
150 Collection<NotificationEntry> getActiveNotifs() {
Ned Burnsf098dbf2019-09-13 19:17:53 -0400151 Assert.isMainThread();
152 return mReadOnlyNotificationSet;
153 }
154
Ned Burns8172d3a2020-01-10 00:24:17 -0500155 /** @see NotifPipeline#addCollectionListener(NotifCollectionListener) */
156 void addCollectionListener(NotifCollectionListener listener) {
Ned Burnsf098dbf2019-09-13 19:17:53 -0400157 Assert.isMainThread();
158 mNotifCollectionListeners.add(listener);
159 }
160
Ned Burns8172d3a2020-01-10 00:24:17 -0500161 /** @see NotifPipeline#addNotificationLifetimeExtender(NotifLifetimeExtender) */
162 void addNotificationLifetimeExtender(NotifLifetimeExtender extender) {
Ned Burnsf098dbf2019-09-13 19:17:53 -0400163 Assert.isMainThread();
164 checkForReentrantCall();
165 if (mLifetimeExtenders.contains(extender)) {
166 throw new IllegalArgumentException("Extender " + extender + " already added.");
167 }
168 mLifetimeExtenders.add(extender);
169 extender.setCallback(this::onEndLifetimeExtension);
170 }
171
172 /**
173 * Dismiss a notification on behalf of the user.
174 */
Ned Burns8172d3a2020-01-10 00:24:17 -0500175 void dismissNotification(
Ned Burnsf098dbf2019-09-13 19:17:53 -0400176 NotificationEntry entry,
177 @CancellationReason int reason,
178 @NonNull DismissedByUserStats stats) {
179 Assert.isMainThread();
Daulet Zhanguzind0549ae2020-01-03 11:08:54 +0000180 Objects.requireNonNull(stats);
Ned Burnsf098dbf2019-09-13 19:17:53 -0400181 checkForReentrantCall();
182
Evan Laird9afe7662019-10-16 17:16:39 -0400183 removeNotification(entry.getKey(), null, reason, stats);
Ned Burnsf098dbf2019-09-13 19:17:53 -0400184 }
185
186 private void onNotificationPosted(StatusBarNotification sbn, RankingMap rankingMap) {
187 Assert.isMainThread();
188
Ned Burnsa944ea32019-12-19 17:04:19 -0500189 postNotification(sbn, requireRanking(rankingMap, sbn.getKey()), rankingMap);
190 rebuildList();
191 }
192
193 private void onNotificationGroupPosted(List<CoalescedEvent> batch) {
194 Assert.isMainThread();
195
Ned Burns7d2e9c12020-01-21 17:47:16 -0500196 mLogger.logNotifGroupPosted(batch.get(0).getSbn().getGroupKey(), batch.size());
197
Ned Burnsa944ea32019-12-19 17:04:19 -0500198 for (CoalescedEvent event : batch) {
199 postNotification(event.getSbn(), event.getRanking(), null);
200 }
201 rebuildList();
202 }
203
204 private void onNotificationRemoved(
205 StatusBarNotification sbn,
206 RankingMap rankingMap,
207 int reason) {
208 Assert.isMainThread();
209
Ned Burns7d2e9c12020-01-21 17:47:16 -0500210 mLogger.logNotifRemoved(sbn.getKey(), reason);
Ned Burnsa944ea32019-12-19 17:04:19 -0500211 removeNotification(sbn.getKey(), rankingMap, reason, null);
212 }
213
214 private void onNotificationRankingUpdate(RankingMap rankingMap) {
215 Assert.isMainThread();
216 applyRanking(rankingMap);
217 rebuildList();
218 }
219
220 private void postNotification(
221 StatusBarNotification sbn,
222 Ranking ranking,
223 @Nullable RankingMap rankingMap) {
Ned Burnsf098dbf2019-09-13 19:17:53 -0400224 NotificationEntry entry = mNotificationSet.get(sbn.getKey());
225
226 if (entry == null) {
227 // A new notification!
Ned Burns7d2e9c12020-01-21 17:47:16 -0500228 mLogger.logNotifPosted(sbn.getKey());
Ned Burnsf098dbf2019-09-13 19:17:53 -0400229
Ned Burnsa944ea32019-12-19 17:04:19 -0500230 entry = new NotificationEntry(sbn, ranking);
Ned Burnsf098dbf2019-09-13 19:17:53 -0400231 mNotificationSet.put(sbn.getKey(), entry);
Ned Burnsa944ea32019-12-19 17:04:19 -0500232 if (rankingMap != null) {
233 applyRanking(rankingMap);
234 }
Ned Burnsf098dbf2019-09-13 19:17:53 -0400235
236 dispatchOnEntryAdded(entry);
237
238 } else {
239 // Update to an existing entry
Ned Burns7d2e9c12020-01-21 17:47:16 -0500240 mLogger.logNotifUpdated(sbn.getKey());
Ned Burnsf098dbf2019-09-13 19:17:53 -0400241
242 // Notification is updated so it is essentially re-added and thus alive again. Don't
243 // need to keep its lifetime extended.
244 cancelLifetimeExtension(entry);
245
Ned Burns00b4b2d2019-10-17 22:09:27 -0400246 entry.setSbn(sbn);
Ned Burnsa944ea32019-12-19 17:04:19 -0500247 if (rankingMap != null) {
248 applyRanking(rankingMap);
249 }
Ned Burnsf098dbf2019-09-13 19:17:53 -0400250
251 dispatchOnEntryUpdated(entry);
252 }
Ned Burnsf098dbf2019-09-13 19:17:53 -0400253 }
254
255 private void removeNotification(
256 String key,
257 @Nullable RankingMap rankingMap,
258 @CancellationReason int reason,
Ned Burnsa944ea32019-12-19 17:04:19 -0500259 @Nullable DismissedByUserStats dismissedByUserStats) {
Ned Burnsf098dbf2019-09-13 19:17:53 -0400260
261 NotificationEntry entry = mNotificationSet.get(key);
262 if (entry == null) {
263 throw new IllegalStateException("No notification to remove with key " + key);
264 }
265
266 entry.mLifetimeExtenders.clear();
267 mAmDispatchingToOtherCode = true;
268 for (NotifLifetimeExtender extender : mLifetimeExtenders) {
269 if (extender.shouldExtendLifetime(entry, reason)) {
270 entry.mLifetimeExtenders.add(extender);
271 }
272 }
273 mAmDispatchingToOtherCode = false;
274
275 if (!isLifetimeExtended(entry)) {
Evan Laird9afe7662019-10-16 17:16:39 -0400276 mNotificationSet.remove(entry.getKey());
Ned Burnsf098dbf2019-09-13 19:17:53 -0400277
278 if (dismissedByUserStats != null) {
279 try {
280 mStatusBarService.onNotificationClear(
Evan Laird9afe7662019-10-16 17:16:39 -0400281 entry.getSbn().getPackageName(),
282 entry.getSbn().getTag(),
283 entry.getSbn().getId(),
284 entry.getSbn().getUser().getIdentifier(),
285 entry.getSbn().getKey(),
Ned Burnsf098dbf2019-09-13 19:17:53 -0400286 dismissedByUserStats.dismissalSurface,
287 dismissedByUserStats.dismissalSentiment,
288 dismissedByUserStats.notificationVisibility);
289 } catch (RemoteException e) {
290 // system process is dead if we're here.
291 }
292 }
293
294 if (rankingMap != null) {
295 applyRanking(rankingMap);
296 }
297
298 dispatchOnEntryRemoved(entry, reason, dismissedByUserStats != null /* removedByUser */);
299 }
300
301 rebuildList();
302 }
303
Ned Burnsa944ea32019-12-19 17:04:19 -0500304 private void applyRanking(@NonNull RankingMap rankingMap) {
Ned Burnsf098dbf2019-09-13 19:17:53 -0400305 for (NotificationEntry entry : mNotificationSet.values()) {
306 if (!isLifetimeExtended(entry)) {
Evan Laird9afe7662019-10-16 17:16:39 -0400307 Ranking ranking = requireRanking(rankingMap, entry.getKey());
Ned Burnsf098dbf2019-09-13 19:17:53 -0400308 entry.setRanking(ranking);
Beverlyce58b4a2020-01-08 15:31:15 -0500309
310 // TODO: (b/145659174) update the sbn's overrideGroupKey in
311 // NotificationEntry.setRanking instead of here once we fully migrate to the
312 // NewNotifPipeline
Beverlybac7f002020-01-24 15:30:30 -0500313 if (mFeatureFlags.isNewNotifPipelineRenderingEnabled()) {
314 final String newOverrideGroupKey = ranking.getOverrideGroupKey();
315 if (!Objects.equals(entry.getSbn().getOverrideGroupKey(),
316 newOverrideGroupKey)) {
317 entry.getSbn().setOverrideGroupKey(newOverrideGroupKey);
318 }
Beverlyce58b4a2020-01-08 15:31:15 -0500319 }
Ned Burnsf098dbf2019-09-13 19:17:53 -0400320 }
321 }
322 }
323
324 private void rebuildList() {
Ned Burns77050aa2019-10-17 21:55:24 -0400325 if (mBuildListener != null) {
326 mBuildListener.onBuildList(mReadOnlyNotificationSet);
Ned Burnsf098dbf2019-09-13 19:17:53 -0400327 }
328 }
329
330 private void onEndLifetimeExtension(NotifLifetimeExtender extender, NotificationEntry entry) {
331 Assert.isMainThread();
332 if (!mAttached) {
333 return;
334 }
335 checkForReentrantCall();
336
337 if (!entry.mLifetimeExtenders.remove(extender)) {
338 throw new IllegalStateException(
339 String.format(
340 "Cannot end lifetime extension for extender \"%s\" (%s)",
341 extender.getName(),
342 extender));
343 }
344
345 if (!isLifetimeExtended(entry)) {
346 // TODO: This doesn't need to be undefined -- we can set either EXTENDER_EXPIRED or
347 // save the original reason
Evan Laird9afe7662019-10-16 17:16:39 -0400348 removeNotification(entry.getKey(), null, REASON_UNKNOWN, null);
Ned Burnsf098dbf2019-09-13 19:17:53 -0400349 }
350 }
351
352 private void cancelLifetimeExtension(NotificationEntry entry) {
353 mAmDispatchingToOtherCode = true;
354 for (NotifLifetimeExtender extender : entry.mLifetimeExtenders) {
355 extender.cancelLifetimeExtension(entry);
356 }
357 mAmDispatchingToOtherCode = false;
358 entry.mLifetimeExtenders.clear();
359 }
360
361 private boolean isLifetimeExtended(NotificationEntry entry) {
362 return entry.mLifetimeExtenders.size() > 0;
363 }
364
365 private void checkForReentrantCall() {
366 if (mAmDispatchingToOtherCode) {
367 throw new IllegalStateException("Reentrant call detected");
368 }
369 }
370
371 private static Ranking requireRanking(RankingMap rankingMap, String key) {
372 // TODO: Modify RankingMap so that we don't have to make a copy here
373 Ranking ranking = new Ranking();
374 if (!rankingMap.getRanking(key, ranking)) {
375 throw new IllegalArgumentException("Ranking map doesn't contain key: " + key);
376 }
377 return ranking;
378 }
379
380 private void dispatchOnEntryAdded(NotificationEntry entry) {
381 mAmDispatchingToOtherCode = true;
Ned Burnsf098dbf2019-09-13 19:17:53 -0400382 for (NotifCollectionListener listener : mNotifCollectionListeners) {
383 listener.onEntryAdded(entry);
384 }
385 mAmDispatchingToOtherCode = false;
386 }
387
388 private void dispatchOnEntryUpdated(NotificationEntry entry) {
389 mAmDispatchingToOtherCode = true;
Ned Burnsf098dbf2019-09-13 19:17:53 -0400390 for (NotifCollectionListener listener : mNotifCollectionListeners) {
391 listener.onEntryUpdated(entry);
392 }
393 mAmDispatchingToOtherCode = false;
394 }
395
396 private void dispatchOnEntryRemoved(
397 NotificationEntry entry,
398 @CancellationReason int reason,
399 boolean removedByUser) {
400 mAmDispatchingToOtherCode = true;
Ned Burnsf098dbf2019-09-13 19:17:53 -0400401 for (NotifCollectionListener listener : mNotifCollectionListeners) {
402 listener.onEntryRemoved(entry, reason, removedByUser);
403 }
404 mAmDispatchingToOtherCode = false;
405 }
406
Ned Burnsa944ea32019-12-19 17:04:19 -0500407 private final BatchableNotificationHandler mNotifHandler = new BatchableNotificationHandler() {
Ned Burnsf098dbf2019-09-13 19:17:53 -0400408 @Override
409 public void onNotificationPosted(StatusBarNotification sbn, RankingMap rankingMap) {
410 NotifCollection.this.onNotificationPosted(sbn, rankingMap);
411 }
412
413 @Override
Ned Burnsa944ea32019-12-19 17:04:19 -0500414 public void onNotificationBatchPosted(List<CoalescedEvent> events) {
415 NotifCollection.this.onNotificationGroupPosted(events);
416 }
417
418 @Override
Ned Burnsf098dbf2019-09-13 19:17:53 -0400419 public void onNotificationRemoved(StatusBarNotification sbn, RankingMap rankingMap) {
420 NotifCollection.this.onNotificationRemoved(sbn, rankingMap, REASON_UNKNOWN);
421 }
422
423 @Override
424 public void onNotificationRemoved(StatusBarNotification sbn, RankingMap rankingMap,
425 int reason) {
426 NotifCollection.this.onNotificationRemoved(sbn, rankingMap, reason);
427 }
428
429 @Override
430 public void onNotificationRankingUpdate(RankingMap rankingMap) {
431 NotifCollection.this.onNotificationRankingUpdate(rankingMap);
432 }
433 };
434
435 private static final String TAG = "NotifCollection";
436
437 @IntDef(prefix = { "REASON_" }, value = {
438 REASON_UNKNOWN,
439 REASON_CLICK,
440 REASON_CANCEL_ALL,
441 REASON_ERROR,
442 REASON_PACKAGE_CHANGED,
443 REASON_USER_STOPPED,
444 REASON_PACKAGE_BANNED,
445 REASON_APP_CANCEL,
446 REASON_APP_CANCEL_ALL,
447 REASON_LISTENER_CANCEL,
448 REASON_LISTENER_CANCEL_ALL,
449 REASON_GROUP_SUMMARY_CANCELED,
450 REASON_GROUP_OPTIMIZATION,
451 REASON_PACKAGE_SUSPENDED,
452 REASON_PROFILE_TURNED_OFF,
453 REASON_UNAUTOBUNDLED,
454 REASON_CHANNEL_BANNED,
455 REASON_SNOOZED,
456 REASON_TIMEOUT,
457 })
458 @Retention(RetentionPolicy.SOURCE)
Ned Burns012048d2020-01-08 19:57:30 -0500459 public @interface CancellationReason {}
Ned Burnsf098dbf2019-09-13 19:17:53 -0400460
461 public static final int REASON_UNKNOWN = 0;
Beverlyb6f4dc22020-01-10 14:58:20 -0500462
463 @Override
464 public void dump(FileDescriptor fd, PrintWriter pw, String[] args) {
465 final List<NotificationEntry> entries = new ArrayList<>(getActiveNotifs());
466
467 pw.println("\t" + TAG + " unsorted/unfiltered notifications:");
468 if (entries.size() == 0) {
469 pw.println("\t\t None");
470 }
471 pw.println(
472 ListDumper.dumpList(
473 entries,
474 true,
475 "\t\t"));
476 }
Ned Burnsf098dbf2019-09-13 19:17:53 -0400477}