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