1 /*
2  * Copyright (C) 2021 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.people;
17 
18 import static android.app.Notification.CATEGORY_MISSED_CALL;
19 import static android.app.people.ConversationStatus.ACTIVITY_ANNIVERSARY;
20 import static android.app.people.ConversationStatus.ACTIVITY_AUDIO;
21 import static android.app.people.ConversationStatus.ACTIVITY_BIRTHDAY;
22 import static android.app.people.ConversationStatus.ACTIVITY_GAME;
23 import static android.app.people.ConversationStatus.ACTIVITY_LOCATION;
24 import static android.app.people.ConversationStatus.ACTIVITY_NEW_STORY;
25 import static android.app.people.ConversationStatus.ACTIVITY_UPCOMING_BIRTHDAY;
26 import static android.app.people.ConversationStatus.ACTIVITY_VIDEO;
27 import static android.app.people.ConversationStatus.AVAILABILITY_AVAILABLE;
28 import static android.appwidget.AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT;
29 import static android.appwidget.AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH;
30 import static android.appwidget.AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT;
31 import static android.appwidget.AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH;
32 import static android.appwidget.AppWidgetManager.OPTION_APPWIDGET_SIZES;
33 import static android.util.TypedValue.COMPLEX_UNIT_DIP;
34 import static android.util.TypedValue.COMPLEX_UNIT_PX;
35 
36 import static com.android.systemui.people.PeopleSpaceUtils.STARRED_CONTACT;
37 import static com.android.systemui.people.PeopleSpaceUtils.VALID_CONTACT;
38 import static com.android.systemui.people.PeopleSpaceUtils.convertDrawableToBitmap;
39 import static com.android.systemui.people.PeopleSpaceUtils.getUserId;
40 
41 import android.annotation.Nullable;
42 import android.app.PendingIntent;
43 import android.app.people.ConversationStatus;
44 import android.app.people.PeopleSpaceTile;
45 import android.content.Context;
46 import android.content.Intent;
47 import android.graphics.Bitmap;
48 import android.graphics.ColorMatrix;
49 import android.graphics.ColorMatrixColorFilter;
50 import android.graphics.ImageDecoder;
51 import android.graphics.drawable.Drawable;
52 import android.graphics.drawable.Icon;
53 import android.graphics.text.LineBreaker;
54 import android.net.Uri;
55 import android.os.Bundle;
56 import android.os.UserHandle;
57 import android.text.StaticLayout;
58 import android.text.TextPaint;
59 import android.text.TextUtils;
60 import android.util.IconDrawableFactory;
61 import android.util.Log;
62 import android.util.Pair;
63 import android.util.Size;
64 import android.util.SizeF;
65 import android.util.TypedValue;
66 import android.view.Gravity;
67 import android.view.View;
68 import android.widget.RemoteViews;
69 import android.widget.TextView;
70 
71 import androidx.annotation.DimenRes;
72 import androidx.annotation.Px;
73 import androidx.core.graphics.drawable.RoundedBitmapDrawable;
74 import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory;
75 import androidx.core.math.MathUtils;
76 
77 import com.android.internal.annotations.VisibleForTesting;
78 import com.android.launcher3.icons.FastBitmapDrawable;
79 import com.android.systemui.R;
80 import com.android.systemui.people.widget.LaunchConversationActivity;
81 import com.android.systemui.people.widget.PeopleSpaceWidgetProvider;
82 import com.android.systemui.people.widget.PeopleTileKey;
83 
84 import java.io.IOException;
85 import java.text.NumberFormat;
86 import java.time.Duration;
87 import java.util.ArrayList;
88 import java.util.Arrays;
89 import java.util.Comparator;
90 import java.util.List;
91 import java.util.Locale;
92 import java.util.Map;
93 import java.util.Objects;
94 import java.util.Optional;
95 import java.util.function.Function;
96 import java.util.regex.Matcher;
97 import java.util.regex.Pattern;
98 import java.util.stream.Collectors;
99 
100 /** Functions that help creating the People tile layouts. */
101 public class PeopleTileViewHelper {
102     /** Turns on debugging information about People Space. */
103     private static final boolean DEBUG = PeopleSpaceUtils.DEBUG;
104     private static final String TAG = "PeopleTileView";
105 
106     private static final int DAYS_IN_A_WEEK = 7;
107     private static final int ONE_DAY = 1;
108 
109     public static final int LAYOUT_SMALL = 0;
110     public static final int LAYOUT_MEDIUM = 1;
111     public static final int LAYOUT_LARGE = 2;
112 
113     private static final int MIN_CONTENT_MAX_LINES = 2;
114     private static final int NAME_MAX_LINES_WITHOUT_LAST_INTERACTION = 3;
115     private static final int NAME_MAX_LINES_WITH_LAST_INTERACTION = 1;
116 
117     private static final int FIXED_HEIGHT_DIMENS_FOR_LARGE_NOTIF_CONTENT = 16 + 22 + 8 + 16;
118     private static final int FIXED_HEIGHT_DIMENS_FOR_LARGE_STATUS_CONTENT = 16 + 16 + 24 + 4 + 16;
119     private static final int MIN_MEDIUM_VERTICAL_PADDING = 4;
120     private static final int MAX_MEDIUM_PADDING = 16;
121     private static final int FIXED_HEIGHT_DIMENS_FOR_MEDIUM_CONTENT_BEFORE_PADDING = 8 + 4;
122     private static final int FIXED_HEIGHT_DIMENS_FOR_SMALL_VERTICAL = 6 + 4 + 8;
123     private static final int FIXED_WIDTH_DIMENS_FOR_SMALL_VERTICAL = 4 + 4;
124     private static final int FIXED_HEIGHT_DIMENS_FOR_SMALL_HORIZONTAL = 6 + 4;
125     private static final int FIXED_WIDTH_DIMENS_FOR_SMALL_HORIZONTAL = 8 + 8;
126 
127     private static final int MESSAGES_COUNT_OVERFLOW = 6;
128 
129     private static final CharSequence EMOJI_CAKE = "\ud83c\udf82";
130 
131     private static final Pattern DOUBLE_EXCLAMATION_PATTERN = Pattern.compile("[!][!]+");
132     private static final Pattern DOUBLE_QUESTION_PATTERN = Pattern.compile("[?][?]+");
133     private static final Pattern ANY_DOUBLE_MARK_PATTERN = Pattern.compile("[!?][!?]+");
134     private static final Pattern MIXED_MARK_PATTERN = Pattern.compile("![?].*|.*[?]!");
135 
136     static final String BRIEF_PAUSE_ON_TALKBACK = "\n\n";
137 
138     // This regex can be used to match Unicode emoji characters and character sequences. It's from
139     // the official Unicode site (https://unicode.org/reports/tr51/#EBNF_and_Regex) with minor
140     // changes to fit our needs. It should be updated once new emoji categories are added.
141     //
142     // Emoji categories that can be matched by this regex:
143     // - Country flags. "\p{RI}\p{RI}" matches country flags since they always consist of 2 Unicode
144     //   scalars.
145     // - Single-Character Emoji. "\p{Emoji}" matches Single-Character Emojis.
146     // - Emoji with modifiers. E.g. Emojis with different skin tones. "\p{Emoji}\p{EMod}" matches
147     //   them.
148     // - Emoji Presentation. Those are characters which can normally be drawn as either text or as
149     //   Emoji. "\p{Emoji}\x{FE0F}" matches them.
150     // - Emoji Keycap. E.g. Emojis for number 0 to 9. "\p{Emoji}\x{FE0F}\x{20E3}" matches them.
151     // - Emoji tag sequence. "\p{Emoji}[\x{E0020}-\x{E007E}]+\x{E007F}" matches them.
152     // - Emoji Zero-Width Joiner (ZWJ) Sequence. A ZWJ emoji is actually multiple emojis joined by
153     //   the jointer "0x200D".
154     //
155     // Note: since "\p{Emoji}" also matches some ASCII characters like digits 0-9, we use
156     // "\p{Emoji}&&\p{So}" to exclude them. This is the change we made from the official emoji
157     // regex.
158     private static final String UNICODE_EMOJI_REGEX =
159             "\\p{RI}\\p{RI}|"
160                     + "("
161                     + "\\p{Emoji}(\\p{EMod}|\\x{FE0F}\\x{20E3}?|[\\x{E0020}-\\x{E007E}]+\\x{E007F})"
162                     + "|[\\p{Emoji}&&\\p{So}]"
163                     + ")"
164                     + "("
165                     + "\\x{200D}"
166                     + "\\p{Emoji}(\\p{EMod}|\\x{FE0F}\\x{20E3}?|[\\x{E0020}-\\x{E007E}]+\\x{E007F})"
167                     + "?)*";
168 
169     private static final Pattern EMOJI_PATTERN = Pattern.compile(UNICODE_EMOJI_REGEX);
170 
171     public static final String EMPTY_STRING = "";
172 
173     private int mMediumVerticalPadding;
174 
175     private Context mContext;
176     @Nullable
177     private PeopleSpaceTile mTile;
178     private PeopleTileKey mKey;
179     private float mDensity;
180     private int mAppWidgetId;
181     private int mWidth;
182     private int mHeight;
183     private int mLayoutSize;
184     private boolean mIsLeftToRight;
185 
186     private Locale mLocale;
187     private NumberFormat mIntegerFormat;
188 
PeopleTileViewHelper(Context context, @Nullable PeopleSpaceTile tile, int appWidgetId, int width, int height, PeopleTileKey key)189     PeopleTileViewHelper(Context context, @Nullable PeopleSpaceTile tile,
190             int appWidgetId, int width, int height, PeopleTileKey key) {
191         mContext = context;
192         mTile = tile;
193         mKey = key;
194         mAppWidgetId = appWidgetId;
195         mDensity = mContext.getResources().getDisplayMetrics().density;
196         mWidth = width;
197         mHeight = height;
198         mLayoutSize = getLayoutSize();
199         mIsLeftToRight = TextUtils.getLayoutDirectionFromLocale(Locale.getDefault())
200                 == View.LAYOUT_DIRECTION_LTR;
201     }
202 
203     /**
204      * Creates a {@link RemoteViews} for the specified arguments. The RemoteViews will support all
205      * the sizes present in {@code options.}.
206      */
createRemoteViews(Context context, @Nullable PeopleSpaceTile tile, int appWidgetId, Bundle options, PeopleTileKey key)207     public static RemoteViews createRemoteViews(Context context, @Nullable PeopleSpaceTile tile,
208             int appWidgetId, Bundle options, PeopleTileKey key) {
209         List<SizeF> widgetSizes = getWidgetSizes(context, options);
210         Map<SizeF, RemoteViews> sizeToRemoteView =
211                 widgetSizes
212                         .stream()
213                         .distinct()
214                         .collect(Collectors.toMap(
215                                 Function.identity(),
216                                 size -> new PeopleTileViewHelper(
217                                         context, tile, appWidgetId,
218                                         (int) size.getWidth(),
219                                         (int) size.getHeight(),
220                                         key)
221                                         .getViews()));
222         return new RemoteViews(sizeToRemoteView);
223     }
224 
getWidgetSizes(Context context, Bundle options)225     private static List<SizeF> getWidgetSizes(Context context, Bundle options) {
226         float density = context.getResources().getDisplayMetrics().density;
227         List<SizeF> widgetSizes = options.getParcelableArrayList(OPTION_APPWIDGET_SIZES);
228         // If the full list of sizes was provided in the options bundle, use that.
229         if (widgetSizes != null && !widgetSizes.isEmpty()) return widgetSizes;
230 
231         // Otherwise, create a list using the portrait/landscape sizes.
232         int defaultWidth = getSizeInDp(context, R.dimen.default_width, density);
233         int defaultHeight = getSizeInDp(context, R.dimen.default_height, density);
234         widgetSizes = new ArrayList<>(2);
235 
236         int portraitWidth = options.getInt(OPTION_APPWIDGET_MIN_WIDTH, defaultWidth);
237         int portraitHeight = options.getInt(OPTION_APPWIDGET_MAX_HEIGHT, defaultHeight);
238         widgetSizes.add(new SizeF(portraitWidth, portraitHeight));
239 
240         int landscapeWidth = options.getInt(OPTION_APPWIDGET_MAX_WIDTH, defaultWidth);
241         int landscapeHeight = options.getInt(OPTION_APPWIDGET_MIN_HEIGHT, defaultHeight);
242         widgetSizes.add(new SizeF(landscapeWidth, landscapeHeight));
243 
244         return widgetSizes;
245     }
246 
247     @VisibleForTesting
getViews()248     RemoteViews getViews() {
249         RemoteViews viewsForTile = getViewForTile();
250         int maxAvatarSize = getMaxAvatarSize(viewsForTile);
251         RemoteViews views = setCommonRemoteViewsFields(viewsForTile, maxAvatarSize);
252         return setLaunchIntents(views);
253     }
254 
255     /**
256      * The prioritization for the {@code mTile} content is missed calls, followed by notification
257      * content, then birthdays, then the most recent status, and finally last interaction.
258      */
getViewForTile()259     private RemoteViews getViewForTile() {
260         if (DEBUG) Log.d(TAG, "Creating view for tile key: " + mKey.toString());
261         if (mTile == null || mTile.isPackageSuspended() || mTile.isUserQuieted()) {
262             if (DEBUG) Log.d(TAG, "Create suppressed view: " + mTile);
263             return createSuppressedView();
264         }
265 
266         if (isDndBlockingTileData(mTile)) {
267             if (DEBUG) Log.d(TAG, "Create dnd view");
268             return createDndRemoteViews().mRemoteViews;
269         }
270 
271         if (Objects.equals(mTile.getNotificationCategory(), CATEGORY_MISSED_CALL)) {
272             if (DEBUG) Log.d(TAG, "Create missed call view");
273             return createMissedCallRemoteViews();
274         }
275 
276         if (mTile.getNotificationKey() != null) {
277             if (DEBUG) Log.d(TAG, "Create notification view");
278             return createNotificationRemoteViews();
279         }
280 
281         // TODO: Add sorting when we expose timestamp of statuses.
282         List<ConversationStatus> statusesForEntireView =
283                 mTile.getStatuses() == null ? Arrays.asList() : mTile.getStatuses().stream().filter(
284                         c -> isStatusValidForEntireStatusView(c)).collect(Collectors.toList());
285         ConversationStatus birthdayStatus = getBirthdayStatus(statusesForEntireView);
286         if (birthdayStatus != null) {
287             if (DEBUG) Log.d(TAG, "Create birthday view");
288             return createStatusRemoteViews(birthdayStatus);
289         }
290 
291         if (!statusesForEntireView.isEmpty()) {
292             if (DEBUG) {
293                 Log.d(TAG,
294                         "Create status view for: " + statusesForEntireView.get(0).getActivity());
295             }
296             ConversationStatus mostRecentlyStartedStatus = statusesForEntireView.stream().max(
297                     Comparator.comparing(s -> s.getStartTimeMillis())).get();
298             return createStatusRemoteViews(mostRecentlyStartedStatus);
299         }
300 
301         return createLastInteractionRemoteViews();
302     }
303 
isDndBlockingTileData(@ullable PeopleSpaceTile tile)304     private static boolean isDndBlockingTileData(@Nullable PeopleSpaceTile tile) {
305         if (tile == null) return false;
306 
307         int notificationPolicyState = tile.getNotificationPolicyState();
308         if ((notificationPolicyState & PeopleSpaceTile.SHOW_CONVERSATIONS) != 0) {
309             // Not in DND, or all conversations
310             if (DEBUG) Log.d(TAG, "Tile can show all data: " + tile.getUserName());
311             return false;
312         }
313         if ((notificationPolicyState & PeopleSpaceTile.SHOW_IMPORTANT_CONVERSATIONS) != 0
314                 && tile.isImportantConversation()) {
315             if (DEBUG) Log.d(TAG, "Tile can show important: " + tile.getUserName());
316             return false;
317         }
318         if ((notificationPolicyState & PeopleSpaceTile.SHOW_STARRED_CONTACTS) != 0
319                 && tile.getContactAffinity() == STARRED_CONTACT) {
320             if (DEBUG) Log.d(TAG, "Tile can show starred: " + tile.getUserName());
321             return false;
322         }
323         if ((notificationPolicyState & PeopleSpaceTile.SHOW_CONTACTS) != 0
324                 && (tile.getContactAffinity() == VALID_CONTACT
325                 || tile.getContactAffinity() == STARRED_CONTACT)) {
326             if (DEBUG) Log.d(TAG, "Tile can show contacts: " + tile.getUserName());
327             return false;
328         }
329         if (DEBUG) Log.d(TAG, "Tile can show if can bypass DND: " + tile.getUserName());
330         return !tile.canBypassDnd();
331     }
332 
createSuppressedView()333     private RemoteViews createSuppressedView() {
334         RemoteViews views;
335         if (mTile != null && mTile.isUserQuieted()) {
336             views = new RemoteViews(mContext.getPackageName(),
337                     R.layout.people_tile_work_profile_quiet_layout);
338         } else {
339             views = new RemoteViews(mContext.getPackageName(),
340                     R.layout.people_tile_suppressed_layout);
341         }
342         Drawable appIcon = mContext.getDrawable(R.drawable.ic_conversation_icon);
343         Bitmap disabledBitmap = convertDrawableToDisabledBitmap(appIcon);
344         views.setImageViewBitmap(R.id.icon, disabledBitmap);
345         return views;
346     }
347 
setMaxLines(RemoteViews views, boolean showSender)348     private void setMaxLines(RemoteViews views, boolean showSender) {
349         int textSizeResId;
350         int nameHeight;
351         if (mLayoutSize == LAYOUT_LARGE) {
352             textSizeResId = R.dimen.content_text_size_for_large;
353             nameHeight = getLineHeightFromResource(R.dimen.name_text_size_for_large_content);
354         } else {
355             textSizeResId = R.dimen.content_text_size_for_medium;
356             nameHeight = getLineHeightFromResource(R.dimen.name_text_size_for_medium_content);
357         }
358         boolean isStatusLayout =
359                 views.getLayoutId() == R.layout.people_tile_large_with_status_content;
360         int contentHeight = getContentHeightForLayout(nameHeight, isStatusLayout);
361         int lineHeight = getLineHeightFromResource(textSizeResId);
362         int maxAdaptiveLines = Math.floorDiv(contentHeight, lineHeight);
363         int maxLines = Math.max(MIN_CONTENT_MAX_LINES, maxAdaptiveLines);
364 
365         // Save a line for sender's name, if present.
366         if (showSender) maxLines--;
367         views.setInt(R.id.text_content, "setMaxLines", maxLines);
368     }
369 
getLineHeightFromResource(int resId)370     private int getLineHeightFromResource(int resId) {
371         try {
372             TextView text = new TextView(mContext);
373             text.setTextSize(TypedValue.COMPLEX_UNIT_PX,
374                     mContext.getResources().getDimension(resId));
375             text.setTextAppearance(android.R.style.TextAppearance_DeviceDefault);
376             int lineHeight = (int) (text.getLineHeight() / mDensity);
377             return lineHeight;
378         } catch (Exception e) {
379             Log.e(TAG, "Could not create text view: " + e);
380             return getSizeInDp(
381                     R.dimen.content_text_size_for_medium);
382         }
383     }
384 
getSizeInDp(int dimenResourceId)385     private int getSizeInDp(int dimenResourceId) {
386         return getSizeInDp(mContext, dimenResourceId, mDensity);
387     }
388 
getSizeInDp(Context context, int dimenResourceId, float density)389     public static int getSizeInDp(Context context, int dimenResourceId, float density) {
390         return (int) (context.getResources().getDimension(dimenResourceId) / density);
391     }
392 
getContentHeightForLayout(int lineHeight, boolean hasPredefinedIcon)393     private int getContentHeightForLayout(int lineHeight, boolean hasPredefinedIcon) {
394         switch (mLayoutSize) {
395             case LAYOUT_MEDIUM:
396                 return mHeight - (lineHeight + FIXED_HEIGHT_DIMENS_FOR_MEDIUM_CONTENT_BEFORE_PADDING
397                         + mMediumVerticalPadding * 2);
398             case LAYOUT_LARGE:
399                 int fixedHeight = hasPredefinedIcon ? FIXED_HEIGHT_DIMENS_FOR_LARGE_STATUS_CONTENT
400                         : FIXED_HEIGHT_DIMENS_FOR_LARGE_NOTIF_CONTENT;
401                 return mHeight - (getSizeInDp(
402                         R.dimen.max_people_avatar_size_for_large_content) + lineHeight
403                         + fixedHeight);
404             default:
405                 return -1;
406         }
407     }
408 
409     /** Calculates the best layout relative to the size in {@code options}. */
getLayoutSize()410     private int getLayoutSize() {
411         if (mHeight >= getSizeInDp(R.dimen.required_height_for_large)
412                 && mWidth >= getSizeInDp(R.dimen.required_width_for_large)) {
413             if (DEBUG) Log.d(TAG, "Large view for mWidth: " + mWidth + " mHeight: " + mHeight);
414             return LAYOUT_LARGE;
415         }
416         // Small layout used below a certain minimum mWidth with any mHeight.
417         if (mHeight >= getSizeInDp(R.dimen.required_height_for_medium)
418                 && mWidth >= getSizeInDp(R.dimen.required_width_for_medium)) {
419             int spaceAvailableForPadding =
420                     mHeight - (getSizeInDp(R.dimen.avatar_size_for_medium)
421                             + 4 + getLineHeightFromResource(
422                             R.dimen.name_text_size_for_medium_content));
423             if (DEBUG) {
424                 Log.d(TAG, "Medium view for mWidth: " + mWidth + " mHeight: " + mHeight
425                         + " with padding space: " + spaceAvailableForPadding);
426             }
427             int maxVerticalPadding = Math.min(Math.floorDiv(spaceAvailableForPadding, 2),
428                     MAX_MEDIUM_PADDING);
429             mMediumVerticalPadding = Math.max(MIN_MEDIUM_VERTICAL_PADDING, maxVerticalPadding);
430             return LAYOUT_MEDIUM;
431         }
432         // Small layout can always handle our minimum mWidth and mHeight for our widget.
433         if (DEBUG) Log.d(TAG, "Small view for mWidth: " + mWidth + " mHeight: " + mHeight);
434         return LAYOUT_SMALL;
435     }
436 
437     /** Returns the max avatar size for {@code views} under the current {@code options}. */
getMaxAvatarSize(RemoteViews views)438     private int getMaxAvatarSize(RemoteViews views) {
439         int layoutId = views.getLayoutId();
440         int avatarSize = getSizeInDp(R.dimen.avatar_size_for_medium);
441         if (layoutId == R.layout.people_tile_medium_empty) {
442             return getSizeInDp(
443                     R.dimen.max_people_avatar_size_for_large_content);
444         }
445         if (layoutId == R.layout.people_tile_medium_with_content) {
446             return getSizeInDp(R.dimen.avatar_size_for_medium);
447         }
448 
449         // Calculate adaptive avatar size for remaining layouts.
450         if (layoutId == R.layout.people_tile_small) {
451             int avatarHeightSpace = mHeight - (FIXED_HEIGHT_DIMENS_FOR_SMALL_VERTICAL + Math.max(18,
452                     getLineHeightFromResource(
453                             R.dimen.name_text_size_for_small)));
454             int avatarWidthSpace = mWidth - FIXED_WIDTH_DIMENS_FOR_SMALL_VERTICAL;
455             avatarSize = Math.min(avatarHeightSpace, avatarWidthSpace);
456         }
457         if (layoutId == R.layout.people_tile_small_horizontal) {
458             int avatarHeightSpace = mHeight - FIXED_HEIGHT_DIMENS_FOR_SMALL_HORIZONTAL;
459             int avatarWidthSpace = mWidth - FIXED_WIDTH_DIMENS_FOR_SMALL_HORIZONTAL;
460             avatarSize = Math.min(avatarHeightSpace, avatarWidthSpace);
461         }
462 
463         if (layoutId == R.layout.people_tile_large_with_notification_content) {
464             avatarSize = mHeight - (FIXED_HEIGHT_DIMENS_FOR_LARGE_NOTIF_CONTENT + (
465                     getLineHeightFromResource(
466                             R.dimen.content_text_size_for_large)
467                             * 3));
468             return Math.min(avatarSize, getSizeInDp(
469                     R.dimen.max_people_avatar_size_for_large_content));
470         } else if (layoutId == R.layout.people_tile_large_with_status_content) {
471             avatarSize = mHeight - (FIXED_HEIGHT_DIMENS_FOR_LARGE_STATUS_CONTENT + (
472                     getLineHeightFromResource(R.dimen.content_text_size_for_large)
473                             * 3));
474             return Math.min(avatarSize, getSizeInDp(
475                     R.dimen.max_people_avatar_size_for_large_content));
476         }
477 
478         if (layoutId == R.layout.people_tile_large_empty) {
479             int avatarHeightSpace = mHeight - (14 + 14 + getLineHeightFromResource(
480                     R.dimen.name_text_size_for_large)
481                     + getLineHeightFromResource(R.dimen.content_text_size_for_large)
482                     + 16 + 10 + 16);
483             int avatarWidthSpace = mWidth - (14 + 14);
484             avatarSize = Math.min(avatarHeightSpace, avatarWidthSpace);
485         }
486 
487         if (isDndBlockingTileData(mTile) && mLayoutSize != LAYOUT_SMALL) {
488             avatarSize = createDndRemoteViews().mAvatarSize;
489         }
490 
491         return Math.min(avatarSize,
492                 getSizeInDp(R.dimen.max_people_avatar_size));
493     }
494 
setCommonRemoteViewsFields(RemoteViews views, int maxAvatarSize)495     private RemoteViews setCommonRemoteViewsFields(RemoteViews views,
496             int maxAvatarSize) {
497         try {
498             if (mTile == null) {
499                 return views;
500             }
501             boolean isAvailable =
502                     mTile.getStatuses() != null && mTile.getStatuses().stream().anyMatch(
503                             c -> c.getAvailability() == AVAILABILITY_AVAILABLE);
504 
505             int startPadding;
506             if (isAvailable) {
507                 views.setViewVisibility(R.id.availability, View.VISIBLE);
508                 startPadding = mContext.getResources().getDimensionPixelSize(
509                         R.dimen.availability_dot_shown_padding);
510                 views.setContentDescription(R.id.availability,
511                         mContext.getString(R.string.person_available));
512             } else {
513                 views.setViewVisibility(R.id.availability, View.GONE);
514                 startPadding = mContext.getResources().getDimensionPixelSize(
515                         R.dimen.availability_dot_missing_padding);
516             }
517             boolean isLeftToRight = TextUtils.getLayoutDirectionFromLocale(Locale.getDefault())
518                     == View.LAYOUT_DIRECTION_LTR;
519             views.setViewPadding(R.id.padding_before_availability,
520                     isLeftToRight ? startPadding : 0, 0, isLeftToRight ? 0 : startPadding,
521                     0);
522 
523             boolean hasNewStory = getHasNewStory(mTile);
524             views.setImageViewBitmap(R.id.person_icon,
525                     getPersonIconBitmap(mContext, mTile, maxAvatarSize, hasNewStory));
526             if (hasNewStory) {
527                 views.setContentDescription(R.id.person_icon,
528                         mContext.getString(R.string.new_story_status_content_description,
529                                 mTile.getUserName()));
530             } else {
531                 views.setContentDescription(R.id.person_icon, null);
532             }
533             return views;
534         } catch (Exception e) {
535             Log.e(TAG, "Failed to set common fields: " + e);
536         }
537         return views;
538     }
539 
getHasNewStory(PeopleSpaceTile tile)540     private static boolean getHasNewStory(PeopleSpaceTile tile) {
541         return tile.getStatuses() != null && tile.getStatuses().stream().anyMatch(
542                 c -> c.getActivity() == ACTIVITY_NEW_STORY);
543     }
544 
setLaunchIntents(RemoteViews views)545     private RemoteViews setLaunchIntents(RemoteViews views) {
546         if (!PeopleTileKey.isValid(mKey) || mTile == null) {
547             if (DEBUG) Log.d(TAG, "Skipping launch intent, Null tile or invalid key: " + mKey);
548             return views;
549         }
550 
551         try {
552             Intent activityIntent = new Intent(mContext, LaunchConversationActivity.class);
553             activityIntent.addFlags(
554                     Intent.FLAG_ACTIVITY_NEW_TASK
555                             | Intent.FLAG_ACTIVITY_CLEAR_TASK
556                             | Intent.FLAG_ACTIVITY_NO_HISTORY
557                             | Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS);
558             activityIntent.putExtra(PeopleSpaceWidgetProvider.EXTRA_TILE_ID, mKey.getShortcutId());
559             activityIntent.putExtra(
560                     PeopleSpaceWidgetProvider.EXTRA_PACKAGE_NAME, mKey.getPackageName());
561             activityIntent.putExtra(PeopleSpaceWidgetProvider.EXTRA_USER_HANDLE,
562                     new UserHandle(mKey.getUserId()));
563             if (mTile != null) {
564                 activityIntent.putExtra(
565                         PeopleSpaceWidgetProvider.EXTRA_NOTIFICATION_KEY,
566                         mTile.getNotificationKey());
567             }
568             views.setOnClickPendingIntent(android.R.id.background, PendingIntent.getActivity(
569                     mContext,
570                     mAppWidgetId,
571                     activityIntent,
572                     PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE));
573             return views;
574         } catch (Exception e) {
575             Log.e(TAG, "Failed to add launch intents: " + e);
576         }
577 
578         return views;
579     }
580 
createDndRemoteViews()581     private RemoteViewsAndSizes createDndRemoteViews() {
582         RemoteViews views = new RemoteViews(mContext.getPackageName(), getViewForDndRemoteViews());
583 
584         int mediumAvatarSize = getSizeInDp(R.dimen.avatar_size_for_medium_empty);
585         int maxAvatarSize = getSizeInDp(R.dimen.max_people_avatar_size);
586 
587         String text = mContext.getString(R.string.paused_by_dnd);
588         views.setTextViewText(R.id.text_content, text);
589 
590         int textSizeResId =
591                 mLayoutSize == LAYOUT_LARGE
592                         ? R.dimen.content_text_size_for_large
593                         : R.dimen.content_text_size_for_medium;
594         float textSizePx = mContext.getResources().getDimension(textSizeResId);
595         views.setTextViewTextSize(R.id.text_content, COMPLEX_UNIT_PX, textSizePx);
596         int lineHeight = getLineHeightFromResource(textSizeResId);
597 
598         int avatarSize;
599         if (mLayoutSize == LAYOUT_MEDIUM) {
600             int maxTextHeight = mHeight - 16;
601             views.setInt(R.id.text_content, "setMaxLines", maxTextHeight / lineHeight);
602             avatarSize = mediumAvatarSize;
603         } else {
604             int outerPadding = 16;
605             int outerPaddingTop = outerPadding - 2;
606             int outerPaddingPx = dpToPx(outerPadding);
607             int outerPaddingTopPx = dpToPx(outerPaddingTop);
608             int iconSize =
609                     getSizeInDp(
610                             mLayoutSize == LAYOUT_SMALL
611                                     ? R.dimen.regular_predefined_icon
612                                     : R.dimen.largest_predefined_icon);
613             int heightWithoutIcon = mHeight - 2 * outerPadding - iconSize;
614             int paddingBetweenElements =
615                     getSizeInDp(R.dimen.padding_between_suppressed_layout_items);
616             int maxTextWidth = mWidth - outerPadding * 2;
617             int maxTextHeight = heightWithoutIcon - mediumAvatarSize - paddingBetweenElements * 2;
618 
619             int availableAvatarHeight;
620             int textHeight = estimateTextHeight(text, textSizeResId, maxTextWidth);
621             if (textHeight <= maxTextHeight && mLayoutSize == LAYOUT_LARGE) {
622                 // If the text will fit, then display it and deduct its height from the space we
623                 // have for the avatar.
624                 availableAvatarHeight = heightWithoutIcon - textHeight - paddingBetweenElements * 2;
625                 views.setViewVisibility(R.id.text_content, View.VISIBLE);
626                 views.setInt(R.id.text_content, "setMaxLines", maxTextHeight / lineHeight);
627                 views.setContentDescription(R.id.predefined_icon, null);
628                 int availableAvatarWidth = mWidth - outerPadding * 2;
629                 avatarSize =
630                         MathUtils.clamp(
631                                 /* value= */ Math.min(availableAvatarWidth, availableAvatarHeight),
632                                 /* min= */ dpToPx(10),
633                                 /* max= */ maxAvatarSize);
634                 views.setViewPadding(
635                         android.R.id.background,
636                         outerPaddingPx,
637                         outerPaddingTopPx,
638                         outerPaddingPx,
639                         outerPaddingPx);
640                 views.setViewLayoutWidth(R.id.predefined_icon, iconSize, COMPLEX_UNIT_DIP);
641                 views.setViewLayoutHeight(R.id.predefined_icon, iconSize, COMPLEX_UNIT_DIP);
642             } else {
643                 // If expected to use LAYOUT_LARGE, but we found we do not have space for the
644                 // text as calculated above, re-assign the view to the small layout.
645                 if (mLayoutSize != LAYOUT_SMALL) {
646                     views = new RemoteViews(mContext.getPackageName(), R.layout.people_tile_small);
647                 }
648                 avatarSize = getMaxAvatarSize(views);
649                 views.setViewVisibility(R.id.messages_count, View.GONE);
650                 views.setViewVisibility(R.id.name, View.GONE);
651                 // If we don't show the dnd text, set it as the content description on the icon
652                 // for a11y.
653                 views.setContentDescription(R.id.predefined_icon, text);
654             }
655             views.setViewVisibility(R.id.predefined_icon, View.VISIBLE);
656             views.setImageViewResource(R.id.predefined_icon, R.drawable.ic_qs_dnd_on);
657         }
658 
659         return new RemoteViewsAndSizes(views, avatarSize);
660     }
661 
createMissedCallRemoteViews()662     private RemoteViews createMissedCallRemoteViews() {
663         RemoteViews views = setViewForContentLayout(new RemoteViews(mContext.getPackageName(),
664                 getLayoutForContent()));
665         setPredefinedIconVisible(views);
666         views.setViewVisibility(R.id.text_content, View.VISIBLE);
667         views.setViewVisibility(R.id.messages_count, View.GONE);
668         setMaxLines(views, false);
669         CharSequence content = mTile.getNotificationContent();
670         views.setTextViewText(R.id.text_content, content);
671         setContentDescriptionForNotificationTextContent(views, content, mTile.getUserName());
672         views.setColorAttr(R.id.text_content, "setTextColor", android.R.attr.colorError);
673         views.setColorAttr(R.id.predefined_icon, "setColorFilter", android.R.attr.colorError);
674         views.setImageViewResource(R.id.predefined_icon, R.drawable.ic_phone_missed);
675         if (mLayoutSize == LAYOUT_LARGE) {
676             views.setInt(R.id.content, "setGravity", Gravity.BOTTOM);
677             views.setViewLayoutHeightDimen(R.id.predefined_icon, R.dimen.larger_predefined_icon);
678             views.setViewLayoutWidthDimen(R.id.predefined_icon, R.dimen.larger_predefined_icon);
679         }
680         setAvailabilityDotPadding(views, R.dimen.availability_dot_notification_padding);
681         return views;
682     }
683 
setPredefinedIconVisible(RemoteViews views)684     private void setPredefinedIconVisible(RemoteViews views) {
685         views.setViewVisibility(R.id.predefined_icon, View.VISIBLE);
686         if (mLayoutSize == LAYOUT_MEDIUM) {
687             int endPadding = mContext.getResources().getDimensionPixelSize(
688                     R.dimen.before_predefined_icon_padding);
689             views.setViewPadding(R.id.name, mIsLeftToRight ? 0 : endPadding, 0,
690                     mIsLeftToRight ? endPadding : 0,
691                     0);
692         }
693     }
694 
createNotificationRemoteViews()695     private RemoteViews createNotificationRemoteViews() {
696         RemoteViews views = setViewForContentLayout(new RemoteViews(mContext.getPackageName(),
697                 getLayoutForNotificationContent()));
698         CharSequence sender = mTile.getNotificationSender();
699         Uri imageUri = mTile.getNotificationDataUri();
700         if (imageUri != null) {
701             String newImageDescription = mContext.getString(
702                     R.string.new_notification_image_content_description, mTile.getUserName());
703             views.setContentDescription(R.id.image, newImageDescription);
704             views.setViewVisibility(R.id.image, View.VISIBLE);
705             views.setViewVisibility(R.id.text_content, View.GONE);
706             try {
707                 Drawable drawable = resolveImage(imageUri, mContext);
708                 Bitmap bitmap = convertDrawableToBitmap(drawable);
709                 views.setImageViewBitmap(R.id.image, bitmap);
710             } catch (IOException e) {
711                 Log.e(TAG, "Could not decode image: " + e);
712                 // If we couldn't load the image, show text that we have a new image.
713                 views.setTextViewText(R.id.text_content, newImageDescription);
714                 views.setViewVisibility(R.id.text_content, View.VISIBLE);
715                 views.setViewVisibility(R.id.image, View.GONE);
716             }
717         } else {
718             setMaxLines(views, !TextUtils.isEmpty(sender));
719             CharSequence content = mTile.getNotificationContent();
720             setContentDescriptionForNotificationTextContent(views, content,
721                     sender != null ? sender : mTile.getUserName());
722             views = decorateBackground(views, content);
723             views.setColorAttr(R.id.text_content, "setTextColor", android.R.attr.textColorPrimary);
724             views.setTextViewText(R.id.text_content, mTile.getNotificationContent());
725             if (mLayoutSize == LAYOUT_LARGE) {
726                 views.setViewPadding(R.id.name, 0, 0, 0,
727                         mContext.getResources().getDimensionPixelSize(
728                                 R.dimen.above_notification_text_padding));
729             }
730             views.setViewVisibility(R.id.image, View.GONE);
731             views.setImageViewResource(R.id.predefined_icon, R.drawable.ic_message);
732         }
733         if (mTile.getMessagesCount() > 1) {
734             if (mLayoutSize == LAYOUT_MEDIUM) {
735                 int endPadding = mContext.getResources().getDimensionPixelSize(
736                         R.dimen.before_messages_count_padding);
737                 views.setViewPadding(R.id.name, mIsLeftToRight ? 0 : endPadding, 0,
738                         mIsLeftToRight ? endPadding : 0,
739                         0);
740             }
741             views.setViewVisibility(R.id.messages_count, View.VISIBLE);
742             views.setTextViewText(R.id.messages_count,
743                     getMessagesCountText(mTile.getMessagesCount()));
744             if (mLayoutSize == LAYOUT_SMALL) {
745                 views.setViewVisibility(R.id.predefined_icon, View.GONE);
746             }
747         }
748         if (!TextUtils.isEmpty(sender)) {
749             views.setViewVisibility(R.id.subtext, View.VISIBLE);
750             views.setTextViewText(R.id.subtext, sender);
751         } else {
752             views.setViewVisibility(R.id.subtext, View.GONE);
753         }
754         setAvailabilityDotPadding(views, R.dimen.availability_dot_notification_padding);
755         return views;
756     }
757 
resolveImage(Uri uri, Context context)758     private Drawable resolveImage(Uri uri, Context context) throws IOException {
759         final ImageDecoder.Source source =
760                 ImageDecoder.createSource(context.getContentResolver(), uri);
761         final Drawable drawable =
762                 ImageDecoder.decodeDrawable(source, (decoder, info, s) -> {
763                     onHeaderDecoded(decoder, info, s);
764                 });
765         return drawable;
766     }
767 
getPowerOfTwoForSampleRatio(double ratio)768     private static int getPowerOfTwoForSampleRatio(double ratio) {
769         final int k = Integer.highestOneBit((int) Math.floor(ratio));
770         return Math.max(1, k);
771     }
772 
onHeaderDecoded(ImageDecoder decoder, ImageDecoder.ImageInfo info, ImageDecoder.Source source)773     private void onHeaderDecoded(ImageDecoder decoder, ImageDecoder.ImageInfo info,
774             ImageDecoder.Source source) {
775         int widthInPx = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, mWidth,
776                 mContext.getResources().getDisplayMetrics());
777         int heightInPx = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, mHeight,
778                 mContext.getResources().getDisplayMetrics());
779         int maxIconSizeInPx = Math.max(widthInPx, heightInPx);
780         int minDimen = (int) (1.5 * Math.min(widthInPx, heightInPx));
781         if (minDimen < maxIconSizeInPx) {
782             maxIconSizeInPx = minDimen;
783         }
784         final Size size = info.getSize();
785         final int originalSize = Math.max(size.getHeight(), size.getWidth());
786         final double ratio = (originalSize > maxIconSizeInPx)
787                 ? originalSize * 1f / maxIconSizeInPx
788                 : 1.0;
789         decoder.setTargetSampleSize(getPowerOfTwoForSampleRatio(ratio));
790     }
791 
setContentDescriptionForNotificationTextContent(RemoteViews views, CharSequence content, CharSequence sender)792     private void setContentDescriptionForNotificationTextContent(RemoteViews views,
793             CharSequence content, CharSequence sender) {
794         String newTextDescriptionWithNotificationContent = mContext.getString(
795                 R.string.new_notification_text_content_description, sender, content);
796         int idForContentDescription =
797                 mLayoutSize == LAYOUT_SMALL ? R.id.predefined_icon : R.id.text_content;
798         views.setContentDescription(idForContentDescription,
799                 newTextDescriptionWithNotificationContent);
800     }
801 
802     // Some messaging apps only include up to 6 messages in their notifications.
getMessagesCountText(int count)803     private String getMessagesCountText(int count) {
804         if (count >= MESSAGES_COUNT_OVERFLOW) {
805             return mContext.getResources().getString(
806                     R.string.messages_count_overflow_indicator, MESSAGES_COUNT_OVERFLOW);
807         }
808 
809         // Cache the locale-appropriate NumberFormat.  Configuration locale is guaranteed
810         // non-null, so the first time this is called we will always get the appropriate
811         // NumberFormat, then never regenerate it unless the locale changes on the fly.
812         final Locale curLocale = mContext.getResources().getConfiguration().getLocales().get(0);
813         if (!curLocale.equals(mLocale)) {
814             mLocale = curLocale;
815             mIntegerFormat = NumberFormat.getIntegerInstance(curLocale);
816         }
817         return mIntegerFormat.format(count);
818     }
819 
createStatusRemoteViews(ConversationStatus status)820     private RemoteViews createStatusRemoteViews(ConversationStatus status) {
821         RemoteViews views = setViewForContentLayout(new RemoteViews(mContext.getPackageName(),
822                 getLayoutForContent()));
823         CharSequence statusText = status.getDescription();
824         if (TextUtils.isEmpty(statusText)) {
825             statusText = getStatusTextByType(status.getActivity());
826         }
827         setPredefinedIconVisible(views);
828         views.setTextViewText(R.id.text_content, statusText);
829 
830         if (status.getActivity() == ACTIVITY_BIRTHDAY
831                 || status.getActivity() == ACTIVITY_UPCOMING_BIRTHDAY) {
832             setEmojiBackground(views, EMOJI_CAKE);
833         }
834 
835         Icon statusIcon = status.getIcon();
836         if (statusIcon != null) {
837             // No text content styled text on medium or large.
838             views.setViewVisibility(R.id.scrim_layout, View.VISIBLE);
839             views.setImageViewIcon(R.id.status_icon, statusIcon);
840             // Show 1-line subtext on large layout with status images.
841             if (mLayoutSize == LAYOUT_LARGE) {
842                 if (DEBUG) Log.d(TAG, "Remove name for large");
843                 views.setInt(R.id.content, "setGravity", Gravity.BOTTOM);
844                 views.setViewVisibility(R.id.name, View.GONE);
845                 views.setColorAttr(R.id.text_content, "setTextColor",
846                         android.R.attr.textColorPrimary);
847             } else if (mLayoutSize == LAYOUT_MEDIUM) {
848                 views.setViewVisibility(R.id.text_content, View.GONE);
849                 views.setTextViewText(R.id.name, statusText);
850             }
851         } else {
852             // Secondary text color for statuses without icons.
853             views.setColorAttr(R.id.text_content, "setTextColor",
854                     android.R.attr.textColorSecondary);
855             setMaxLines(views, false);
856         }
857         setAvailabilityDotPadding(views, R.dimen.availability_dot_status_padding);
858         views.setImageViewResource(R.id.predefined_icon, getDrawableForStatus(status));
859         CharSequence descriptionForStatus =
860                 getContentDescriptionForStatus(status);
861         CharSequence customContentDescriptionForStatus = mContext.getString(
862                 R.string.new_status_content_description, mTile.getUserName(), descriptionForStatus);
863         switch (mLayoutSize) {
864             case LAYOUT_LARGE:
865                 views.setContentDescription(R.id.text_content,
866                         customContentDescriptionForStatus);
867                 break;
868             case LAYOUT_MEDIUM:
869                 views.setContentDescription(statusIcon == null ? R.id.text_content : R.id.name,
870                         customContentDescriptionForStatus);
871                 break;
872             case LAYOUT_SMALL:
873                 views.setContentDescription(R.id.predefined_icon,
874                         customContentDescriptionForStatus);
875                 break;
876         }
877         return views;
878     }
879 
getContentDescriptionForStatus(ConversationStatus status)880     private CharSequence getContentDescriptionForStatus(ConversationStatus status) {
881         CharSequence name = mTile.getUserName();
882         if (!TextUtils.isEmpty(status.getDescription())) {
883             return status.getDescription();
884         }
885         switch (status.getActivity()) {
886             case ACTIVITY_NEW_STORY:
887                 return mContext.getString(R.string.new_story_status_content_description,
888                         name);
889             case ACTIVITY_ANNIVERSARY:
890                 return mContext.getString(R.string.anniversary_status_content_description, name);
891             case ACTIVITY_UPCOMING_BIRTHDAY:
892                 return mContext.getString(R.string.upcoming_birthday_status_content_description,
893                         name);
894             case ACTIVITY_BIRTHDAY:
895                 return mContext.getString(R.string.birthday_status_content_description, name);
896             case ACTIVITY_LOCATION:
897                 return mContext.getString(R.string.location_status_content_description, name);
898             case ACTIVITY_GAME:
899                 return mContext.getString(R.string.game_status);
900             case ACTIVITY_VIDEO:
901                 return mContext.getString(R.string.video_status);
902             case ACTIVITY_AUDIO:
903                 return mContext.getString(R.string.audio_status);
904             default:
905                 return EMPTY_STRING;
906         }
907     }
908 
getDrawableForStatus(ConversationStatus status)909     private int getDrawableForStatus(ConversationStatus status) {
910         switch (status.getActivity()) {
911             case ACTIVITY_NEW_STORY:
912                 return R.drawable.ic_pages;
913             case ACTIVITY_ANNIVERSARY:
914                 return R.drawable.ic_celebration;
915             case ACTIVITY_UPCOMING_BIRTHDAY:
916                 return R.drawable.ic_gift;
917             case ACTIVITY_BIRTHDAY:
918                 return R.drawable.ic_cake;
919             case ACTIVITY_LOCATION:
920                 return R.drawable.ic_location;
921             case ACTIVITY_GAME:
922                 return R.drawable.ic_play_games;
923             case ACTIVITY_VIDEO:
924                 return R.drawable.ic_video;
925             case ACTIVITY_AUDIO:
926                 return R.drawable.ic_music_note;
927             default:
928                 return R.drawable.ic_person;
929         }
930     }
931 
932     /**
933      * Update the padding of the availability dot. The padding on the availability dot decreases
934      * on the status layouts compared to all other layouts.
935      */
setAvailabilityDotPadding(RemoteViews views, int resId)936     private void setAvailabilityDotPadding(RemoteViews views, int resId) {
937         int startPadding = mContext.getResources().getDimensionPixelSize(resId);
938         int bottomPadding = mContext.getResources().getDimensionPixelSize(
939                 R.dimen.medium_content_padding_above_name);
940         views.setViewPadding(R.id.medium_content,
941                 mIsLeftToRight ? startPadding : 0, 0, mIsLeftToRight ? 0 : startPadding,
942                 bottomPadding);
943     }
944 
945     @Nullable
getBirthdayStatus( List<ConversationStatus> statuses)946     private ConversationStatus getBirthdayStatus(
947             List<ConversationStatus> statuses) {
948         Optional<ConversationStatus> birthdayStatus = statuses.stream().filter(
949                 c -> c.getActivity() == ACTIVITY_BIRTHDAY).findFirst();
950         if (birthdayStatus.isPresent()) {
951             return birthdayStatus.get();
952         }
953         if (!TextUtils.isEmpty(mTile.getBirthdayText())) {
954             return new ConversationStatus.Builder(mTile.getId(), ACTIVITY_BIRTHDAY).build();
955         }
956 
957         return null;
958     }
959 
960     /**
961      * Returns whether a {@code status} should have its own entire templated view.
962      *
963      * <p>A status may still be shown on the view (for example, as a new story ring) even if it's
964      * not valid to compose an entire view.
965      */
isStatusValidForEntireStatusView(ConversationStatus status)966     private boolean isStatusValidForEntireStatusView(ConversationStatus status) {
967         switch (status.getActivity()) {
968             // Birthday & Anniversary don't require text provided or icon provided.
969             case ACTIVITY_BIRTHDAY:
970             case ACTIVITY_ANNIVERSARY:
971                 return true;
972             default:
973                 // For future birthday, location, new story, video, music, game, and other, the
974                 // app must provide either text or an icon.
975                 return !TextUtils.isEmpty(status.getDescription())
976                         || status.getIcon() != null;
977         }
978     }
979 
getStatusTextByType(int activity)980     private String getStatusTextByType(int activity) {
981         switch (activity) {
982             case ACTIVITY_BIRTHDAY:
983                 return mContext.getString(R.string.birthday_status);
984             case ACTIVITY_UPCOMING_BIRTHDAY:
985                 return mContext.getString(R.string.upcoming_birthday_status);
986             case ACTIVITY_ANNIVERSARY:
987                 return mContext.getString(R.string.anniversary_status);
988             case ACTIVITY_LOCATION:
989                 return mContext.getString(R.string.location_status);
990             case ACTIVITY_NEW_STORY:
991                 return mContext.getString(R.string.new_story_status);
992             case ACTIVITY_VIDEO:
993                 return mContext.getString(R.string.video_status);
994             case ACTIVITY_AUDIO:
995                 return mContext.getString(R.string.audio_status);
996             case ACTIVITY_GAME:
997                 return mContext.getString(R.string.game_status);
998             default:
999                 return EMPTY_STRING;
1000         }
1001     }
1002 
decorateBackground(RemoteViews views, CharSequence content)1003     private RemoteViews decorateBackground(RemoteViews views, CharSequence content) {
1004         CharSequence emoji = getDoubleEmoji(content);
1005         if (!TextUtils.isEmpty(emoji)) {
1006             setEmojiBackground(views, emoji);
1007             setPunctuationBackground(views, null);
1008             return views;
1009         }
1010 
1011         CharSequence punctuation = getDoublePunctuation(content);
1012         setEmojiBackground(views, null);
1013         setPunctuationBackground(views, punctuation);
1014         return views;
1015     }
1016 
setEmojiBackground(RemoteViews views, CharSequence content)1017     private RemoteViews setEmojiBackground(RemoteViews views, CharSequence content) {
1018         if (TextUtils.isEmpty(content)) {
1019             views.setViewVisibility(R.id.emojis, View.GONE);
1020             return views;
1021         }
1022         views.setTextViewText(R.id.emoji1, content);
1023         views.setTextViewText(R.id.emoji2, content);
1024         views.setTextViewText(R.id.emoji3, content);
1025 
1026         views.setViewVisibility(R.id.emojis, View.VISIBLE);
1027         return views;
1028     }
1029 
setPunctuationBackground(RemoteViews views, CharSequence content)1030     private RemoteViews setPunctuationBackground(RemoteViews views, CharSequence content) {
1031         if (TextUtils.isEmpty(content)) {
1032             views.setViewVisibility(R.id.punctuations, View.GONE);
1033             return views;
1034         }
1035         views.setTextViewText(R.id.punctuation1, content);
1036         views.setTextViewText(R.id.punctuation2, content);
1037         views.setTextViewText(R.id.punctuation3, content);
1038         views.setTextViewText(R.id.punctuation4, content);
1039         views.setTextViewText(R.id.punctuation5, content);
1040         views.setTextViewText(R.id.punctuation6, content);
1041 
1042         views.setViewVisibility(R.id.punctuations, View.VISIBLE);
1043         return views;
1044     }
1045 
1046     /** Returns punctuation character(s) if {@code message} has double punctuation ("!" or "?"). */
1047     @VisibleForTesting
getDoublePunctuation(CharSequence message)1048     CharSequence getDoublePunctuation(CharSequence message) {
1049         if (!ANY_DOUBLE_MARK_PATTERN.matcher(message).find()) {
1050             return null;
1051         }
1052         if (MIXED_MARK_PATTERN.matcher(message).find()) {
1053             return "!?";
1054         }
1055         Matcher doubleQuestionMatcher = DOUBLE_QUESTION_PATTERN.matcher(message);
1056         if (!doubleQuestionMatcher.find()) {
1057             return "!";
1058         }
1059         Matcher doubleExclamationMatcher = DOUBLE_EXCLAMATION_PATTERN.matcher(message);
1060         if (!doubleExclamationMatcher.find()) {
1061             return "?";
1062         }
1063         // If we have both "!!" and "??", return the one that comes first.
1064         if (doubleQuestionMatcher.start() < doubleExclamationMatcher.start()) {
1065             return "?";
1066         }
1067         return "!";
1068     }
1069 
1070     /** Returns emoji if {@code message} has two of the same emoji in sequence. */
1071     @VisibleForTesting
getDoubleEmoji(CharSequence message)1072     CharSequence getDoubleEmoji(CharSequence message) {
1073         Matcher unicodeEmojiMatcher = EMOJI_PATTERN.matcher(message);
1074         // Stores the start and end indices of each matched emoji.
1075         List<Pair<Integer, Integer>> emojiIndices = new ArrayList<>();
1076         // Stores each emoji text.
1077         List<CharSequence> emojiTexts = new ArrayList<>();
1078 
1079         // Scan message for emojis
1080         while (unicodeEmojiMatcher.find()) {
1081             int start = unicodeEmojiMatcher.start();
1082             int end = unicodeEmojiMatcher.end();
1083             emojiIndices.add(new Pair(start, end));
1084             emojiTexts.add(message.subSequence(start, end));
1085         }
1086 
1087         if (DEBUG) Log.d(TAG, "Number of emojis in the message: " + emojiIndices.size());
1088         if (emojiIndices.size() < 2) {
1089             return null;
1090         }
1091 
1092         for (int i = 1; i < emojiIndices.size(); ++i) {
1093             Pair<Integer, Integer> second = emojiIndices.get(i);
1094             Pair<Integer, Integer> first = emojiIndices.get(i - 1);
1095 
1096             // Check if second emoji starts right after first starts
1097             if (second.first == first.second) {
1098                 // Check if emojis in sequence are the same
1099                 if (Objects.equals(emojiTexts.get(i), emojiTexts.get(i - 1))) {
1100                     if (DEBUG) {
1101                         Log.d(TAG, "Two of the same emojis in sequence: " + emojiTexts.get(i));
1102                     }
1103                     return emojiTexts.get(i);
1104                 }
1105             }
1106         }
1107 
1108         // No equal emojis in sequence.
1109         return null;
1110     }
1111 
setViewForContentLayout(RemoteViews views)1112     private RemoteViews setViewForContentLayout(RemoteViews views) {
1113         views = decorateBackground(views, "");
1114         views.setContentDescription(R.id.predefined_icon, null);
1115         views.setContentDescription(R.id.text_content, null);
1116         views.setContentDescription(R.id.name, null);
1117         views.setContentDescription(R.id.image, null);
1118         views.setAccessibilityTraversalAfter(R.id.text_content, R.id.name);
1119         if (mLayoutSize == LAYOUT_SMALL) {
1120             views.setViewVisibility(R.id.predefined_icon, View.VISIBLE);
1121             views.setViewVisibility(R.id.name, View.GONE);
1122         } else {
1123             views.setViewVisibility(R.id.predefined_icon, View.GONE);
1124             views.setViewVisibility(R.id.name, View.VISIBLE);
1125             views.setViewVisibility(R.id.text_content, View.VISIBLE);
1126             views.setViewVisibility(R.id.subtext, View.GONE);
1127             views.setViewVisibility(R.id.image, View.GONE);
1128             views.setViewVisibility(R.id.scrim_layout, View.GONE);
1129         }
1130 
1131         if (mLayoutSize == LAYOUT_MEDIUM) {
1132             // Maximize vertical padding with an avatar size of 48dp and name on medium.
1133             if (DEBUG) Log.d(TAG, "Set vertical padding: " + mMediumVerticalPadding);
1134             int horizontalPadding = (int) Math.floor(MAX_MEDIUM_PADDING * mDensity);
1135             int verticalPadding = (int) Math.floor(mMediumVerticalPadding * mDensity);
1136             views.setViewPadding(R.id.content, horizontalPadding, verticalPadding,
1137                     horizontalPadding,
1138                     verticalPadding);
1139             views.setViewPadding(R.id.name, 0, 0, 0, 0);
1140             // Expand the name font on medium if there's space.
1141             int heightRequiredForMaxContentText = (int) (mContext.getResources().getDimension(
1142                     R.dimen.medium_height_for_max_name_text_size) / mDensity);
1143             if (mHeight > heightRequiredForMaxContentText) {
1144                 views.setTextViewTextSize(R.id.name, TypedValue.COMPLEX_UNIT_PX,
1145                         (int) mContext.getResources().getDimension(
1146                                 R.dimen.max_name_text_size_for_medium));
1147             }
1148         }
1149 
1150         if (mLayoutSize == LAYOUT_LARGE) {
1151             // Decrease the view padding below the name on all layouts besides notification "text".
1152             views.setViewPadding(R.id.name, 0, 0, 0,
1153                     mContext.getResources().getDimensionPixelSize(
1154                             R.dimen.below_name_text_padding));
1155             // All large layouts besides missed calls & statuses with images, have gravity top.
1156             views.setInt(R.id.content, "setGravity", Gravity.TOP);
1157         }
1158 
1159         // For all layouts except Missed Calls, ensure predefined icon is regular sized.
1160         views.setViewLayoutHeightDimen(R.id.predefined_icon, R.dimen.regular_predefined_icon);
1161         views.setViewLayoutWidthDimen(R.id.predefined_icon, R.dimen.regular_predefined_icon);
1162 
1163         views.setViewVisibility(R.id.messages_count, View.GONE);
1164         if (mTile.getUserName() != null) {
1165             views.setTextViewText(R.id.name, mTile.getUserName());
1166         }
1167 
1168         return views;
1169     }
1170 
createLastInteractionRemoteViews()1171     private RemoteViews createLastInteractionRemoteViews() {
1172         RemoteViews views = new RemoteViews(mContext.getPackageName(), getEmptyLayout());
1173         views.setInt(R.id.name, "setMaxLines", NAME_MAX_LINES_WITH_LAST_INTERACTION);
1174         if (mLayoutSize == LAYOUT_SMALL) {
1175             views.setViewVisibility(R.id.name, View.VISIBLE);
1176             views.setViewVisibility(R.id.predefined_icon, View.GONE);
1177             views.setViewVisibility(R.id.messages_count, View.GONE);
1178         }
1179         if (mTile.getUserName() != null) {
1180             views.setTextViewText(R.id.name, mTile.getUserName());
1181         }
1182         String status = getLastInteractionString(mContext,
1183                 mTile.getLastInteractionTimestamp());
1184         if (status != null) {
1185             if (DEBUG) Log.d(TAG, "Show last interaction");
1186             views.setViewVisibility(R.id.last_interaction, View.VISIBLE);
1187             views.setTextViewText(R.id.last_interaction, status);
1188         } else {
1189             if (DEBUG) Log.d(TAG, "Hide last interaction");
1190             views.setViewVisibility(R.id.last_interaction, View.GONE);
1191             if (mLayoutSize == LAYOUT_MEDIUM) {
1192                 views.setInt(R.id.name, "setMaxLines", NAME_MAX_LINES_WITHOUT_LAST_INTERACTION);
1193             }
1194         }
1195         return views;
1196     }
1197 
getEmptyLayout()1198     private int getEmptyLayout() {
1199         switch (mLayoutSize) {
1200             case LAYOUT_MEDIUM:
1201                 return R.layout.people_tile_medium_empty;
1202             case LAYOUT_LARGE:
1203                 return R.layout.people_tile_large_empty;
1204             case LAYOUT_SMALL:
1205             default:
1206                 return getLayoutSmallByHeight();
1207         }
1208     }
1209 
getLayoutForNotificationContent()1210     private int getLayoutForNotificationContent() {
1211         switch (mLayoutSize) {
1212             case LAYOUT_MEDIUM:
1213                 return R.layout.people_tile_medium_with_content;
1214             case LAYOUT_LARGE:
1215                 return R.layout.people_tile_large_with_notification_content;
1216             case LAYOUT_SMALL:
1217             default:
1218                 return getLayoutSmallByHeight();
1219         }
1220     }
1221 
getLayoutForContent()1222     private int getLayoutForContent() {
1223         switch (mLayoutSize) {
1224             case LAYOUT_MEDIUM:
1225                 return R.layout.people_tile_medium_with_content;
1226             case LAYOUT_LARGE:
1227                 return R.layout.people_tile_large_with_status_content;
1228             case LAYOUT_SMALL:
1229             default:
1230                 return getLayoutSmallByHeight();
1231         }
1232     }
1233 
getViewForDndRemoteViews()1234     private int getViewForDndRemoteViews() {
1235         switch (mLayoutSize) {
1236             case LAYOUT_MEDIUM:
1237                 return R.layout.people_tile_with_suppression_detail_content_horizontal;
1238             case LAYOUT_LARGE:
1239                 return R.layout.people_tile_with_suppression_detail_content_vertical;
1240             case LAYOUT_SMALL:
1241             default:
1242                 return getLayoutSmallByHeight();
1243         }
1244     }
1245 
getLayoutSmallByHeight()1246     private int getLayoutSmallByHeight() {
1247         if (mHeight >= getSizeInDp(R.dimen.required_height_for_medium)) {
1248             return R.layout.people_tile_small;
1249         }
1250         return R.layout.people_tile_small_horizontal;
1251     }
1252 
1253     /** Returns a bitmap with the user icon and package icon. */
getPersonIconBitmap(Context context, PeopleSpaceTile tile, int maxAvatarSize)1254     public static Bitmap getPersonIconBitmap(Context context, PeopleSpaceTile tile,
1255             int maxAvatarSize) {
1256         boolean hasNewStory = getHasNewStory(tile);
1257         return getPersonIconBitmap(context, tile, maxAvatarSize, hasNewStory);
1258     }
1259 
1260     /** Returns a bitmap with the user icon and package icon. */
getPersonIconBitmap( Context context, PeopleSpaceTile tile, int maxAvatarSize, boolean hasNewStory)1261     private static Bitmap getPersonIconBitmap(
1262             Context context, PeopleSpaceTile tile, int maxAvatarSize, boolean hasNewStory) {
1263         Icon icon = tile.getUserIcon();
1264         if (icon == null) {
1265             Drawable placeholder = context.getDrawable(R.drawable.ic_avatar_with_badge);
1266             return convertDrawableToDisabledBitmap(placeholder);
1267         }
1268         PeopleStoryIconFactory storyIcon = new PeopleStoryIconFactory(context,
1269                 context.getPackageManager(),
1270                 IconDrawableFactory.newInstance(context, false),
1271                 maxAvatarSize);
1272         RoundedBitmapDrawable roundedDrawable = RoundedBitmapDrawableFactory.create(
1273                 context.getResources(), icon.getBitmap());
1274         Drawable personDrawable = storyIcon.getPeopleTileDrawable(roundedDrawable,
1275                 tile.getPackageName(), getUserId(tile), tile.isImportantConversation(),
1276                 hasNewStory);
1277 
1278         if (isDndBlockingTileData(tile)) {
1279             // If DND is blocking the conversation, then display the icon in grayscale.
1280             ColorMatrix colorMatrix = new ColorMatrix();
1281             colorMatrix.setSaturation(0);
1282             personDrawable.setColorFilter(new ColorMatrixColorFilter(colorMatrix));
1283         }
1284 
1285         return convertDrawableToBitmap(personDrawable);
1286     }
1287 
1288     /** Returns a readable status describing the {@code lastInteraction}. */
1289     @Nullable
getLastInteractionString(Context context, long lastInteraction)1290     public static String getLastInteractionString(Context context, long lastInteraction) {
1291         if (lastInteraction == 0L) {
1292             Log.e(TAG, "Could not get valid last interaction");
1293             return null;
1294         }
1295         long now = System.currentTimeMillis();
1296         Duration durationSinceLastInteraction = Duration.ofMillis(now - lastInteraction);
1297         if (durationSinceLastInteraction.toDays() <= ONE_DAY) {
1298             return null;
1299         } else if (durationSinceLastInteraction.toDays() < DAYS_IN_A_WEEK) {
1300             return context.getString(R.string.days_timestamp,
1301                     durationSinceLastInteraction.toDays());
1302         } else if (durationSinceLastInteraction.toDays() == DAYS_IN_A_WEEK) {
1303             return context.getString(R.string.one_week_timestamp);
1304         } else if (durationSinceLastInteraction.toDays() < DAYS_IN_A_WEEK * 2) {
1305             return context.getString(R.string.over_one_week_timestamp);
1306         } else if (durationSinceLastInteraction.toDays() == DAYS_IN_A_WEEK * 2) {
1307             return context.getString(R.string.two_weeks_timestamp);
1308         } else {
1309             // Over 2 weeks ago
1310             return context.getString(R.string.over_two_weeks_timestamp);
1311         }
1312     }
1313 
1314     /**
1315      * Estimates the height (in dp) which the text will have given the text size and the available
1316      * width. Returns Integer.MAX_VALUE if the estimation couldn't be obtained, as this is intended
1317      * to be used an estimate of the maximum.
1318      */
estimateTextHeight( CharSequence text, @DimenRes int textSizeResId, int availableWidthDp)1319     private int estimateTextHeight(
1320             CharSequence text,
1321             @DimenRes int textSizeResId,
1322             int availableWidthDp) {
1323         StaticLayout staticLayout = buildStaticLayout(text, textSizeResId, availableWidthDp);
1324         if (staticLayout == null) {
1325             // Return max value (rather than e.g. -1) so the value can be used with <= bound checks.
1326             return Integer.MAX_VALUE;
1327         }
1328         return pxToDp(staticLayout.getHeight());
1329     }
1330 
1331     /**
1332      * Builds a StaticLayout for the text given the text size and available width. This can be used
1333      * to obtain information about how TextView will lay out the text. Returns null if any error
1334      * occurred creating a TextView.
1335      */
1336     @Nullable
buildStaticLayout( CharSequence text, @DimenRes int textSizeResId, int availableWidthDp)1337     private StaticLayout buildStaticLayout(
1338             CharSequence text,
1339             @DimenRes int textSizeResId,
1340             int availableWidthDp) {
1341         try {
1342             TextView textView = new TextView(mContext);
1343             textView.setTextSize(TypedValue.COMPLEX_UNIT_PX,
1344                     mContext.getResources().getDimension(textSizeResId));
1345             textView.setTextAppearance(android.R.style.TextAppearance_DeviceDefault);
1346             TextPaint paint = textView.getPaint();
1347             return StaticLayout.Builder.obtain(
1348                     text, 0, text.length(), paint, dpToPx(availableWidthDp))
1349                     // Simple break strategy avoids hyphenation unless there's a single word longer
1350                     // than the line width. We use this break strategy so that we consider text to
1351                     // "fit" only if it fits in a nice way (i.e. without hyphenation in the middle
1352                     // of words).
1353                     .setBreakStrategy(LineBreaker.BREAK_STRATEGY_SIMPLE)
1354                     .build();
1355         } catch (Exception e) {
1356             Log.e(TAG, "Could not create static layout: " + e);
1357             return null;
1358         }
1359     }
1360 
dpToPx(float dp)1361     private int dpToPx(float dp) {
1362         return (int) (dp * mDensity);
1363     }
1364 
pxToDp(@x float px)1365     private int pxToDp(@Px float px) {
1366         return (int) (px / mDensity);
1367     }
1368 
1369     private static final class RemoteViewsAndSizes {
1370         final RemoteViews mRemoteViews;
1371         final int mAvatarSize;
1372 
RemoteViewsAndSizes(RemoteViews remoteViews, int avatarSize)1373         RemoteViewsAndSizes(RemoteViews remoteViews, int avatarSize) {
1374             mRemoteViews = remoteViews;
1375             mAvatarSize = avatarSize;
1376         }
1377     }
1378 
convertDrawableToDisabledBitmap(Drawable icon)1379     private static Bitmap convertDrawableToDisabledBitmap(Drawable icon) {
1380         Bitmap appIconAsBitmap = convertDrawableToBitmap(icon);
1381         FastBitmapDrawable drawable = new FastBitmapDrawable(appIconAsBitmap);
1382         drawable.setIsDisabled(true);
1383         return convertDrawableToBitmap(drawable);
1384     }
1385 }
1386