// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package org.chromium.chrome.browser.notifications;
import android.app.Notification;
import android.content.Context;
import android.content.res.Resources;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.Drawable;
import android.os.Build;
import android.os.StrictMode;
import android.os.SystemClock;
import android.text.format.DateFormat;
import android.util.DisplayMetrics;
import android.util.TypedValue;
import android.view.View;
import android.widget.RemoteViews;
import org.chromium.base.ApiCompatibilityUtils;
import org.chromium.base.VisibleForTesting;
import org.chromium.base.metrics.RecordHistogram;
import org.chromium.chrome.R;
import org.chromium.ui.base.LocalizationUtils;
import java.util.Date;
import java.util.concurrent.TimeUnit;
/**
* Builds a notification using the given inputs. Uses RemoteViews to provide a custom layout.
*/
public class CustomNotificationBuilder extends NotificationBuilderBase {
/**
* The maximum width of action icons in dp units.
*/
private static final int MAX_ACTION_ICON_WIDTH_DP = 32;
/**
* The maximum number of lines of body text for the expanded state. Fewer lines are used when
* the text is scaled up, with a minimum of one line.
*/
private static final int MAX_BODY_LINES = 7;
/**
* The fontScale considered large for the purposes of layout.
*/
private static final float FONT_SCALE_LARGE = 1.3f;
/**
* The maximum amount of padding (in dip units) that is applied around views that must have a
* flexible amount of padding. If the font size is scaled up the applied padding will be scaled
* down towards 0.
*/
private static final int MAX_SCALABLE_PADDING_DP = 3;
/**
* The amount of padding at the start of the button, either before an icon or before the text.
*/
private static final int BUTTON_PADDING_START_DP = 8;
/**
* The amount of padding between the icon and text of a button. Used only if there is an icon.
*/
private static final int BUTTON_ICON_PADDING_DP = 8;
/**
* The size of the work profile badge (width and height).
*/
private static final int WORK_PROFILE_BADGE_SIZE_DP = 16;
/**
* Material Grey 600 - to be applied to action button icons in the Material theme.
*/
private static final int BUTTON_ICON_COLOR_MATERIAL = 0xff757575;
private final Context mContext;
public CustomNotificationBuilder(Context context) {
super(context.getResources());
mContext = context;
}
@Override
public Notification build() {
// A note about RemoteViews and updating notifications. When a notification is passed to the
// {@code NotificationManager} with the same tag and id as a previous notification, an
// in-place update will be performed. In that case, the actions of all new
// {@link RemoteViews} will be applied to the views of the old notification. This is safe
// for actions that overwrite old values such as setting the text of a {@code TextView}, but
// care must be taken for additive actions. Especially in the case of
// {@link RemoteViews#addView} the result could be to append new views below stale ones. In
// that case {@link RemoteViews#removeAllViews} must be called before adding new ones.
RemoteViews compactView =
new RemoteViews(mContext.getPackageName(), R.layout.web_notification);
RemoteViews bigView =
new RemoteViews(mContext.getPackageName(), R.layout.web_notification_big);
float fontScale = mContext.getResources().getConfiguration().fontScale;
bigView.setInt(R.id.body, "setMaxLines", calculateMaxBodyLines(fontScale));
int scaledPadding =
calculateScaledPadding(fontScale, mContext.getResources().getDisplayMetrics());
String formattedTime = "";
// Temporarily allowing disk access. TODO: Fix. See http://crbug.com/577185
StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskReads();
StrictMode.allowThreadDiskWrites();
try {
long time = SystemClock.elapsedRealtime();
formattedTime = DateFormat.getTimeFormat(mContext).format(new Date());
RecordHistogram.recordTimesHistogram("Android.StrictMode.NotificationUIBuildTime",
SystemClock.elapsedRealtime() - time, TimeUnit.MILLISECONDS);
} finally {
StrictMode.setThreadPolicy(oldPolicy);
}
for (RemoteViews view : new RemoteViews[] {compactView, bigView}) {
view.setTextViewText(R.id.time, formattedTime);
view.setTextViewText(R.id.title, mTitle);
view.setTextViewText(R.id.body, mBody);
view.setTextViewText(R.id.origin, mOrigin);
view.setImageViewBitmap(R.id.icon, getNormalizedLargeIcon());
view.setViewPadding(R.id.title, 0, scaledPadding, 0, 0);
view.setViewPadding(R.id.body_container, 0, scaledPadding, 0, scaledPadding);
addWorkProfileBadge(view);
int smallIconId = useMaterial() ? R.id.small_icon_overlay : R.id.small_icon_footer;
view.setViewVisibility(smallIconId, View.VISIBLE);
if (mSmallIconBitmap != null) {
view.setImageViewBitmap(smallIconId, mSmallIconBitmap);
} else {
view.setImageViewResource(smallIconId, mSmallIconId);
}
}
addActionButtons(bigView);
configureSettingsButton(bigView);
// Note: this is not a NotificationCompat builder so be mindful of the
// API level of methods you call on the builder.
Notification.Builder builder = new Notification.Builder(mContext);
builder.setTicker(mTickerText);
builder.setContentIntent(mContentIntent);
builder.setDeleteIntent(mDeleteIntent);
builder.setDefaults(mDefaults);
builder.setVibrate(mVibratePattern);
builder.setWhen(mTimestamp);
builder.setOnlyAlertOnce(!mRenotify);
builder.setContent(compactView);
// Some things are duplicated in the builder to ensure the notification shows correctly on
// Wear devices and custom lock screens.
builder.setContentTitle(mTitle);
builder.setContentText(mBody);
builder.setSubText(mOrigin);
builder.setLargeIcon(getNormalizedLargeIcon());
setSmallIconOnBuilder(builder, mSmallIconId, mSmallIconBitmap);
for (Action action : mActions) {
addActionToBuilder(builder, action);
}
if (mSettingsAction != null) {
addActionToBuilder(builder, mSettingsAction);
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
// Notification.Builder.setPublicVersion was added in Android L.
builder.setPublicVersion(createPublicNotification(mContext));
}
Notification notification = builder.build();
notification.bigContentView = bigView;
return notification;
}
/**
* If there are actions, shows the button related views, and adds a button for each action.
*/
private void addActionButtons(RemoteViews bigView) {
// Remove the existing buttons in case an existing notification is being updated.
bigView.removeAllViews(R.id.buttons);
// Always set the visibility of the views associated with the action buttons. The current
// visibility state is not known as perhaps an existing notification is being updated.
int visibility = mActions.isEmpty() ? View.GONE : View.VISIBLE;
bigView.setViewVisibility(R.id.button_divider, visibility);
bigView.setViewVisibility(R.id.buttons, visibility);
Resources resources = mContext.getResources();
DisplayMetrics metrics = resources.getDisplayMetrics();
for (Action action : mActions) {
RemoteViews view =
new RemoteViews(mContext.getPackageName(), R.layout.web_notification_button);
// If there is an icon then set it and add some padding.
if (action.iconBitmap != null || action.iconId != 0) {
if (useMaterial()) {
view.setInt(R.id.button_icon, "setColorFilter", BUTTON_ICON_COLOR_MATERIAL);
}
int iconWidth = 0;
if (action.iconBitmap != null) {
view.setImageViewBitmap(R.id.button_icon, action.iconBitmap);
iconWidth = action.iconBitmap.getWidth();
} else if (action.iconId != 0) {
view.setImageViewResource(R.id.button_icon, action.iconId);
BitmapFactory.Options options = new BitmapFactory.Options();
options.inJustDecodeBounds = true;
BitmapFactory.decodeResource(resources, action.iconId, options);
iconWidth = options.outWidth;
}
iconWidth = dpToPx(
Math.min(pxToDp(iconWidth, metrics), MAX_ACTION_ICON_WIDTH_DP), metrics);
// Set the padding of the button so the text does not overlap with the icon. Flip
// between left and right manually as RemoteViews does not expose a method that sets
// padding in a writing-direction independent way.
int buttonPadding =
dpToPx(BUTTON_PADDING_START_DP + BUTTON_ICON_PADDING_DP, metrics)
+ iconWidth;
int buttonPaddingLeft = LocalizationUtils.isLayoutRtl() ? 0 : buttonPadding;
int buttonPaddingRight = LocalizationUtils.isLayoutRtl() ? buttonPadding : 0;
view.setViewPadding(R.id.button, buttonPaddingLeft, 0, buttonPaddingRight, 0);
}
view.setTextViewText(R.id.button, action.title);
view.setOnClickPendingIntent(R.id.button, action.intent);
bigView.addView(R.id.buttons, view);
}
}
private void configureSettingsButton(RemoteViews bigView) {
if (mSettingsAction == null) {
return;
}
bigView.setOnClickPendingIntent(R.id.origin, mSettingsAction.intent);
if (useMaterial()) {
bigView.setInt(R.id.origin_settings_icon, "setColorFilter", BUTTON_ICON_COLOR_MATERIAL);
}
}
/**
* Shows the work profile badge if it is needed.
*/
private void addWorkProfileBadge(RemoteViews view) {
Resources resources = mContext.getResources();
DisplayMetrics metrics = resources.getDisplayMetrics();
int size = dpToPx(WORK_PROFILE_BADGE_SIZE_DP, metrics);
int[] colors = new int[size * size];
// Create an immutable bitmap, so that it can not be reused for painting a badge into it.
Bitmap bitmap = Bitmap.createBitmap(colors, size, size, Bitmap.Config.ARGB_8888);
Drawable inputDrawable = new BitmapDrawable(resources, bitmap);
Drawable outputDrawable = ApiCompatibilityUtils.getUserBadgedDrawableForDensity(
mContext, inputDrawable, null /* badgeLocation */, metrics.densityDpi);
// The input bitmap is immutable, so the output drawable will be a different instance from
// the input drawable if the work profile badge was applied.
if (inputDrawable != outputDrawable && outputDrawable instanceof BitmapDrawable) {
view.setImageViewBitmap(
R.id.work_profile_badge, ((BitmapDrawable) outputDrawable).getBitmap());
view.setViewVisibility(R.id.work_profile_badge, View.VISIBLE);
}
}
/**
* Scales down the maximum number of displayed lines in the body text if font scaling is greater
* than 1.0. Never scales up the number of lines, as on some devices the notification text is
* rendered in dp units (which do not scale) and additional lines could lead to cropping at the
* bottom of the notification.
*
* @param fontScale The current system font scaling factor.
* @return The number of lines to be displayed.
*/
@VisibleForTesting
static int calculateMaxBodyLines(float fontScale) {
if (fontScale > 1.0f) {
return (int) Math.round(Math.ceil((1 / fontScale) * MAX_BODY_LINES));
}
return MAX_BODY_LINES;
}
/**
* Scales down the maximum amount of flexible padding to use if font scaling is over 1.0. Never
* scales up the amount of padding, as on some devices the notification text is rendered in dp
* units (which do not scale) and additional padding could lead to cropping at the bottom of the
* notification. Never scales the padding below zero.
*
* @param fontScale The current system font scaling factor.
* @param displayMetrics The display metrics for the current context.
* @return The amount of padding to be used, in pixels.
*/
@VisibleForTesting
static int calculateScaledPadding(float fontScale, DisplayMetrics metrics) {
float paddingScale = 1.0f;
if (fontScale > 1.0f) {
fontScale = Math.min(fontScale, FONT_SCALE_LARGE);
paddingScale = (FONT_SCALE_LARGE - fontScale) / (FONT_SCALE_LARGE - 1.0f);
}
return dpToPx(paddingScale * MAX_SCALABLE_PADDING_DP, metrics);
}
/**
* Converts a dp value to a px value.
*/
private static int dpToPx(float value, DisplayMetrics metrics) {
return Math.round(TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value, metrics));
}
/**
* Converts a px value to a dp value.
*/
private static int pxToDp(float value, DisplayMetrics metrics) {
return Math.round(value / ((float) metrics.densityDpi / DisplayMetrics.DENSITY_DEFAULT));
}
/**
* Whether to use the Material look and feel or fall back to Holo.
*/
@VisibleForTesting
static boolean useMaterial() {
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP;
}
}