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