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