| /* |
| * Copyright (C) 2021 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| package com.android.systemui.people; |
| |
| import static android.app.Notification.CATEGORY_MISSED_CALL; |
| import static android.app.people.ConversationStatus.ACTIVITY_ANNIVERSARY; |
| import static android.app.people.ConversationStatus.ACTIVITY_AUDIO; |
| import static android.app.people.ConversationStatus.ACTIVITY_BIRTHDAY; |
| import static android.app.people.ConversationStatus.ACTIVITY_GAME; |
| import static android.app.people.ConversationStatus.ACTIVITY_LOCATION; |
| import static android.app.people.ConversationStatus.ACTIVITY_NEW_STORY; |
| import static android.app.people.ConversationStatus.ACTIVITY_UPCOMING_BIRTHDAY; |
| import static android.app.people.ConversationStatus.ACTIVITY_VIDEO; |
| import static android.app.people.ConversationStatus.AVAILABILITY_AVAILABLE; |
| import static android.appwidget.AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT; |
| import static android.appwidget.AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH; |
| import static android.appwidget.AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT; |
| import static android.appwidget.AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH; |
| import static android.appwidget.AppWidgetManager.OPTION_APPWIDGET_SIZES; |
| import static android.util.TypedValue.COMPLEX_UNIT_DIP; |
| import static android.util.TypedValue.COMPLEX_UNIT_PX; |
| |
| import static com.android.systemui.people.PeopleSpaceUtils.STARRED_CONTACT; |
| import static com.android.systemui.people.PeopleSpaceUtils.VALID_CONTACT; |
| import static com.android.systemui.people.PeopleSpaceUtils.convertDrawableToBitmap; |
| import static com.android.systemui.people.PeopleSpaceUtils.getUserId; |
| |
| import android.annotation.Nullable; |
| import android.app.PendingIntent; |
| import android.app.people.ConversationStatus; |
| import android.app.people.PeopleSpaceTile; |
| import android.content.Context; |
| import android.content.Intent; |
| import android.graphics.Bitmap; |
| import android.graphics.ColorMatrix; |
| import android.graphics.ColorMatrixColorFilter; |
| import android.graphics.ImageDecoder; |
| import android.graphics.drawable.Drawable; |
| import android.graphics.drawable.Icon; |
| import android.graphics.text.LineBreaker; |
| import android.net.Uri; |
| import android.os.Bundle; |
| import android.os.UserHandle; |
| import android.text.StaticLayout; |
| import android.text.TextPaint; |
| import android.text.TextUtils; |
| import android.util.IconDrawableFactory; |
| import android.util.Log; |
| import android.util.Pair; |
| import android.util.Size; |
| import android.util.SizeF; |
| import android.util.TypedValue; |
| import android.view.Gravity; |
| import android.view.View; |
| import android.widget.RemoteViews; |
| import android.widget.TextView; |
| |
| import androidx.annotation.DimenRes; |
| import androidx.annotation.Px; |
| import androidx.core.graphics.drawable.RoundedBitmapDrawable; |
| import androidx.core.graphics.drawable.RoundedBitmapDrawableFactory; |
| import androidx.core.math.MathUtils; |
| |
| import com.android.internal.annotations.VisibleForTesting; |
| import com.android.launcher3.icons.FastBitmapDrawable; |
| import com.android.systemui.R; |
| import com.android.systemui.people.widget.LaunchConversationActivity; |
| import com.android.systemui.people.widget.PeopleSpaceWidgetProvider; |
| import com.android.systemui.people.widget.PeopleTileKey; |
| |
| import java.io.IOException; |
| import java.text.NumberFormat; |
| import java.time.Duration; |
| import java.util.ArrayList; |
| import java.util.Arrays; |
| import java.util.Comparator; |
| import java.util.List; |
| import java.util.Locale; |
| import java.util.Map; |
| import java.util.Objects; |
| import java.util.Optional; |
| import java.util.function.Function; |
| import java.util.regex.Matcher; |
| import java.util.regex.Pattern; |
| import java.util.stream.Collectors; |
| |
| /** Functions that help creating the People tile layouts. */ |
| public class PeopleTileViewHelper { |
| /** Turns on debugging information about People Space. */ |
| private static final boolean DEBUG = PeopleSpaceUtils.DEBUG; |
| private static final String TAG = "PeopleTileView"; |
| |
| private static final int DAYS_IN_A_WEEK = 7; |
| private static final int ONE_DAY = 1; |
| |
| public static final int LAYOUT_SMALL = 0; |
| public static final int LAYOUT_MEDIUM = 1; |
| public static final int LAYOUT_LARGE = 2; |
| |
| private static final int MIN_CONTENT_MAX_LINES = 2; |
| private static final int NAME_MAX_LINES_WITHOUT_LAST_INTERACTION = 3; |
| private static final int NAME_MAX_LINES_WITH_LAST_INTERACTION = 1; |
| |
| private static final int FIXED_HEIGHT_DIMENS_FOR_LARGE_NOTIF_CONTENT = 16 + 22 + 8 + 16; |
| private static final int FIXED_HEIGHT_DIMENS_FOR_LARGE_STATUS_CONTENT = 16 + 16 + 24 + 4 + 16; |
| private static final int MIN_MEDIUM_VERTICAL_PADDING = 4; |
| private static final int MAX_MEDIUM_PADDING = 16; |
| private static final int FIXED_HEIGHT_DIMENS_FOR_MEDIUM_CONTENT_BEFORE_PADDING = 8 + 4; |
| private static final int FIXED_HEIGHT_DIMENS_FOR_SMALL_VERTICAL = 6 + 4 + 8; |
| private static final int FIXED_WIDTH_DIMENS_FOR_SMALL_VERTICAL = 4 + 4; |
| private static final int FIXED_HEIGHT_DIMENS_FOR_SMALL_HORIZONTAL = 6 + 4; |
| private static final int FIXED_WIDTH_DIMENS_FOR_SMALL_HORIZONTAL = 8 + 8; |
| |
| private static final int MESSAGES_COUNT_OVERFLOW = 6; |
| |
| private static final CharSequence EMOJI_CAKE = "\ud83c\udf82"; |
| |
| private static final Pattern DOUBLE_EXCLAMATION_PATTERN = Pattern.compile("[!][!]+"); |
| private static final Pattern DOUBLE_QUESTION_PATTERN = Pattern.compile("[?][?]+"); |
| private static final Pattern ANY_DOUBLE_MARK_PATTERN = Pattern.compile("[!?][!?]+"); |
| private static final Pattern MIXED_MARK_PATTERN = Pattern.compile("![?].*|.*[?]!"); |
| |
| static final String BRIEF_PAUSE_ON_TALKBACK = "\n\n"; |
| |
| // This regex can be used to match Unicode emoji characters and character sequences. It's from |
| // the official Unicode site (https://unicode.org/reports/tr51/#EBNF_and_Regex) with minor |
| // changes to fit our needs. It should be updated once new emoji categories are added. |
| // |
| // Emoji categories that can be matched by this regex: |
| // - Country flags. "\p{RI}\p{RI}" matches country flags since they always consist of 2 Unicode |
| // scalars. |
| // - Single-Character Emoji. "\p{Emoji}" matches Single-Character Emojis. |
| // - Emoji with modifiers. E.g. Emojis with different skin tones. "\p{Emoji}\p{EMod}" matches |
| // them. |
| // - Emoji Presentation. Those are characters which can normally be drawn as either text or as |
| // Emoji. "\p{Emoji}\x{FE0F}" matches them. |
| // - Emoji Keycap. E.g. Emojis for number 0 to 9. "\p{Emoji}\x{FE0F}\x{20E3}" matches them. |
| // - Emoji tag sequence. "\p{Emoji}[\x{E0020}-\x{E007E}]+\x{E007F}" matches them. |
| // - Emoji Zero-Width Joiner (ZWJ) Sequence. A ZWJ emoji is actually multiple emojis joined by |
| // the jointer "0x200D". |
| // |
| // Note: since "\p{Emoji}" also matches some ASCII characters like digits 0-9, we use |
| // "\p{Emoji}&&\p{So}" to exclude them. This is the change we made from the official emoji |
| // regex. |
| private static final String UNICODE_EMOJI_REGEX = |
| "\\p{RI}\\p{RI}|" |
| + "(" |
| + "\\p{Emoji}(\\p{EMod}|\\x{FE0F}\\x{20E3}?|[\\x{E0020}-\\x{E007E}]+\\x{E007F})" |
| + "|[\\p{Emoji}&&\\p{So}]" |
| + ")" |
| + "(" |
| + "\\x{200D}" |
| + "\\p{Emoji}(\\p{EMod}|\\x{FE0F}\\x{20E3}?|[\\x{E0020}-\\x{E007E}]+\\x{E007F})" |
| + "?)*"; |
| |
| private static final Pattern EMOJI_PATTERN = Pattern.compile(UNICODE_EMOJI_REGEX); |
| |
| public static final String EMPTY_STRING = ""; |
| |
| private int mMediumVerticalPadding; |
| |
| private Context mContext; |
| @Nullable |
| private PeopleSpaceTile mTile; |
| private PeopleTileKey mKey; |
| private float mDensity; |
| private int mAppWidgetId; |
| private int mWidth; |
| private int mHeight; |
| private int mLayoutSize; |
| private boolean mIsLeftToRight; |
| |
| private Locale mLocale; |
| private NumberFormat mIntegerFormat; |
| |
| PeopleTileViewHelper(Context context, @Nullable PeopleSpaceTile tile, |
| int appWidgetId, int width, int height, PeopleTileKey key) { |
| mContext = context; |
| mTile = tile; |
| mKey = key; |
| mAppWidgetId = appWidgetId; |
| mDensity = mContext.getResources().getDisplayMetrics().density; |
| mWidth = width; |
| mHeight = height; |
| mLayoutSize = getLayoutSize(); |
| mIsLeftToRight = TextUtils.getLayoutDirectionFromLocale(Locale.getDefault()) |
| == View.LAYOUT_DIRECTION_LTR; |
| } |
| |
| /** |
| * Creates a {@link RemoteViews} for the specified arguments. The RemoteViews will support all |
| * the sizes present in {@code options.}. |
| */ |
| public static RemoteViews createRemoteViews(Context context, @Nullable PeopleSpaceTile tile, |
| int appWidgetId, Bundle options, PeopleTileKey key) { |
| List<SizeF> widgetSizes = getWidgetSizes(context, options); |
| Map<SizeF, RemoteViews> sizeToRemoteView = |
| widgetSizes |
| .stream() |
| .distinct() |
| .collect(Collectors.toMap( |
| Function.identity(), |
| size -> new PeopleTileViewHelper( |
| context, tile, appWidgetId, |
| (int) size.getWidth(), |
| (int) size.getHeight(), |
| key) |
| .getViews())); |
| return new RemoteViews(sizeToRemoteView); |
| } |
| |
| private static List<SizeF> getWidgetSizes(Context context, Bundle options) { |
| float density = context.getResources().getDisplayMetrics().density; |
| List<SizeF> widgetSizes = options.getParcelableArrayList(OPTION_APPWIDGET_SIZES); |
| // If the full list of sizes was provided in the options bundle, use that. |
| if (widgetSizes != null && !widgetSizes.isEmpty()) return widgetSizes; |
| |
| // Otherwise, create a list using the portrait/landscape sizes. |
| int defaultWidth = getSizeInDp(context, R.dimen.default_width, density); |
| int defaultHeight = getSizeInDp(context, R.dimen.default_height, density); |
| widgetSizes = new ArrayList<>(2); |
| |
| int portraitWidth = options.getInt(OPTION_APPWIDGET_MIN_WIDTH, defaultWidth); |
| int portraitHeight = options.getInt(OPTION_APPWIDGET_MAX_HEIGHT, defaultHeight); |
| widgetSizes.add(new SizeF(portraitWidth, portraitHeight)); |
| |
| int landscapeWidth = options.getInt(OPTION_APPWIDGET_MAX_WIDTH, defaultWidth); |
| int landscapeHeight = options.getInt(OPTION_APPWIDGET_MIN_HEIGHT, defaultHeight); |
| widgetSizes.add(new SizeF(landscapeWidth, landscapeHeight)); |
| |
| return widgetSizes; |
| } |
| |
| @VisibleForTesting |
| RemoteViews getViews() { |
| RemoteViews viewsForTile = getViewForTile(); |
| int maxAvatarSize = getMaxAvatarSize(viewsForTile); |
| RemoteViews views = setCommonRemoteViewsFields(viewsForTile, maxAvatarSize); |
| return setLaunchIntents(views); |
| } |
| |
| /** |
| * The prioritization for the {@code mTile} content is missed calls, followed by notification |
| * content, then birthdays, then the most recent status, and finally last interaction. |
| */ |
| private RemoteViews getViewForTile() { |
| if (DEBUG) Log.d(TAG, "Creating view for tile key: " + mKey.toString()); |
| if (mTile == null || mTile.isPackageSuspended() || mTile.isUserQuieted()) { |
| if (DEBUG) Log.d(TAG, "Create suppressed view: " + mTile); |
| return createSuppressedView(); |
| } |
| |
| if (isDndBlockingTileData(mTile)) { |
| if (DEBUG) Log.d(TAG, "Create dnd view"); |
| return createDndRemoteViews().mRemoteViews; |
| } |
| |
| if (Objects.equals(mTile.getNotificationCategory(), CATEGORY_MISSED_CALL)) { |
| if (DEBUG) Log.d(TAG, "Create missed call view"); |
| return createMissedCallRemoteViews(); |
| } |
| |
| if (mTile.getNotificationKey() != null) { |
| if (DEBUG) Log.d(TAG, "Create notification view"); |
| return createNotificationRemoteViews(); |
| } |
| |
| // TODO: Add sorting when we expose timestamp of statuses. |
| List<ConversationStatus> statusesForEntireView = |
| mTile.getStatuses() == null ? Arrays.asList() : mTile.getStatuses().stream().filter( |
| c -> isStatusValidForEntireStatusView(c)).collect(Collectors.toList()); |
| ConversationStatus birthdayStatus = getBirthdayStatus(statusesForEntireView); |
| if (birthdayStatus != null) { |
| if (DEBUG) Log.d(TAG, "Create birthday view"); |
| return createStatusRemoteViews(birthdayStatus); |
| } |
| |
| if (!statusesForEntireView.isEmpty()) { |
| if (DEBUG) { |
| Log.d(TAG, |
| "Create status view for: " + statusesForEntireView.get(0).getActivity()); |
| } |
| ConversationStatus mostRecentlyStartedStatus = statusesForEntireView.stream().max( |
| Comparator.comparing(s -> s.getStartTimeMillis())).get(); |
| return createStatusRemoteViews(mostRecentlyStartedStatus); |
| } |
| |
| return createLastInteractionRemoteViews(); |
| } |
| |
| private static boolean isDndBlockingTileData(@Nullable PeopleSpaceTile tile) { |
| if (tile == null) return false; |
| |
| int notificationPolicyState = tile.getNotificationPolicyState(); |
| if ((notificationPolicyState & PeopleSpaceTile.SHOW_CONVERSATIONS) != 0) { |
| // Not in DND, or all conversations |
| if (DEBUG) Log.d(TAG, "Tile can show all data: " + tile.getUserName()); |
| return false; |
| } |
| if ((notificationPolicyState & PeopleSpaceTile.SHOW_IMPORTANT_CONVERSATIONS) != 0 |
| && tile.isImportantConversation()) { |
| if (DEBUG) Log.d(TAG, "Tile can show important: " + tile.getUserName()); |
| return false; |
| } |
| if ((notificationPolicyState & PeopleSpaceTile.SHOW_STARRED_CONTACTS) != 0 |
| && tile.getContactAffinity() == STARRED_CONTACT) { |
| if (DEBUG) Log.d(TAG, "Tile can show starred: " + tile.getUserName()); |
| return false; |
| } |
| if ((notificationPolicyState & PeopleSpaceTile.SHOW_CONTACTS) != 0 |
| && (tile.getContactAffinity() == VALID_CONTACT |
| || tile.getContactAffinity() == STARRED_CONTACT)) { |
| if (DEBUG) Log.d(TAG, "Tile can show contacts: " + tile.getUserName()); |
| return false; |
| } |
| if (DEBUG) Log.d(TAG, "Tile can show if can bypass DND: " + tile.getUserName()); |
| return !tile.canBypassDnd(); |
| } |
| |
| private RemoteViews createSuppressedView() { |
| RemoteViews views; |
| if (mTile != null && mTile.isUserQuieted()) { |
| views = new RemoteViews(mContext.getPackageName(), |
| R.layout.people_tile_work_profile_quiet_layout); |
| } else { |
| views = new RemoteViews(mContext.getPackageName(), |
| R.layout.people_tile_suppressed_layout); |
| } |
| Drawable appIcon = mContext.getDrawable(R.drawable.ic_conversation_icon); |
| Bitmap disabledBitmap = convertDrawableToDisabledBitmap(appIcon); |
| views.setImageViewBitmap(R.id.icon, disabledBitmap); |
| return views; |
| } |
| |
| private void setMaxLines(RemoteViews views, boolean showSender) { |
| int textSizeResId; |
| int nameHeight; |
| if (mLayoutSize == LAYOUT_LARGE) { |
| textSizeResId = R.dimen.content_text_size_for_large; |
| nameHeight = getLineHeightFromResource(R.dimen.name_text_size_for_large_content); |
| } else { |
| textSizeResId = R.dimen.content_text_size_for_medium; |
| nameHeight = getLineHeightFromResource(R.dimen.name_text_size_for_medium_content); |
| } |
| boolean isStatusLayout = |
| views.getLayoutId() == R.layout.people_tile_large_with_status_content; |
| int contentHeight = getContentHeightForLayout(nameHeight, isStatusLayout); |
| int lineHeight = getLineHeightFromResource(textSizeResId); |
| int maxAdaptiveLines = Math.floorDiv(contentHeight, lineHeight); |
| int maxLines = Math.max(MIN_CONTENT_MAX_LINES, maxAdaptiveLines); |
| |
| // Save a line for sender's name, if present. |
| if (showSender) maxLines--; |
| views.setInt(R.id.text_content, "setMaxLines", maxLines); |
| } |
| |
| private int getLineHeightFromResource(int resId) { |
| try { |
| TextView text = new TextView(mContext); |
| text.setTextSize(TypedValue.COMPLEX_UNIT_PX, |
| mContext.getResources().getDimension(resId)); |
| text.setTextAppearance(android.R.style.TextAppearance_DeviceDefault); |
| int lineHeight = (int) (text.getLineHeight() / mDensity); |
| return lineHeight; |
| } catch (Exception e) { |
| Log.e(TAG, "Could not create text view: " + e); |
| return getSizeInDp( |
| R.dimen.content_text_size_for_medium); |
| } |
| } |
| |
| private int getSizeInDp(int dimenResourceId) { |
| return getSizeInDp(mContext, dimenResourceId, mDensity); |
| } |
| |
| public static int getSizeInDp(Context context, int dimenResourceId, float density) { |
| return (int) (context.getResources().getDimension(dimenResourceId) / density); |
| } |
| |
| private int getContentHeightForLayout(int lineHeight, boolean hasPredefinedIcon) { |
| switch (mLayoutSize) { |
| case LAYOUT_MEDIUM: |
| return mHeight - (lineHeight + FIXED_HEIGHT_DIMENS_FOR_MEDIUM_CONTENT_BEFORE_PADDING |
| + mMediumVerticalPadding * 2); |
| case LAYOUT_LARGE: |
| int fixedHeight = hasPredefinedIcon ? FIXED_HEIGHT_DIMENS_FOR_LARGE_STATUS_CONTENT |
| : FIXED_HEIGHT_DIMENS_FOR_LARGE_NOTIF_CONTENT; |
| return mHeight - (getSizeInDp( |
| R.dimen.max_people_avatar_size_for_large_content) + lineHeight |
| + fixedHeight); |
| default: |
| return -1; |
| } |
| } |
| |
| /** Calculates the best layout relative to the size in {@code options}. */ |
| private int getLayoutSize() { |
| if (mHeight >= getSizeInDp(R.dimen.required_height_for_large) |
| && mWidth >= getSizeInDp(R.dimen.required_width_for_large)) { |
| if (DEBUG) Log.d(TAG, "Large view for mWidth: " + mWidth + " mHeight: " + mHeight); |
| return LAYOUT_LARGE; |
| } |
| // Small layout used below a certain minimum mWidth with any mHeight. |
| if (mHeight >= getSizeInDp(R.dimen.required_height_for_medium) |
| && mWidth >= getSizeInDp(R.dimen.required_width_for_medium)) { |
| int spaceAvailableForPadding = |
| mHeight - (getSizeInDp(R.dimen.avatar_size_for_medium) |
| + 4 + getLineHeightFromResource( |
| R.dimen.name_text_size_for_medium_content)); |
| if (DEBUG) { |
| Log.d(TAG, "Medium view for mWidth: " + mWidth + " mHeight: " + mHeight |
| + " with padding space: " + spaceAvailableForPadding); |
| } |
| int maxVerticalPadding = Math.min(Math.floorDiv(spaceAvailableForPadding, 2), |
| MAX_MEDIUM_PADDING); |
| mMediumVerticalPadding = Math.max(MIN_MEDIUM_VERTICAL_PADDING, maxVerticalPadding); |
| return LAYOUT_MEDIUM; |
| } |
| // Small layout can always handle our minimum mWidth and mHeight for our widget. |
| if (DEBUG) Log.d(TAG, "Small view for mWidth: " + mWidth + " mHeight: " + mHeight); |
| return LAYOUT_SMALL; |
| } |
| |
| /** Returns the max avatar size for {@code views} under the current {@code options}. */ |
| private int getMaxAvatarSize(RemoteViews views) { |
| int layoutId = views.getLayoutId(); |
| int avatarSize = getSizeInDp(R.dimen.avatar_size_for_medium); |
| if (layoutId == R.layout.people_tile_medium_empty) { |
| return getSizeInDp( |
| R.dimen.max_people_avatar_size_for_large_content); |
| } |
| if (layoutId == R.layout.people_tile_medium_with_content) { |
| return getSizeInDp(R.dimen.avatar_size_for_medium); |
| } |
| |
| // Calculate adaptive avatar size for remaining layouts. |
| if (layoutId == R.layout.people_tile_small) { |
| int avatarHeightSpace = mHeight - (FIXED_HEIGHT_DIMENS_FOR_SMALL_VERTICAL + Math.max(18, |
| getLineHeightFromResource( |
| R.dimen.name_text_size_for_small))); |
| int avatarWidthSpace = mWidth - FIXED_WIDTH_DIMENS_FOR_SMALL_VERTICAL; |
| avatarSize = Math.min(avatarHeightSpace, avatarWidthSpace); |
| } |
| if (layoutId == R.layout.people_tile_small_horizontal) { |
| int avatarHeightSpace = mHeight - FIXED_HEIGHT_DIMENS_FOR_SMALL_HORIZONTAL; |
| int avatarWidthSpace = mWidth - FIXED_WIDTH_DIMENS_FOR_SMALL_HORIZONTAL; |
| avatarSize = Math.min(avatarHeightSpace, avatarWidthSpace); |
| } |
| |
| if (layoutId == R.layout.people_tile_large_with_notification_content) { |
| avatarSize = mHeight - (FIXED_HEIGHT_DIMENS_FOR_LARGE_NOTIF_CONTENT + ( |
| getLineHeightFromResource( |
| R.dimen.content_text_size_for_large) |
| * 3)); |
| return Math.min(avatarSize, getSizeInDp( |
| R.dimen.max_people_avatar_size_for_large_content)); |
| } else if (layoutId == R.layout.people_tile_large_with_status_content) { |
| avatarSize = mHeight - (FIXED_HEIGHT_DIMENS_FOR_LARGE_STATUS_CONTENT + ( |
| getLineHeightFromResource(R.dimen.content_text_size_for_large) |
| * 3)); |
| return Math.min(avatarSize, getSizeInDp( |
| R.dimen.max_people_avatar_size_for_large_content)); |
| } |
| |
| if (layoutId == R.layout.people_tile_large_empty) { |
| int avatarHeightSpace = mHeight - (14 + 14 + getLineHeightFromResource( |
| R.dimen.name_text_size_for_large) |
| + getLineHeightFromResource(R.dimen.content_text_size_for_large) |
| + 16 + 10 + 16); |
| int avatarWidthSpace = mWidth - (14 + 14); |
| avatarSize = Math.min(avatarHeightSpace, avatarWidthSpace); |
| } |
| |
| if (isDndBlockingTileData(mTile) && mLayoutSize != LAYOUT_SMALL) { |
| avatarSize = createDndRemoteViews().mAvatarSize; |
| } |
| |
| return Math.min(avatarSize, |
| getSizeInDp(R.dimen.max_people_avatar_size)); |
| } |
| |
| private RemoteViews setCommonRemoteViewsFields(RemoteViews views, |
| int maxAvatarSize) { |
| try { |
| if (mTile == null) { |
| return views; |
| } |
| boolean isAvailable = |
| mTile.getStatuses() != null && mTile.getStatuses().stream().anyMatch( |
| c -> c.getAvailability() == AVAILABILITY_AVAILABLE); |
| |
| int startPadding; |
| if (isAvailable) { |
| views.setViewVisibility(R.id.availability, View.VISIBLE); |
| startPadding = mContext.getResources().getDimensionPixelSize( |
| R.dimen.availability_dot_shown_padding); |
| views.setContentDescription(R.id.availability, |
| mContext.getString(R.string.person_available)); |
| } else { |
| views.setViewVisibility(R.id.availability, View.GONE); |
| startPadding = mContext.getResources().getDimensionPixelSize( |
| R.dimen.availability_dot_missing_padding); |
| } |
| boolean isLeftToRight = TextUtils.getLayoutDirectionFromLocale(Locale.getDefault()) |
| == View.LAYOUT_DIRECTION_LTR; |
| views.setViewPadding(R.id.padding_before_availability, |
| isLeftToRight ? startPadding : 0, 0, isLeftToRight ? 0 : startPadding, |
| 0); |
| |
| boolean hasNewStory = getHasNewStory(mTile); |
| views.setImageViewBitmap(R.id.person_icon, |
| getPersonIconBitmap(mContext, mTile, maxAvatarSize, hasNewStory)); |
| if (hasNewStory) { |
| views.setContentDescription(R.id.person_icon, |
| mContext.getString(R.string.new_story_status_content_description, |
| mTile.getUserName())); |
| } else { |
| views.setContentDescription(R.id.person_icon, null); |
| } |
| return views; |
| } catch (Exception e) { |
| Log.e(TAG, "Failed to set common fields: " + e); |
| } |
| return views; |
| } |
| |
| private static boolean getHasNewStory(PeopleSpaceTile tile) { |
| return tile.getStatuses() != null && tile.getStatuses().stream().anyMatch( |
| c -> c.getActivity() == ACTIVITY_NEW_STORY); |
| } |
| |
| private RemoteViews setLaunchIntents(RemoteViews views) { |
| if (!PeopleTileKey.isValid(mKey) || mTile == null) { |
| if (DEBUG) Log.d(TAG, "Skipping launch intent, Null tile or invalid key: " + mKey); |
| return views; |
| } |
| |
| try { |
| Intent activityIntent = new Intent(mContext, LaunchConversationActivity.class); |
| activityIntent.addFlags( |
| Intent.FLAG_ACTIVITY_NEW_TASK |
| | Intent.FLAG_ACTIVITY_CLEAR_TASK |
| | Intent.FLAG_ACTIVITY_NO_HISTORY |
| | Intent.FLAG_ACTIVITY_EXCLUDE_FROM_RECENTS); |
| activityIntent.putExtra(PeopleSpaceWidgetProvider.EXTRA_TILE_ID, mKey.getShortcutId()); |
| activityIntent.putExtra( |
| PeopleSpaceWidgetProvider.EXTRA_PACKAGE_NAME, mKey.getPackageName()); |
| activityIntent.putExtra(PeopleSpaceWidgetProvider.EXTRA_USER_HANDLE, |
| new UserHandle(mKey.getUserId())); |
| if (mTile != null) { |
| activityIntent.putExtra( |
| PeopleSpaceWidgetProvider.EXTRA_NOTIFICATION_KEY, |
| mTile.getNotificationKey()); |
| } |
| views.setOnClickPendingIntent(android.R.id.background, PendingIntent.getActivity( |
| mContext, |
| mAppWidgetId, |
| activityIntent, |
| PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE)); |
| return views; |
| } catch (Exception e) { |
| Log.e(TAG, "Failed to add launch intents: " + e); |
| } |
| |
| return views; |
| } |
| |
| private RemoteViewsAndSizes createDndRemoteViews() { |
| RemoteViews views = new RemoteViews(mContext.getPackageName(), getViewForDndRemoteViews()); |
| |
| int mediumAvatarSize = getSizeInDp(R.dimen.avatar_size_for_medium_empty); |
| int maxAvatarSize = getSizeInDp(R.dimen.max_people_avatar_size); |
| |
| String text = mContext.getString(R.string.paused_by_dnd); |
| views.setTextViewText(R.id.text_content, text); |
| |
| int textSizeResId = |
| mLayoutSize == LAYOUT_LARGE |
| ? R.dimen.content_text_size_for_large |
| : R.dimen.content_text_size_for_medium; |
| float textSizePx = mContext.getResources().getDimension(textSizeResId); |
| views.setTextViewTextSize(R.id.text_content, COMPLEX_UNIT_PX, textSizePx); |
| int lineHeight = getLineHeightFromResource(textSizeResId); |
| |
| int avatarSize; |
| if (mLayoutSize == LAYOUT_MEDIUM) { |
| int maxTextHeight = mHeight - 16; |
| views.setInt(R.id.text_content, "setMaxLines", maxTextHeight / lineHeight); |
| avatarSize = mediumAvatarSize; |
| } else { |
| int outerPadding = 16; |
| int outerPaddingTop = outerPadding - 2; |
| int outerPaddingPx = dpToPx(outerPadding); |
| int outerPaddingTopPx = dpToPx(outerPaddingTop); |
| int iconSize = |
| getSizeInDp( |
| mLayoutSize == LAYOUT_SMALL |
| ? R.dimen.regular_predefined_icon |
| : R.dimen.largest_predefined_icon); |
| int heightWithoutIcon = mHeight - 2 * outerPadding - iconSize; |
| int paddingBetweenElements = |
| getSizeInDp(R.dimen.padding_between_suppressed_layout_items); |
| int maxTextWidth = mWidth - outerPadding * 2; |
| int maxTextHeight = heightWithoutIcon - mediumAvatarSize - paddingBetweenElements * 2; |
| |
| int availableAvatarHeight; |
| int textHeight = estimateTextHeight(text, textSizeResId, maxTextWidth); |
| if (textHeight <= maxTextHeight && mLayoutSize == LAYOUT_LARGE) { |
| // If the text will fit, then display it and deduct its height from the space we |
| // have for the avatar. |
| availableAvatarHeight = heightWithoutIcon - textHeight - paddingBetweenElements * 2; |
| views.setViewVisibility(R.id.text_content, View.VISIBLE); |
| views.setInt(R.id.text_content, "setMaxLines", maxTextHeight / lineHeight); |
| views.setContentDescription(R.id.predefined_icon, null); |
| int availableAvatarWidth = mWidth - outerPadding * 2; |
| avatarSize = |
| MathUtils.clamp( |
| /* value= */ Math.min(availableAvatarWidth, availableAvatarHeight), |
| /* min= */ dpToPx(10), |
| /* max= */ maxAvatarSize); |
| views.setViewPadding( |
| android.R.id.background, |
| outerPaddingPx, |
| outerPaddingTopPx, |
| outerPaddingPx, |
| outerPaddingPx); |
| views.setViewLayoutWidth(R.id.predefined_icon, iconSize, COMPLEX_UNIT_DIP); |
| views.setViewLayoutHeight(R.id.predefined_icon, iconSize, COMPLEX_UNIT_DIP); |
| } else { |
| // If expected to use LAYOUT_LARGE, but we found we do not have space for the |
| // text as calculated above, re-assign the view to the small layout. |
| if (mLayoutSize != LAYOUT_SMALL) { |
| views = new RemoteViews(mContext.getPackageName(), R.layout.people_tile_small); |
| } |
| avatarSize = getMaxAvatarSize(views); |
| views.setViewVisibility(R.id.messages_count, View.GONE); |
| views.setViewVisibility(R.id.name, View.GONE); |
| // If we don't show the dnd text, set it as the content description on the icon |
| // for a11y. |
| views.setContentDescription(R.id.predefined_icon, text); |
| } |
| views.setViewVisibility(R.id.predefined_icon, View.VISIBLE); |
| views.setImageViewResource(R.id.predefined_icon, R.drawable.ic_qs_dnd_on); |
| } |
| |
| return new RemoteViewsAndSizes(views, avatarSize); |
| } |
| |
| private RemoteViews createMissedCallRemoteViews() { |
| RemoteViews views = setViewForContentLayout(new RemoteViews(mContext.getPackageName(), |
| getLayoutForContent())); |
| setPredefinedIconVisible(views); |
| views.setViewVisibility(R.id.text_content, View.VISIBLE); |
| views.setViewVisibility(R.id.messages_count, View.GONE); |
| setMaxLines(views, false); |
| CharSequence content = mTile.getNotificationContent(); |
| views.setTextViewText(R.id.text_content, content); |
| setContentDescriptionForNotificationTextContent(views, content, mTile.getUserName()); |
| views.setColorAttr(R.id.text_content, "setTextColor", android.R.attr.colorError); |
| views.setColorAttr(R.id.predefined_icon, "setColorFilter", android.R.attr.colorError); |
| views.setImageViewResource(R.id.predefined_icon, R.drawable.ic_phone_missed); |
| if (mLayoutSize == LAYOUT_LARGE) { |
| views.setInt(R.id.content, "setGravity", Gravity.BOTTOM); |
| views.setViewLayoutHeightDimen(R.id.predefined_icon, R.dimen.larger_predefined_icon); |
| views.setViewLayoutWidthDimen(R.id.predefined_icon, R.dimen.larger_predefined_icon); |
| } |
| setAvailabilityDotPadding(views, R.dimen.availability_dot_notification_padding); |
| return views; |
| } |
| |
| private void setPredefinedIconVisible(RemoteViews views) { |
| views.setViewVisibility(R.id.predefined_icon, View.VISIBLE); |
| if (mLayoutSize == LAYOUT_MEDIUM) { |
| int endPadding = mContext.getResources().getDimensionPixelSize( |
| R.dimen.before_predefined_icon_padding); |
| views.setViewPadding(R.id.name, mIsLeftToRight ? 0 : endPadding, 0, |
| mIsLeftToRight ? endPadding : 0, |
| 0); |
| } |
| } |
| |
| private RemoteViews createNotificationRemoteViews() { |
| RemoteViews views = setViewForContentLayout(new RemoteViews(mContext.getPackageName(), |
| getLayoutForNotificationContent())); |
| CharSequence sender = mTile.getNotificationSender(); |
| Uri imageUri = mTile.getNotificationDataUri(); |
| if (imageUri != null) { |
| String newImageDescription = mContext.getString( |
| R.string.new_notification_image_content_description, mTile.getUserName()); |
| views.setContentDescription(R.id.image, newImageDescription); |
| views.setViewVisibility(R.id.image, View.VISIBLE); |
| views.setViewVisibility(R.id.text_content, View.GONE); |
| try { |
| Drawable drawable = resolveImage(imageUri, mContext); |
| Bitmap bitmap = convertDrawableToBitmap(drawable); |
| views.setImageViewBitmap(R.id.image, bitmap); |
| } catch (IOException e) { |
| Log.e(TAG, "Could not decode image: " + e); |
| // If we couldn't load the image, show text that we have a new image. |
| views.setTextViewText(R.id.text_content, newImageDescription); |
| views.setViewVisibility(R.id.text_content, View.VISIBLE); |
| views.setViewVisibility(R.id.image, View.GONE); |
| } |
| } else { |
| setMaxLines(views, !TextUtils.isEmpty(sender)); |
| CharSequence content = mTile.getNotificationContent(); |
| setContentDescriptionForNotificationTextContent(views, content, |
| sender != null ? sender : mTile.getUserName()); |
| views = decorateBackground(views, content); |
| views.setColorAttr(R.id.text_content, "setTextColor", android.R.attr.textColorPrimary); |
| views.setTextViewText(R.id.text_content, mTile.getNotificationContent()); |
| if (mLayoutSize == LAYOUT_LARGE) { |
| views.setViewPadding(R.id.name, 0, 0, 0, |
| mContext.getResources().getDimensionPixelSize( |
| R.dimen.above_notification_text_padding)); |
| } |
| views.setViewVisibility(R.id.image, View.GONE); |
| views.setImageViewResource(R.id.predefined_icon, R.drawable.ic_message); |
| } |
| if (mTile.getMessagesCount() > 1) { |
| if (mLayoutSize == LAYOUT_MEDIUM) { |
| int endPadding = mContext.getResources().getDimensionPixelSize( |
| R.dimen.before_messages_count_padding); |
| views.setViewPadding(R.id.name, mIsLeftToRight ? 0 : endPadding, 0, |
| mIsLeftToRight ? endPadding : 0, |
| 0); |
| } |
| views.setViewVisibility(R.id.messages_count, View.VISIBLE); |
| views.setTextViewText(R.id.messages_count, |
| getMessagesCountText(mTile.getMessagesCount())); |
| if (mLayoutSize == LAYOUT_SMALL) { |
| views.setViewVisibility(R.id.predefined_icon, View.GONE); |
| } |
| } |
| if (!TextUtils.isEmpty(sender)) { |
| views.setViewVisibility(R.id.subtext, View.VISIBLE); |
| views.setTextViewText(R.id.subtext, sender); |
| } else { |
| views.setViewVisibility(R.id.subtext, View.GONE); |
| } |
| setAvailabilityDotPadding(views, R.dimen.availability_dot_notification_padding); |
| return views; |
| } |
| |
| private Drawable resolveImage(Uri uri, Context context) throws IOException { |
| final ImageDecoder.Source source = |
| ImageDecoder.createSource(context.getContentResolver(), uri); |
| final Drawable drawable = |
| ImageDecoder.decodeDrawable(source, (decoder, info, s) -> { |
| onHeaderDecoded(decoder, info, s); |
| }); |
| return drawable; |
| } |
| |
| private static int getPowerOfTwoForSampleRatio(double ratio) { |
| final int k = Integer.highestOneBit((int) Math.floor(ratio)); |
| return Math.max(1, k); |
| } |
| |
| private void onHeaderDecoded(ImageDecoder decoder, ImageDecoder.ImageInfo info, |
| ImageDecoder.Source source) { |
| int widthInPx = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, mWidth, |
| mContext.getResources().getDisplayMetrics()); |
| int heightInPx = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, mHeight, |
| mContext.getResources().getDisplayMetrics()); |
| int maxIconSizeInPx = Math.max(widthInPx, heightInPx); |
| int minDimen = (int) (1.5 * Math.min(widthInPx, heightInPx)); |
| if (minDimen < maxIconSizeInPx) { |
| maxIconSizeInPx = minDimen; |
| } |
| final Size size = info.getSize(); |
| final int originalSize = Math.max(size.getHeight(), size.getWidth()); |
| final double ratio = (originalSize > maxIconSizeInPx) |
| ? originalSize * 1f / maxIconSizeInPx |
| : 1.0; |
| decoder.setTargetSampleSize(getPowerOfTwoForSampleRatio(ratio)); |
| } |
| |
| private void setContentDescriptionForNotificationTextContent(RemoteViews views, |
| CharSequence content, CharSequence sender) { |
| String newTextDescriptionWithNotificationContent = mContext.getString( |
| R.string.new_notification_text_content_description, sender, content); |
| int idForContentDescription = |
| mLayoutSize == LAYOUT_SMALL ? R.id.predefined_icon : R.id.text_content; |
| views.setContentDescription(idForContentDescription, |
| newTextDescriptionWithNotificationContent); |
| } |
| |
| // Some messaging apps only include up to 6 messages in their notifications. |
| private String getMessagesCountText(int count) { |
| if (count >= MESSAGES_COUNT_OVERFLOW) { |
| return mContext.getResources().getString( |
| R.string.messages_count_overflow_indicator, MESSAGES_COUNT_OVERFLOW); |
| } |
| |
| // Cache the locale-appropriate NumberFormat. Configuration locale is guaranteed |
| // non-null, so the first time this is called we will always get the appropriate |
| // NumberFormat, then never regenerate it unless the locale changes on the fly. |
| final Locale curLocale = mContext.getResources().getConfiguration().getLocales().get(0); |
| if (!curLocale.equals(mLocale)) { |
| mLocale = curLocale; |
| mIntegerFormat = NumberFormat.getIntegerInstance(curLocale); |
| } |
| return mIntegerFormat.format(count); |
| } |
| |
| private RemoteViews createStatusRemoteViews(ConversationStatus status) { |
| RemoteViews views = setViewForContentLayout(new RemoteViews(mContext.getPackageName(), |
| getLayoutForContent())); |
| CharSequence statusText = status.getDescription(); |
| if (TextUtils.isEmpty(statusText)) { |
| statusText = getStatusTextByType(status.getActivity()); |
| } |
| setPredefinedIconVisible(views); |
| views.setTextViewText(R.id.text_content, statusText); |
| |
| if (status.getActivity() == ACTIVITY_BIRTHDAY |
| || status.getActivity() == ACTIVITY_UPCOMING_BIRTHDAY) { |
| setEmojiBackground(views, EMOJI_CAKE); |
| } |
| |
| Icon statusIcon = status.getIcon(); |
| if (statusIcon != null) { |
| // No text content styled text on medium or large. |
| views.setViewVisibility(R.id.scrim_layout, View.VISIBLE); |
| views.setImageViewIcon(R.id.status_icon, statusIcon); |
| // Show 1-line subtext on large layout with status images. |
| if (mLayoutSize == LAYOUT_LARGE) { |
| if (DEBUG) Log.d(TAG, "Remove name for large"); |
| views.setInt(R.id.content, "setGravity", Gravity.BOTTOM); |
| views.setViewVisibility(R.id.name, View.GONE); |
| views.setColorAttr(R.id.text_content, "setTextColor", |
| android.R.attr.textColorPrimary); |
| } else if (mLayoutSize == LAYOUT_MEDIUM) { |
| views.setViewVisibility(R.id.text_content, View.GONE); |
| views.setTextViewText(R.id.name, statusText); |
| } |
| } else { |
| // Secondary text color for statuses without icons. |
| views.setColorAttr(R.id.text_content, "setTextColor", |
| android.R.attr.textColorSecondary); |
| setMaxLines(views, false); |
| } |
| setAvailabilityDotPadding(views, R.dimen.availability_dot_status_padding); |
| views.setImageViewResource(R.id.predefined_icon, getDrawableForStatus(status)); |
| CharSequence descriptionForStatus = |
| getContentDescriptionForStatus(status); |
| CharSequence customContentDescriptionForStatus = mContext.getString( |
| R.string.new_status_content_description, mTile.getUserName(), descriptionForStatus); |
| switch (mLayoutSize) { |
| case LAYOUT_LARGE: |
| views.setContentDescription(R.id.text_content, |
| customContentDescriptionForStatus); |
| break; |
| case LAYOUT_MEDIUM: |
| views.setContentDescription(statusIcon == null ? R.id.text_content : R.id.name, |
| customContentDescriptionForStatus); |
| break; |
| case LAYOUT_SMALL: |
| views.setContentDescription(R.id.predefined_icon, |
| customContentDescriptionForStatus); |
| break; |
| } |
| return views; |
| } |
| |
| private CharSequence getContentDescriptionForStatus(ConversationStatus status) { |
| CharSequence name = mTile.getUserName(); |
| if (!TextUtils.isEmpty(status.getDescription())) { |
| return status.getDescription(); |
| } |
| switch (status.getActivity()) { |
| case ACTIVITY_NEW_STORY: |
| return mContext.getString(R.string.new_story_status_content_description, |
| name); |
| case ACTIVITY_ANNIVERSARY: |
| return mContext.getString(R.string.anniversary_status_content_description, name); |
| case ACTIVITY_UPCOMING_BIRTHDAY: |
| return mContext.getString(R.string.upcoming_birthday_status_content_description, |
| name); |
| case ACTIVITY_BIRTHDAY: |
| return mContext.getString(R.string.birthday_status_content_description, name); |
| case ACTIVITY_LOCATION: |
| return mContext.getString(R.string.location_status_content_description, name); |
| case ACTIVITY_GAME: |
| return mContext.getString(R.string.game_status); |
| case ACTIVITY_VIDEO: |
| return mContext.getString(R.string.video_status); |
| case ACTIVITY_AUDIO: |
| return mContext.getString(R.string.audio_status); |
| default: |
| return EMPTY_STRING; |
| } |
| } |
| |
| private int getDrawableForStatus(ConversationStatus status) { |
| switch (status.getActivity()) { |
| case ACTIVITY_NEW_STORY: |
| return R.drawable.ic_pages; |
| case ACTIVITY_ANNIVERSARY: |
| return R.drawable.ic_celebration; |
| case ACTIVITY_UPCOMING_BIRTHDAY: |
| return R.drawable.ic_gift; |
| case ACTIVITY_BIRTHDAY: |
| return R.drawable.ic_cake; |
| case ACTIVITY_LOCATION: |
| return R.drawable.ic_location; |
| case ACTIVITY_GAME: |
| return R.drawable.ic_play_games; |
| case ACTIVITY_VIDEO: |
| return R.drawable.ic_video; |
| case ACTIVITY_AUDIO: |
| return R.drawable.ic_music_note; |
| default: |
| return R.drawable.ic_person; |
| } |
| } |
| |
| /** |
| * Update the padding of the availability dot. The padding on the availability dot decreases |
| * on the status layouts compared to all other layouts. |
| */ |
| private void setAvailabilityDotPadding(RemoteViews views, int resId) { |
| int startPadding = mContext.getResources().getDimensionPixelSize(resId); |
| int bottomPadding = mContext.getResources().getDimensionPixelSize( |
| R.dimen.medium_content_padding_above_name); |
| views.setViewPadding(R.id.medium_content, |
| mIsLeftToRight ? startPadding : 0, 0, mIsLeftToRight ? 0 : startPadding, |
| bottomPadding); |
| } |
| |
| @Nullable |
| private ConversationStatus getBirthdayStatus( |
| List<ConversationStatus> statuses) { |
| Optional<ConversationStatus> birthdayStatus = statuses.stream().filter( |
| c -> c.getActivity() == ACTIVITY_BIRTHDAY).findFirst(); |
| if (birthdayStatus.isPresent()) { |
| return birthdayStatus.get(); |
| } |
| if (!TextUtils.isEmpty(mTile.getBirthdayText())) { |
| return new ConversationStatus.Builder(mTile.getId(), ACTIVITY_BIRTHDAY).build(); |
| } |
| |
| return null; |
| } |
| |
| /** |
| * Returns whether a {@code status} should have its own entire templated view. |
| * |
| * <p>A status may still be shown on the view (for example, as a new story ring) even if it's |
| * not valid to compose an entire view. |
| */ |
| private boolean isStatusValidForEntireStatusView(ConversationStatus status) { |
| switch (status.getActivity()) { |
| // Birthday & Anniversary don't require text provided or icon provided. |
| case ACTIVITY_BIRTHDAY: |
| case ACTIVITY_ANNIVERSARY: |
| return true; |
| default: |
| // For future birthday, location, new story, video, music, game, and other, the |
| // app must provide either text or an icon. |
| return !TextUtils.isEmpty(status.getDescription()) |
| || status.getIcon() != null; |
| } |
| } |
| |
| private String getStatusTextByType(int activity) { |
| switch (activity) { |
| case ACTIVITY_BIRTHDAY: |
| return mContext.getString(R.string.birthday_status); |
| case ACTIVITY_UPCOMING_BIRTHDAY: |
| return mContext.getString(R.string.upcoming_birthday_status); |
| case ACTIVITY_ANNIVERSARY: |
| return mContext.getString(R.string.anniversary_status); |
| case ACTIVITY_LOCATION: |
| return mContext.getString(R.string.location_status); |
| case ACTIVITY_NEW_STORY: |
| return mContext.getString(R.string.new_story_status); |
| case ACTIVITY_VIDEO: |
| return mContext.getString(R.string.video_status); |
| case ACTIVITY_AUDIO: |
| return mContext.getString(R.string.audio_status); |
| case ACTIVITY_GAME: |
| return mContext.getString(R.string.game_status); |
| default: |
| return EMPTY_STRING; |
| } |
| } |
| |
| private RemoteViews decorateBackground(RemoteViews views, CharSequence content) { |
| CharSequence emoji = getDoubleEmoji(content); |
| if (!TextUtils.isEmpty(emoji)) { |
| setEmojiBackground(views, emoji); |
| setPunctuationBackground(views, null); |
| return views; |
| } |
| |
| CharSequence punctuation = getDoublePunctuation(content); |
| setEmojiBackground(views, null); |
| setPunctuationBackground(views, punctuation); |
| return views; |
| } |
| |
| private RemoteViews setEmojiBackground(RemoteViews views, CharSequence content) { |
| if (TextUtils.isEmpty(content)) { |
| views.setViewVisibility(R.id.emojis, View.GONE); |
| return views; |
| } |
| views.setTextViewText(R.id.emoji1, content); |
| views.setTextViewText(R.id.emoji2, content); |
| views.setTextViewText(R.id.emoji3, content); |
| |
| views.setViewVisibility(R.id.emojis, View.VISIBLE); |
| return views; |
| } |
| |
| private RemoteViews setPunctuationBackground(RemoteViews views, CharSequence content) { |
| if (TextUtils.isEmpty(content)) { |
| views.setViewVisibility(R.id.punctuations, View.GONE); |
| return views; |
| } |
| views.setTextViewText(R.id.punctuation1, content); |
| views.setTextViewText(R.id.punctuation2, content); |
| views.setTextViewText(R.id.punctuation3, content); |
| views.setTextViewText(R.id.punctuation4, content); |
| views.setTextViewText(R.id.punctuation5, content); |
| views.setTextViewText(R.id.punctuation6, content); |
| |
| views.setViewVisibility(R.id.punctuations, View.VISIBLE); |
| return views; |
| } |
| |
| /** Returns punctuation character(s) if {@code message} has double punctuation ("!" or "?"). */ |
| @VisibleForTesting |
| CharSequence getDoublePunctuation(CharSequence message) { |
| if (!ANY_DOUBLE_MARK_PATTERN.matcher(message).find()) { |
| return null; |
| } |
| if (MIXED_MARK_PATTERN.matcher(message).find()) { |
| return "!?"; |
| } |
| Matcher doubleQuestionMatcher = DOUBLE_QUESTION_PATTERN.matcher(message); |
| if (!doubleQuestionMatcher.find()) { |
| return "!"; |
| } |
| Matcher doubleExclamationMatcher = DOUBLE_EXCLAMATION_PATTERN.matcher(message); |
| if (!doubleExclamationMatcher.find()) { |
| return "?"; |
| } |
| // If we have both "!!" and "??", return the one that comes first. |
| if (doubleQuestionMatcher.start() < doubleExclamationMatcher.start()) { |
| return "?"; |
| } |
| return "!"; |
| } |
| |
| /** Returns emoji if {@code message} has two of the same emoji in sequence. */ |
| @VisibleForTesting |
| CharSequence getDoubleEmoji(CharSequence message) { |
| Matcher unicodeEmojiMatcher = EMOJI_PATTERN.matcher(message); |
| // Stores the start and end indices of each matched emoji. |
| List<Pair<Integer, Integer>> emojiIndices = new ArrayList<>(); |
| // Stores each emoji text. |
| List<CharSequence> emojiTexts = new ArrayList<>(); |
| |
| // Scan message for emojis |
| while (unicodeEmojiMatcher.find()) { |
| int start = unicodeEmojiMatcher.start(); |
| int end = unicodeEmojiMatcher.end(); |
| emojiIndices.add(new Pair(start, end)); |
| emojiTexts.add(message.subSequence(start, end)); |
| } |
| |
| if (DEBUG) Log.d(TAG, "Number of emojis in the message: " + emojiIndices.size()); |
| if (emojiIndices.size() < 2) { |
| return null; |
| } |
| |
| for (int i = 1; i < emojiIndices.size(); ++i) { |
| Pair<Integer, Integer> second = emojiIndices.get(i); |
| Pair<Integer, Integer> first = emojiIndices.get(i - 1); |
| |
| // Check if second emoji starts right after first starts |
| if (second.first == first.second) { |
| // Check if emojis in sequence are the same |
| if (Objects.equals(emojiTexts.get(i), emojiTexts.get(i - 1))) { |
| if (DEBUG) { |
| Log.d(TAG, "Two of the same emojis in sequence: " + emojiTexts.get(i)); |
| } |
| return emojiTexts.get(i); |
| } |
| } |
| } |
| |
| // No equal emojis in sequence. |
| return null; |
| } |
| |
| private RemoteViews setViewForContentLayout(RemoteViews views) { |
| views = decorateBackground(views, ""); |
| views.setContentDescription(R.id.predefined_icon, null); |
| views.setContentDescription(R.id.text_content, null); |
| views.setContentDescription(R.id.name, null); |
| views.setContentDescription(R.id.image, null); |
| views.setAccessibilityTraversalAfter(R.id.text_content, R.id.name); |
| if (mLayoutSize == LAYOUT_SMALL) { |
| views.setViewVisibility(R.id.predefined_icon, View.VISIBLE); |
| views.setViewVisibility(R.id.name, View.GONE); |
| } else { |
| views.setViewVisibility(R.id.predefined_icon, View.GONE); |
| views.setViewVisibility(R.id.name, View.VISIBLE); |
| views.setViewVisibility(R.id.text_content, View.VISIBLE); |
| views.setViewVisibility(R.id.subtext, View.GONE); |
| views.setViewVisibility(R.id.image, View.GONE); |
| views.setViewVisibility(R.id.scrim_layout, View.GONE); |
| } |
| |
| if (mLayoutSize == LAYOUT_MEDIUM) { |
| // Maximize vertical padding with an avatar size of 48dp and name on medium. |
| if (DEBUG) Log.d(TAG, "Set vertical padding: " + mMediumVerticalPadding); |
| int horizontalPadding = (int) Math.floor(MAX_MEDIUM_PADDING * mDensity); |
| int verticalPadding = (int) Math.floor(mMediumVerticalPadding * mDensity); |
| views.setViewPadding(R.id.content, horizontalPadding, verticalPadding, |
| horizontalPadding, |
| verticalPadding); |
| views.setViewPadding(R.id.name, 0, 0, 0, 0); |
| // Expand the name font on medium if there's space. |
| int heightRequiredForMaxContentText = (int) (mContext.getResources().getDimension( |
| R.dimen.medium_height_for_max_name_text_size) / mDensity); |
| if (mHeight > heightRequiredForMaxContentText) { |
| views.setTextViewTextSize(R.id.name, TypedValue.COMPLEX_UNIT_PX, |
| (int) mContext.getResources().getDimension( |
| R.dimen.max_name_text_size_for_medium)); |
| } |
| } |
| |
| if (mLayoutSize == LAYOUT_LARGE) { |
| // Decrease the view padding below the name on all layouts besides notification "text". |
| views.setViewPadding(R.id.name, 0, 0, 0, |
| mContext.getResources().getDimensionPixelSize( |
| R.dimen.below_name_text_padding)); |
| // All large layouts besides missed calls & statuses with images, have gravity top. |
| views.setInt(R.id.content, "setGravity", Gravity.TOP); |
| } |
| |
| // For all layouts except Missed Calls, ensure predefined icon is regular sized. |
| views.setViewLayoutHeightDimen(R.id.predefined_icon, R.dimen.regular_predefined_icon); |
| views.setViewLayoutWidthDimen(R.id.predefined_icon, R.dimen.regular_predefined_icon); |
| |
| views.setViewVisibility(R.id.messages_count, View.GONE); |
| if (mTile.getUserName() != null) { |
| views.setTextViewText(R.id.name, mTile.getUserName()); |
| } |
| |
| return views; |
| } |
| |
| private RemoteViews createLastInteractionRemoteViews() { |
| RemoteViews views = new RemoteViews(mContext.getPackageName(), getEmptyLayout()); |
| views.setInt(R.id.name, "setMaxLines", NAME_MAX_LINES_WITH_LAST_INTERACTION); |
| if (mLayoutSize == LAYOUT_SMALL) { |
| views.setViewVisibility(R.id.name, View.VISIBLE); |
| views.setViewVisibility(R.id.predefined_icon, View.GONE); |
| views.setViewVisibility(R.id.messages_count, View.GONE); |
| } |
| if (mTile.getUserName() != null) { |
| views.setTextViewText(R.id.name, mTile.getUserName()); |
| } |
| String status = getLastInteractionString(mContext, |
| mTile.getLastInteractionTimestamp()); |
| if (status != null) { |
| if (DEBUG) Log.d(TAG, "Show last interaction"); |
| views.setViewVisibility(R.id.last_interaction, View.VISIBLE); |
| views.setTextViewText(R.id.last_interaction, status); |
| } else { |
| if (DEBUG) Log.d(TAG, "Hide last interaction"); |
| views.setViewVisibility(R.id.last_interaction, View.GONE); |
| if (mLayoutSize == LAYOUT_MEDIUM) { |
| views.setInt(R.id.name, "setMaxLines", NAME_MAX_LINES_WITHOUT_LAST_INTERACTION); |
| } |
| } |
| return views; |
| } |
| |
| private int getEmptyLayout() { |
| switch (mLayoutSize) { |
| case LAYOUT_MEDIUM: |
| return R.layout.people_tile_medium_empty; |
| case LAYOUT_LARGE: |
| return R.layout.people_tile_large_empty; |
| case LAYOUT_SMALL: |
| default: |
| return getLayoutSmallByHeight(); |
| } |
| } |
| |
| private int getLayoutForNotificationContent() { |
| switch (mLayoutSize) { |
| case LAYOUT_MEDIUM: |
| return R.layout.people_tile_medium_with_content; |
| case LAYOUT_LARGE: |
| return R.layout.people_tile_large_with_notification_content; |
| case LAYOUT_SMALL: |
| default: |
| return getLayoutSmallByHeight(); |
| } |
| } |
| |
| private int getLayoutForContent() { |
| switch (mLayoutSize) { |
| case LAYOUT_MEDIUM: |
| return R.layout.people_tile_medium_with_content; |
| case LAYOUT_LARGE: |
| return R.layout.people_tile_large_with_status_content; |
| case LAYOUT_SMALL: |
| default: |
| return getLayoutSmallByHeight(); |
| } |
| } |
| |
| private int getViewForDndRemoteViews() { |
| switch (mLayoutSize) { |
| case LAYOUT_MEDIUM: |
| return R.layout.people_tile_with_suppression_detail_content_horizontal; |
| case LAYOUT_LARGE: |
| return R.layout.people_tile_with_suppression_detail_content_vertical; |
| case LAYOUT_SMALL: |
| default: |
| return getLayoutSmallByHeight(); |
| } |
| } |
| |
| private int getLayoutSmallByHeight() { |
| if (mHeight >= getSizeInDp(R.dimen.required_height_for_medium)) { |
| return R.layout.people_tile_small; |
| } |
| return R.layout.people_tile_small_horizontal; |
| } |
| |
| /** Returns a bitmap with the user icon and package icon. */ |
| public static Bitmap getPersonIconBitmap(Context context, PeopleSpaceTile tile, |
| int maxAvatarSize) { |
| boolean hasNewStory = getHasNewStory(tile); |
| return getPersonIconBitmap(context, tile, maxAvatarSize, hasNewStory); |
| } |
| |
| /** Returns a bitmap with the user icon and package icon. */ |
| private static Bitmap getPersonIconBitmap( |
| Context context, PeopleSpaceTile tile, int maxAvatarSize, boolean hasNewStory) { |
| Icon icon = tile.getUserIcon(); |
| if (icon == null) { |
| Drawable placeholder = context.getDrawable(R.drawable.ic_avatar_with_badge); |
| return convertDrawableToDisabledBitmap(placeholder); |
| } |
| PeopleStoryIconFactory storyIcon = new PeopleStoryIconFactory(context, |
| context.getPackageManager(), |
| IconDrawableFactory.newInstance(context, false), |
| maxAvatarSize); |
| RoundedBitmapDrawable roundedDrawable = RoundedBitmapDrawableFactory.create( |
| context.getResources(), icon.getBitmap()); |
| Drawable personDrawable = storyIcon.getPeopleTileDrawable(roundedDrawable, |
| tile.getPackageName(), getUserId(tile), tile.isImportantConversation(), |
| hasNewStory); |
| |
| if (isDndBlockingTileData(tile)) { |
| // If DND is blocking the conversation, then display the icon in grayscale. |
| ColorMatrix colorMatrix = new ColorMatrix(); |
| colorMatrix.setSaturation(0); |
| personDrawable.setColorFilter(new ColorMatrixColorFilter(colorMatrix)); |
| } |
| |
| return convertDrawableToBitmap(personDrawable); |
| } |
| |
| /** Returns a readable status describing the {@code lastInteraction}. */ |
| @Nullable |
| public static String getLastInteractionString(Context context, long lastInteraction) { |
| if (lastInteraction == 0L) { |
| Log.e(TAG, "Could not get valid last interaction"); |
| return null; |
| } |
| long now = System.currentTimeMillis(); |
| Duration durationSinceLastInteraction = Duration.ofMillis(now - lastInteraction); |
| if (durationSinceLastInteraction.toDays() <= ONE_DAY) { |
| return null; |
| } else if (durationSinceLastInteraction.toDays() < DAYS_IN_A_WEEK) { |
| return context.getString(R.string.days_timestamp, |
| durationSinceLastInteraction.toDays()); |
| } else if (durationSinceLastInteraction.toDays() == DAYS_IN_A_WEEK) { |
| return context.getString(R.string.one_week_timestamp); |
| } else if (durationSinceLastInteraction.toDays() < DAYS_IN_A_WEEK * 2) { |
| return context.getString(R.string.over_one_week_timestamp); |
| } else if (durationSinceLastInteraction.toDays() == DAYS_IN_A_WEEK * 2) { |
| return context.getString(R.string.two_weeks_timestamp); |
| } else { |
| // Over 2 weeks ago |
| return context.getString(R.string.over_two_weeks_timestamp); |
| } |
| } |
| |
| /** |
| * Estimates the height (in dp) which the text will have given the text size and the available |
| * width. Returns Integer.MAX_VALUE if the estimation couldn't be obtained, as this is intended |
| * to be used an estimate of the maximum. |
| */ |
| private int estimateTextHeight( |
| CharSequence text, |
| @DimenRes int textSizeResId, |
| int availableWidthDp) { |
| StaticLayout staticLayout = buildStaticLayout(text, textSizeResId, availableWidthDp); |
| if (staticLayout == null) { |
| // Return max value (rather than e.g. -1) so the value can be used with <= bound checks. |
| return Integer.MAX_VALUE; |
| } |
| return pxToDp(staticLayout.getHeight()); |
| } |
| |
| /** |
| * Builds a StaticLayout for the text given the text size and available width. This can be used |
| * to obtain information about how TextView will lay out the text. Returns null if any error |
| * occurred creating a TextView. |
| */ |
| @Nullable |
| private StaticLayout buildStaticLayout( |
| CharSequence text, |
| @DimenRes int textSizeResId, |
| int availableWidthDp) { |
| try { |
| TextView textView = new TextView(mContext); |
| textView.setTextSize(TypedValue.COMPLEX_UNIT_PX, |
| mContext.getResources().getDimension(textSizeResId)); |
| textView.setTextAppearance(android.R.style.TextAppearance_DeviceDefault); |
| TextPaint paint = textView.getPaint(); |
| return StaticLayout.Builder.obtain( |
| text, 0, text.length(), paint, dpToPx(availableWidthDp)) |
| // Simple break strategy avoids hyphenation unless there's a single word longer |
| // than the line width. We use this break strategy so that we consider text to |
| // "fit" only if it fits in a nice way (i.e. without hyphenation in the middle |
| // of words). |
| .setBreakStrategy(LineBreaker.BREAK_STRATEGY_SIMPLE) |
| .build(); |
| } catch (Exception e) { |
| Log.e(TAG, "Could not create static layout: " + e); |
| return null; |
| } |
| } |
| |
| private int dpToPx(float dp) { |
| return (int) (dp * mDensity); |
| } |
| |
| private int pxToDp(@Px float px) { |
| return (int) (px / mDensity); |
| } |
| |
| private static final class RemoteViewsAndSizes { |
| final RemoteViews mRemoteViews; |
| final int mAvatarSize; |
| |
| RemoteViewsAndSizes(RemoteViews remoteViews, int avatarSize) { |
| mRemoteViews = remoteViews; |
| mAvatarSize = avatarSize; |
| } |
| } |
| |
| private static Bitmap convertDrawableToDisabledBitmap(Drawable icon) { |
| Bitmap appIconAsBitmap = convertDrawableToBitmap(icon); |
| FastBitmapDrawable drawable = new FastBitmapDrawable(appIconAsBitmap); |
| drawable.setIsDisabled(true); |
| return convertDrawableToBitmap(drawable); |
| } |
| } |