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