blob: a16b92f494a44000639e34b15e7cf6370dc47431 [file] [log] [blame]
/*
* 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);
}
}