1 /*
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 package com.android.systemui.bubbles;
17 
18 
19 import static android.view.Display.INVALID_DISPLAY;
20 
21 import static com.android.internal.annotations.VisibleForTesting.Visibility.PRIVATE;
22 
23 import android.annotation.NonNull;
24 import android.annotation.Nullable;
25 import android.app.Notification;
26 import android.app.PendingIntent;
27 import android.content.Context;
28 import android.content.Intent;
29 import android.content.pm.ApplicationInfo;
30 import android.content.pm.PackageManager;
31 import android.content.res.Resources;
32 import android.graphics.drawable.Drawable;
33 import android.os.Parcelable;
34 import android.os.UserHandle;
35 import android.provider.Settings;
36 import android.text.TextUtils;
37 import android.util.Log;
38 import android.view.LayoutInflater;
39 
40 import com.android.internal.annotations.VisibleForTesting;
41 import com.android.systemui.R;
42 import com.android.systemui.statusbar.notification.collection.NotificationEntry;
43 
44 import java.io.FileDescriptor;
45 import java.io.PrintWriter;
46 import java.util.List;
47 import java.util.Objects;
48 
49 /**
50  * Encapsulates the data and UI elements of a bubble.
51  */
52 class Bubble {
53     private static final String TAG = "Bubble";
54 
55     private NotificationEntry mEntry;
56     private final String mKey;
57     private final String mGroupId;
58     private String mAppName;
59     private Drawable mUserBadgedAppIcon;
60 
61     private boolean mInflated;
62     private BubbleView mIconView;
63     private BubbleExpandedView mExpandedView;
64 
65     private long mLastUpdated;
66     private long mLastAccessed;
67     private boolean mIsRemoved;
68 
69     /**
70      * Whether this notification should be shown in the shade when it is also displayed as a bubble.
71      *
72      * <p>When a notification is a bubble we don't show it in the shade once the bubble has been
73      * expanded</p>
74      */
75     private boolean mShowInShadeWhenBubble = true;
76 
77     /**
78      * Whether the bubble should show a dot for the notification indicating updated content.
79      */
80     private boolean mShowBubbleUpdateDot = true;
81 
82     /** Whether flyout text should be suppressed, regardless of any other flags or state. */
83     private boolean mSuppressFlyout;
84 
groupId(NotificationEntry entry)85     public static String groupId(NotificationEntry entry) {
86         UserHandle user = entry.notification.getUser();
87         return user.getIdentifier() + "|" + entry.notification.getPackageName();
88     }
89 
90     /** Used in tests when no UI is required. */
91     @VisibleForTesting(visibility = PRIVATE)
Bubble(Context context, NotificationEntry e)92     Bubble(Context context, NotificationEntry e) {
93         mEntry = e;
94         mKey = e.key;
95         mLastUpdated = e.notification.getPostTime();
96         mGroupId = groupId(e);
97 
98         PackageManager pm = context.getPackageManager();
99         ApplicationInfo info;
100         try {
101             info = pm.getApplicationInfo(
102                 mEntry.notification.getPackageName(),
103                 PackageManager.MATCH_UNINSTALLED_PACKAGES
104                     | PackageManager.MATCH_DISABLED_COMPONENTS
105                     | PackageManager.MATCH_DIRECT_BOOT_UNAWARE
106                     | PackageManager.MATCH_DIRECT_BOOT_AWARE);
107             if (info != null) {
108                 mAppName = String.valueOf(pm.getApplicationLabel(info));
109             }
110             Drawable appIcon = pm.getApplicationIcon(mEntry.notification.getPackageName());
111             mUserBadgedAppIcon = pm.getUserBadgedIcon(appIcon, mEntry.notification.getUser());
112         } catch (PackageManager.NameNotFoundException unused) {
113             mAppName = mEntry.notification.getPackageName();
114         }
115     }
116 
getKey()117     public String getKey() {
118         return mKey;
119     }
120 
getEntry()121     public NotificationEntry getEntry() {
122         return mEntry;
123     }
124 
getGroupId()125     public String getGroupId() {
126         return mGroupId;
127     }
128 
getPackageName()129     public String getPackageName() {
130         return mEntry.notification.getPackageName();
131     }
132 
getAppName()133     public String getAppName() {
134         return mAppName;
135     }
136 
isInflated()137     boolean isInflated() {
138         return mInflated;
139     }
140 
updateDotVisibility()141     void updateDotVisibility() {
142         if (mIconView != null) {
143             mIconView.updateDotVisibility(true /* animate */);
144         }
145     }
146 
getIconView()147     BubbleView getIconView() {
148         return mIconView;
149     }
150 
getExpandedView()151     BubbleExpandedView getExpandedView() {
152         return mExpandedView;
153     }
154 
cleanupExpandedState()155     void cleanupExpandedState() {
156         if (mExpandedView != null) {
157             mExpandedView.cleanUpExpandedState();
158         }
159     }
160 
inflate(LayoutInflater inflater, BubbleStackView stackView)161     void inflate(LayoutInflater inflater, BubbleStackView stackView) {
162         if (mInflated) {
163             return;
164         }
165         mIconView = (BubbleView) inflater.inflate(
166                 R.layout.bubble_view, stackView, false /* attachToRoot */);
167         mIconView.setBubble(this);
168         mIconView.setAppIcon(mUserBadgedAppIcon);
169 
170         mExpandedView = (BubbleExpandedView) inflater.inflate(
171                 R.layout.bubble_expanded_view, stackView, false /* attachToRoot */);
172         mExpandedView.setBubble(this, stackView, mAppName);
173 
174         mInflated = true;
175     }
176 
177     /**
178      * Set visibility of bubble in the expanded state.
179      *
180      * @param visibility {@code true} if the expanded bubble should be visible on the screen.
181      *
182      * Note that this contents visibility doesn't affect visibility at {@link android.view.View},
183      * and setting {@code false} actually means rendering the expanded view in transparent.
184      */
setContentVisibility(boolean visibility)185     void setContentVisibility(boolean visibility) {
186         if (mExpandedView != null) {
187             mExpandedView.setContentVisibility(visibility);
188         }
189     }
190 
updateEntry(NotificationEntry entry)191     void updateEntry(NotificationEntry entry) {
192         mEntry = entry;
193         mLastUpdated = entry.notification.getPostTime();
194         if (mInflated) {
195             mIconView.update(this);
196             mExpandedView.update(this);
197         }
198     }
199 
200     /**
201      * @return the newer of {@link #getLastUpdateTime()} and {@link #getLastAccessTime()}
202      */
getLastActivity()203     long getLastActivity() {
204         return Math.max(mLastUpdated, mLastAccessed);
205     }
206 
207     /**
208      * @return the timestamp in milliseconds of the most recent notification entry for this bubble
209      */
getLastUpdateTime()210     long getLastUpdateTime() {
211         return mLastUpdated;
212     }
213 
214     /**
215      * @return the timestamp in milliseconds when this bubble was last displayed in expanded state
216      */
getLastAccessTime()217     long getLastAccessTime() {
218         return mLastAccessed;
219     }
220 
221     /**
222      * @return the display id of the virtual display on which bubble contents is drawn.
223      */
getDisplayId()224     int getDisplayId() {
225         return mExpandedView != null ? mExpandedView.getVirtualDisplayId() : INVALID_DISPLAY;
226     }
227 
228     /**
229      * Should be invoked whenever a Bubble is accessed (selected while expanded).
230      */
markAsAccessedAt(long lastAccessedMillis)231     void markAsAccessedAt(long lastAccessedMillis) {
232         mLastAccessed = lastAccessedMillis;
233         setShowInShadeWhenBubble(false);
234         setShowBubbleDot(false);
235     }
236 
237     /**
238      * Whether this notification should be shown in the shade when it is also displayed as a
239      * bubble.
240      */
showInShadeWhenBubble()241     boolean showInShadeWhenBubble() {
242         return !mEntry.isRowDismissed() && !shouldSuppressNotification()
243                 && (!mEntry.isClearable() || mShowInShadeWhenBubble);
244     }
245 
246     /**
247      * Sets whether this notification should be shown in the shade when it is also displayed as a
248      * bubble.
249      */
setShowInShadeWhenBubble(boolean showInShade)250     void setShowInShadeWhenBubble(boolean showInShade) {
251         mShowInShadeWhenBubble = showInShade;
252     }
253 
254     /**
255      * Sets whether the bubble for this notification should show a dot indicating updated content.
256      */
setShowBubbleDot(boolean showDot)257     void setShowBubbleDot(boolean showDot) {
258         mShowBubbleUpdateDot = showDot;
259     }
260 
261     /**
262      * Whether the bubble for this notification should show a dot indicating updated content.
263      */
showBubbleDot()264     boolean showBubbleDot() {
265         return mShowBubbleUpdateDot && !mEntry.shouldSuppressNotificationDot();
266     }
267 
268     /**
269      * Whether the flyout for the bubble should be shown.
270      */
showFlyoutForBubble()271     boolean showFlyoutForBubble() {
272         return !mSuppressFlyout && !mEntry.shouldSuppressPeek()
273                 && !mEntry.shouldSuppressNotificationList();
274     }
275 
276     /**
277      * Set whether the flyout text for the bubble should be shown when an update is received.
278      *
279      * @param suppressFlyout whether the flyout text is shown
280      */
setSuppressFlyout(boolean suppressFlyout)281     void setSuppressFlyout(boolean suppressFlyout) {
282         mSuppressFlyout = suppressFlyout;
283     }
284 
285     /**
286      * Returns whether the notification for this bubble is a foreground service. It shows that this
287      * is an ongoing bubble.
288      */
isOngoing()289     boolean isOngoing() {
290         int flags = mEntry.notification.getNotification().flags;
291         return (flags & Notification.FLAG_FOREGROUND_SERVICE) != 0;
292     }
293 
getDesiredHeight(Context context)294     float getDesiredHeight(Context context) {
295         Notification.BubbleMetadata data = mEntry.getBubbleMetadata();
296         boolean useRes = data.getDesiredHeightResId() != 0;
297         if (useRes) {
298             return getDimenForPackageUser(context, data.getDesiredHeightResId(),
299                     mEntry.notification.getPackageName(),
300                     mEntry.notification.getUser().getIdentifier());
301         } else {
302             return data.getDesiredHeight()
303                     * context.getResources().getDisplayMetrics().density;
304         }
305     }
306 
getDesiredHeightString()307     String getDesiredHeightString() {
308         Notification.BubbleMetadata data = mEntry.getBubbleMetadata();
309         boolean useRes = data.getDesiredHeightResId() != 0;
310         if (useRes) {
311             return String.valueOf(data.getDesiredHeightResId());
312         } else {
313             return String.valueOf(data.getDesiredHeight());
314         }
315     }
316 
317     @Nullable
getBubbleIntent(Context context)318     PendingIntent getBubbleIntent(Context context) {
319         Notification notif = mEntry.notification.getNotification();
320         Notification.BubbleMetadata data = notif.getBubbleMetadata();
321         if (BubbleController.canLaunchInActivityView(context, mEntry) && data != null) {
322             return data.getIntent();
323         }
324         return null;
325     }
326 
getSettingsIntent()327     Intent getSettingsIntent() {
328         final Intent intent = new Intent(Settings.ACTION_APP_NOTIFICATION_BUBBLE_SETTINGS);
329         intent.putExtra(Settings.EXTRA_APP_PACKAGE, getPackageName());
330         intent.putExtra(Settings.EXTRA_APP_UID, mEntry.notification.getUid());
331         intent.addFlags(Intent.FLAG_ACTIVITY_MULTIPLE_TASK);
332         intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
333         intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP);
334         return intent;
335     }
336 
337     /**
338      * Returns our best guess for the most relevant text summary of the latest update to this
339      * notification, based on its type. Returns null if there should not be an update message.
340      */
getUpdateMessage(Context context)341     CharSequence getUpdateMessage(Context context) {
342         final Notification underlyingNotif = mEntry.notification.getNotification();
343         final Class<? extends Notification.Style> style = underlyingNotif.getNotificationStyle();
344 
345         try {
346             if (Notification.BigTextStyle.class.equals(style)) {
347                 // Return the big text, it is big so probably important. If it's not there use the
348                 // normal text.
349                 CharSequence bigText =
350                         underlyingNotif.extras.getCharSequence(Notification.EXTRA_BIG_TEXT);
351                 return !TextUtils.isEmpty(bigText)
352                         ? bigText
353                         : underlyingNotif.extras.getCharSequence(Notification.EXTRA_TEXT);
354             } else if (Notification.MessagingStyle.class.equals(style)) {
355                 final List<Notification.MessagingStyle.Message> messages =
356                         Notification.MessagingStyle.Message.getMessagesFromBundleArray(
357                                 (Parcelable[]) underlyingNotif.extras.get(
358                                         Notification.EXTRA_MESSAGES));
359 
360                 final Notification.MessagingStyle.Message latestMessage =
361                         Notification.MessagingStyle.findLatestIncomingMessage(messages);
362 
363                 if (latestMessage != null) {
364                     final CharSequence personName = latestMessage.getSenderPerson() != null
365                             ? latestMessage.getSenderPerson().getName()
366                             : null;
367 
368                     // Prepend the sender name if available since group chats also use messaging
369                     // style.
370                     if (!TextUtils.isEmpty(personName)) {
371                         return context.getResources().getString(
372                                 R.string.notification_summary_message_format,
373                                 personName,
374                                 latestMessage.getText());
375                     } else {
376                         return latestMessage.getText();
377                     }
378                 }
379             } else if (Notification.InboxStyle.class.equals(style)) {
380                 CharSequence[] lines =
381                         underlyingNotif.extras.getCharSequenceArray(Notification.EXTRA_TEXT_LINES);
382 
383                 // Return the last line since it should be the most recent.
384                 if (lines != null && lines.length > 0) {
385                     return lines[lines.length - 1];
386                 }
387             } else if (Notification.MediaStyle.class.equals(style)) {
388                 // Return nothing, media updates aren't typically useful as a text update.
389                 return null;
390             } else {
391                 // Default to text extra.
392                 return underlyingNotif.extras.getCharSequence(Notification.EXTRA_TEXT);
393             }
394         } catch (ClassCastException | NullPointerException | ArrayIndexOutOfBoundsException e) {
395             // No use crashing, we'll just return null and the caller will assume there's no update
396             // message.
397             e.printStackTrace();
398         }
399 
400         return null;
401     }
402 
getDimenForPackageUser(Context context, int resId, String pkg, int userId)403     private int getDimenForPackageUser(Context context, int resId, String pkg, int userId) {
404         PackageManager pm = context.getPackageManager();
405         Resources r;
406         if (pkg != null) {
407             try {
408                 if (userId == UserHandle.USER_ALL) {
409                     userId = UserHandle.USER_SYSTEM;
410                 }
411                 r = pm.getResourcesForApplicationAsUser(pkg, userId);
412                 return r.getDimensionPixelSize(resId);
413             } catch (PackageManager.NameNotFoundException ex) {
414                 // Uninstalled, don't care
415             } catch (Resources.NotFoundException e) {
416                 // Invalid res id, return 0 and user our default
417                 Log.e(TAG, "Couldn't find desired height res id", e);
418             }
419         }
420         return 0;
421     }
422 
shouldSuppressNotification()423     private boolean shouldSuppressNotification() {
424         return mEntry.getBubbleMetadata() != null
425                 && mEntry.getBubbleMetadata().isNotificationSuppressed();
426     }
427 
shouldAutoExpand()428     boolean shouldAutoExpand() {
429         Notification.BubbleMetadata metadata = mEntry.getBubbleMetadata();
430         return metadata != null && metadata.getAutoExpandBubble();
431     }
432 
433     @Override
toString()434     public String toString() {
435         return "Bubble{" + mKey + '}';
436     }
437 
438     /**
439      * Description of current bubble state.
440      */
dump( @onNull FileDescriptor fd, @NonNull PrintWriter pw, @NonNull String[] args)441     public void dump(
442             @NonNull FileDescriptor fd, @NonNull PrintWriter pw, @NonNull String[] args) {
443         pw.print("key: "); pw.println(mKey);
444         pw.print("  showInShade:   "); pw.println(showInShadeWhenBubble());
445         pw.print("  showDot:       "); pw.println(showBubbleDot());
446         pw.print("  showFlyout:    "); pw.println(showFlyoutForBubble());
447         pw.print("  desiredHeight: "); pw.println(getDesiredHeightString());
448         pw.print("  suppressNotif: "); pw.println(shouldSuppressNotification());
449         pw.print("  autoExpand:    "); pw.println(shouldAutoExpand());
450     }
451 
452     @Override
equals(Object o)453     public boolean equals(Object o) {
454         if (this == o) return true;
455         if (!(o instanceof Bubble)) return false;
456         Bubble bubble = (Bubble) o;
457         return Objects.equals(mKey, bubble.mKey);
458     }
459 
460     @Override
hashCode()461     public int hashCode() {
462         return Objects.hash(mKey);
463     }
464 }
465