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